mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-02 13:11:03 -07:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 366036a197 | |||
| 55620235c0 | |||
| 366f9e4766 | |||
| 06e7621b26 | |||
| 8d05bdbe49 | |||
| 5b1f7375a3 | |||
| d613dd05db | |||
| a6d43cba2d | |||
| 161ba28966 | |||
| 05aa1465fe | |||
| 8f6edc50c1 | |||
| 88ddcab616 |
@@ -0,0 +1,132 @@
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
|
||||
|
||||
name: 'Agent Session Drift Check'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'release/**'
|
||||
paths:
|
||||
- 'packages/cli/src/nonInteractiveCli.ts'
|
||||
- 'packages/cli/src/nonInteractiveCliAgentSession.ts'
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}'
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-drift:
|
||||
name: 'Check Agent Session Drift'
|
||||
runs-on: 'ubuntu-latest'
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
permissions:
|
||||
contents: 'read'
|
||||
pull-requests: 'write'
|
||||
steps:
|
||||
- name: 'Detect drift and comment'
|
||||
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' # ratchet:actions/github-script@v8
|
||||
with:
|
||||
script: |-
|
||||
// === Pair configuration — append here to cover more pairs ===
|
||||
const PAIRS = [
|
||||
{
|
||||
legacy: 'packages/cli/src/nonInteractiveCli.ts',
|
||||
session: 'packages/cli/src/nonInteractiveCliAgentSession.ts',
|
||||
label: 'non-interactive CLI',
|
||||
},
|
||||
// Future pairs can be added here. Remember to also add both
|
||||
// paths to the `paths:` filter at the top of this workflow.
|
||||
// Example:
|
||||
// {
|
||||
// legacy: 'packages/core/src/agents/local-invocation.ts',
|
||||
// session: 'packages/core/src/agents/local-session-invocation.ts',
|
||||
// label: 'local subagent invocation',
|
||||
// },
|
||||
];
|
||||
// ============================================================
|
||||
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
// Use the API to list changed files — no checkout/git diff needed.
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
const changed = new Set(files.map((f) => f.filename));
|
||||
|
||||
const warnings = [];
|
||||
for (const { legacy, session, label } of PAIRS) {
|
||||
const legacyChanged = changed.has(legacy);
|
||||
const sessionChanged = changed.has(session);
|
||||
if (legacyChanged && !sessionChanged) {
|
||||
warnings.push(
|
||||
`**${label}**: \`${legacy}\` was modified but \`${session}\` was not.`,
|
||||
);
|
||||
} else if (!legacyChanged && sessionChanged) {
|
||||
warnings.push(
|
||||
`**${label}**: \`${session}\` was modified but \`${legacy}\` was not.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const MARKER = '<!-- agent-session-drift-check -->';
|
||||
|
||||
// Look up our existing drift comment (for upsert/cleanup).
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
const existing = comments.find(
|
||||
(c) => c.user?.type === 'Bot' && c.body?.includes(MARKER),
|
||||
);
|
||||
|
||||
if (warnings.length === 0) {
|
||||
core.info('No drift detected.');
|
||||
// If drift was previously flagged and is now resolved, remove the comment.
|
||||
if (existing) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existing.id,
|
||||
});
|
||||
core.info(`Deleted stale drift comment ${existing.id}.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const body = [
|
||||
MARKER,
|
||||
'### ⚠️ Invocation Drift Warning',
|
||||
'',
|
||||
'The following file pairs should generally be kept in sync during the AgentSession migration:',
|
||||
'',
|
||||
...warnings.map((w) => `- ${w}`),
|
||||
'',
|
||||
'If this is intentional (e.g., a bug fix specific to one implementation), you can ignore this comment.',
|
||||
'',
|
||||
'_This check will be removed once the legacy implementations are deleted._',
|
||||
].join('\n');
|
||||
|
||||
if (existing) {
|
||||
core.info(`Updating existing drift comment ${existing.id}.`);
|
||||
await github.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
core.info('Creating new drift comment.');
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body,
|
||||
});
|
||||
}
|
||||
@@ -183,7 +183,7 @@ jobs:
|
||||
needs:
|
||||
- 'merge_queue_skipper'
|
||||
- 'parse_run_context'
|
||||
runs-on: 'macos-latest'
|
||||
runs-on: 'macos-latest-large'
|
||||
if: |
|
||||
github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
|
||||
steps:
|
||||
|
||||
@@ -224,7 +224,7 @@ jobs:
|
||||
|
||||
test_mac:
|
||||
name: 'Test (Mac) - ${{ matrix.node-version }}, ${{ matrix.shard }}'
|
||||
runs-on: 'macos-latest'
|
||||
runs-on: 'macos-latest-large'
|
||||
needs:
|
||||
- 'merge_queue_skipper'
|
||||
if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'"
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
|
||||
deflake_e2e_mac:
|
||||
name: 'E2E Test (macOS)'
|
||||
runs-on: 'macos-latest'
|
||||
runs-on: 'macos-latest-large'
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Latest stable release: v0.37.1
|
||||
# Latest stable release: v0.37.2
|
||||
|
||||
Released: April 09, 2026
|
||||
Released: April 13, 2026
|
||||
|
||||
For most users, our latest stable release is the recommended release. Install
|
||||
the latest stable version with:
|
||||
@@ -26,6 +26,9 @@ npm install -g @google/gemini-cli
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(patch): cherry-pick 9d741ab to release/v0.37.1-pr-24565 to patch version
|
||||
v0.37.1 and create version 0.37.2 by @gemini-cli-robot in
|
||||
[#25322](https://github.com/google-gemini/gemini-cli/pull/25322)
|
||||
- fix(acp): handle all InvalidStreamError types gracefully in prompt
|
||||
[#24540](https://github.com/google-gemini/gemini-cli/pull/24540)
|
||||
- feat(acp): add support for /about command
|
||||
@@ -422,4 +425,4 @@ npm install -g @google/gemini-cli
|
||||
[#24842](https://github.com/google-gemini/gemini-cli/pull/24842)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.36.0...v0.37.1
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.36.0...v0.37.2
|
||||
|
||||
Generated
+15
-328
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -1727,29 +1727,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@joshua.litt/get-ripgrep": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@joshua.litt/get-ripgrep/-/get-ripgrep-0.0.3.tgz",
|
||||
"integrity": "sha512-rycdieAKKqXi2bsM7G2ayDiNk5CAX8ZOzsTQsirfOqUKPef04Xw40BWGGyimaOOuvPgLWYt3tPnLLG3TvPXi5Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lvce-editor/verror": "^1.6.0",
|
||||
"execa": "^9.5.2",
|
||||
"extract-zip": "^2.0.1",
|
||||
"fs-extra": "^11.3.0",
|
||||
"got": "^14.4.5",
|
||||
"path-exists": "^5.0.0",
|
||||
"xdg-basedir": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@joshua.litt/get-ripgrep/node_modules/path-exists": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz",
|
||||
"integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -1932,12 +1909,6 @@
|
||||
"integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lvce-editor/verror": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@lvce-editor/verror/-/verror-1.7.0.tgz",
|
||||
"integrity": "sha512-+LGuAEIC2L7pbvkyAQVWM2Go0dAy+UWEui28g07zNtZsCBhm+gusBK8PNwLJLV5Jay+TyUYuwLIbJdjLLzqEBg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lydell/node-pty": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@lydell/node-pty/-/node-pty-1.1.0.tgz",
|
||||
@@ -3590,18 +3561,6 @@
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
}
|
||||
},
|
||||
"node_modules/@sindresorhus/is": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.0.2.tgz",
|
||||
"integrity": "sha512-d9xRovfKNz1SKieM0qJdO+PQonjnnIfSNWfHYnBSJ9hkjm0ZPw6HlxscDXYstp3z+7V2GOFHc+J0CYrYTjqCJw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sindresorhus/is?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@sindresorhus/merge-streams": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
|
||||
@@ -3614,18 +3573,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@szmarczak/http-timer": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz",
|
||||
"integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"defer-to-connect": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/@textlint/ast-node-types": {
|
||||
"version": "15.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.2.2.tgz",
|
||||
@@ -3931,12 +3878,6 @@
|
||||
"integrity": "sha512-pUY3cKH/Nm2yYrEmDlPR1mR7yszjGx4DrwPjQ702C4/D5CwHuZTgZdIdwPkRbcuhs7BAh2L5rg3CL5cbRiGTCQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/http-cache-semantics": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz",
|
||||
"integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/http-errors": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||
@@ -6008,33 +5949,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cacheable-lookup": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz",
|
||||
"integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/cacheable-request": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-12.0.1.tgz",
|
||||
"integrity": "sha512-Yo9wGIQUaAfIbk+qY0X4cDQgCosecfBe3V9NSyeY4qPC2SAkbCS4Xj79VP8WOzitpJUZKc/wsRCYF5ariDIwkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-cache-semantics": "^4.0.4",
|
||||
"get-stream": "^9.0.1",
|
||||
"http-cache-semantics": "^4.1.1",
|
||||
"keyv": "^4.5.4",
|
||||
"mimic-response": "^4.0.0",
|
||||
"normalize-url": "^8.0.1",
|
||||
"responselike": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
|
||||
@@ -6968,33 +6882,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mimic-response": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/decompress-response/node_modules/mimic-response": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-eql": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||
@@ -7057,15 +6944,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/defer-to-connect": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
|
||||
"integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/define-data-property": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||
@@ -8428,9 +8306,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/execa": {
|
||||
"version": "9.6.0",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.0.tgz",
|
||||
"integrity": "sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==",
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
|
||||
"integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sindresorhus/merge-streams": "^4.0.0",
|
||||
@@ -8950,15 +8828,6 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data-encoder": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-4.1.0.tgz",
|
||||
"integrity": "sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
@@ -9586,43 +9455,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/got": {
|
||||
"version": "14.4.8",
|
||||
"resolved": "https://registry.npmjs.org/got/-/got-14.4.8.tgz",
|
||||
"integrity": "sha512-vxwU4HuR0BIl+zcT1LYrgBjM+IJjNElOjCzs0aPgHorQyr/V6H6Y73Sn3r3FOlUffvWD+Q5jtRuGWaXkU8Jbhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sindresorhus/is": "^7.0.1",
|
||||
"@szmarczak/http-timer": "^5.0.1",
|
||||
"cacheable-lookup": "^7.0.0",
|
||||
"cacheable-request": "^12.0.1",
|
||||
"decompress-response": "^6.0.0",
|
||||
"form-data-encoder": "^4.0.2",
|
||||
"http2-wrapper": "^2.2.1",
|
||||
"lowercase-keys": "^3.0.0",
|
||||
"p-cancelable": "^4.0.1",
|
||||
"responselike": "^3.0.0",
|
||||
"type-fest": "^4.26.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sindresorhus/got?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/got/node_modules/type-fest": {
|
||||
"version": "4.41.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
||||
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
@@ -9878,12 +9710,6 @@
|
||||
"entities": "^4.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-cache-semantics": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
||||
"integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
@@ -9917,19 +9743,6 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/http2-wrapper": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz",
|
||||
"integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"quick-lru": "^5.1.1",
|
||||
"resolve-alpn": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
@@ -11040,6 +10853,7 @@
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-parse-better-errors": {
|
||||
@@ -11235,6 +11049,7 @@
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-buffer": "3.0.1"
|
||||
@@ -11699,18 +11514,6 @@
|
||||
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lowercase-keys": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz",
|
||||
"integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lowlight": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz",
|
||||
@@ -11981,18 +11784,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-response": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz",
|
||||
"integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
||||
@@ -12371,18 +12162,6 @@
|
||||
"node": "^16.14.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-url": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.2.tgz",
|
||||
"integrity": "sha512-Ee/R3SyN4BuynXcnTaekmaVdbDAEiNrHqjQIA37mHU8G9pf7aaAD4ZX3XjBLo6rsdcxA/gtkcNYZLt30ACgynw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/npm-normalize-package-bin": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz",
|
||||
@@ -12895,15 +12674,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/p-cancelable": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-4.0.1.tgz",
|
||||
"integrity": "sha512-wBowNApzd45EIKdO1LaU+LrMBwAcjfPaYtVzV3lmfM3gf8Z4CHZsiIqlM8TZZ8okYvh5A1cP6gTfCRQtwUpaUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
@@ -13743,18 +13513,6 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quick-lru": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
|
||||
"integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
@@ -14195,12 +13953,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-alpn": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
|
||||
"integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve-dir": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz",
|
||||
@@ -14235,21 +13987,6 @@
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/responselike": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/responselike/-/responselike-3.0.0.tgz",
|
||||
"integrity": "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lowercase-keys": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/restore-cursor": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz",
|
||||
@@ -17644,18 +17381,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xdg-basedir": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz",
|
||||
"integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/xml2js": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
|
||||
@@ -17864,7 +17589,7 @@
|
||||
},
|
||||
"packages/a2a-server": {
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
"@google-cloud/storage": "^7.16.0",
|
||||
@@ -17979,7 +17704,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.16.1",
|
||||
@@ -18079,44 +17804,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"packages/cli/node_modules/execa": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
|
||||
"integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sindresorhus/merge-streams": "^4.0.0",
|
||||
"cross-spawn": "^7.0.6",
|
||||
"figures": "^6.1.0",
|
||||
"get-stream": "^9.0.0",
|
||||
"human-signals": "^8.0.1",
|
||||
"is-plain-obj": "^4.1.0",
|
||||
"is-stream": "^4.0.1",
|
||||
"npm-run-path": "^6.0.0",
|
||||
"pretty-ms": "^9.2.0",
|
||||
"signal-exit": "^4.1.0",
|
||||
"strip-final-newline": "^4.0.0",
|
||||
"yoctocolors": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.5.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sindresorhus/execa?sponsor=1"
|
||||
}
|
||||
},
|
||||
"packages/cli/node_modules/is-stream": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz",
|
||||
"integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"packages/cli/node_modules/string-width": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz",
|
||||
@@ -18151,7 +17838,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
@@ -18162,7 +17849,6 @@
|
||||
"@google/genai": "1.30.0",
|
||||
"@grpc/grpc-js": "^1.14.3",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@joshua.litt/get-ripgrep": "^0.0.3",
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/api-logs": "^0.211.0",
|
||||
@@ -18190,6 +17876,7 @@
|
||||
"diff": "^8.0.3",
|
||||
"dotenv": "^17.2.4",
|
||||
"dotenv-expand": "^12.0.3",
|
||||
"execa": "^9.6.1",
|
||||
"fast-levenshtein": "^2.0.6",
|
||||
"fdir": "^6.4.6",
|
||||
"fzf": "^0.5.2",
|
||||
@@ -18418,7 +18105,7 @@
|
||||
},
|
||||
"packages/devtools": {
|
||||
"name": "@google/gemini-cli-devtools",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"ws": "^8.16.0"
|
||||
@@ -18433,7 +18120,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@google/gemini-cli-sdk",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -18450,7 +18137,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -18468,7 +18155,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -14,7 +14,7 @@
|
||||
"url": "git+https://github.com/google-gemini/gemini-cli.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.39.0-nightly.20260408.e77b22e63"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.40.0-nightly.20260414.g5b1f7375a"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"description": "Gemini CLI",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -27,7 +27,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.39.0-nightly.20260408.e77b22e63"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.40.0-nightly.20260414.g5b1f7375a"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.16.1",
|
||||
|
||||
@@ -529,6 +529,7 @@ export async function runNonInteractive(
|
||||
// Cleanup stdin cancellation before other cleanup
|
||||
cleanupStdinCancellation();
|
||||
|
||||
await config.getGeminiClient()?.shutdownSessionServices?.();
|
||||
scheduler?.dispose();
|
||||
consolePatcher.cleanup();
|
||||
coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback);
|
||||
|
||||
@@ -616,6 +616,7 @@ export async function runNonInteractive({
|
||||
cleanupStdinCancellation();
|
||||
abortController.signal.removeEventListener('abort', abortSession);
|
||||
|
||||
await config.getGeminiClient()?.shutdownSessionServices?.();
|
||||
scheduler?.dispose();
|
||||
consolePatcher.cleanup();
|
||||
coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback);
|
||||
|
||||
@@ -92,7 +92,6 @@ import {
|
||||
ApiKeyUpdatedEvent,
|
||||
LegacyAgentProtocol,
|
||||
type InjectionSource,
|
||||
startMemoryService,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { validateAuthMethod } from '../config/auth.js';
|
||||
import process from 'node:process';
|
||||
@@ -482,13 +481,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
setConfigInitialized(true);
|
||||
startupProfiler.flush(config);
|
||||
|
||||
// Fire-and-forget memory service (skill extraction from past sessions)
|
||||
if (config.isMemoryManagerEnabled()) {
|
||||
startMemoryService(config).catch((e) => {
|
||||
debugLogger.error('Failed to start memory service:', e);
|
||||
});
|
||||
}
|
||||
|
||||
const sessionStartSource = resumedSessionData
|
||||
? SessionStartSource.Resume
|
||||
: SessionStartSource.Startup;
|
||||
@@ -540,6 +532,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
|
||||
// Fire SessionEnd hook on cleanup (only if hooks are enabled)
|
||||
await config?.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit);
|
||||
await config?.getGeminiClient()?.shutdownSessionServices?.();
|
||||
};
|
||||
registerCleanup(cleanupFn);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { ApiAuthDialog } from './ApiAuthDialog.js';
|
||||
@@ -40,11 +40,16 @@ vi.mock('../components/shared/text-buffer.js', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../contexts/UIStateContext.js', () => ({
|
||||
useUIState: vi.fn(() => ({
|
||||
terminalWidth: 80,
|
||||
})),
|
||||
}));
|
||||
vi.mock('../contexts/UIStateContext.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../contexts/UIStateContext.js')>();
|
||||
return {
|
||||
...actual,
|
||||
useUIState: vi.fn(() => ({
|
||||
terminalWidth: 80,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedUseKeypress = useKeypress as Mock;
|
||||
const mockedUseTextBuffer = useTextBuffer as Mock;
|
||||
@@ -73,7 +78,7 @@ describe('ApiAuthDialog', () => {
|
||||
});
|
||||
|
||||
it('renders correctly', async () => {
|
||||
const { lastFrame, unmount } = await render(
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -81,7 +86,7 @@ describe('ApiAuthDialog', () => {
|
||||
});
|
||||
|
||||
it('renders with a defaultValue', async () => {
|
||||
const { unmount } = await render(
|
||||
const { unmount } = await renderWithProviders(
|
||||
<ApiAuthDialog
|
||||
onSubmit={onSubmit}
|
||||
onCancel={onCancel}
|
||||
@@ -111,7 +116,7 @@ describe('ApiAuthDialog', () => {
|
||||
'calls $expectedCall.name when $keyName is pressed',
|
||||
async ({ keyName, sequence, expectedCall, args }) => {
|
||||
mockBuffer.text = 'submitted-key'; // Set for the onSubmit case
|
||||
const { unmount } = await render(
|
||||
const { unmount } = await renderWithProviders(
|
||||
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
|
||||
);
|
||||
// calls[0] is the ApiAuthDialog's useKeypress (Ctrl+C handler)
|
||||
@@ -133,7 +138,7 @@ describe('ApiAuthDialog', () => {
|
||||
);
|
||||
|
||||
it('displays an error message', async () => {
|
||||
const { lastFrame, unmount } = await render(
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<ApiAuthDialog
|
||||
onSubmit={onSubmit}
|
||||
onCancel={onCancel}
|
||||
@@ -146,7 +151,7 @@ describe('ApiAuthDialog', () => {
|
||||
});
|
||||
|
||||
it('calls clearApiKey and clears buffer when Ctrl+C is pressed', async () => {
|
||||
const { unmount } = await render(
|
||||
const { unmount } = await renderWithProviders(
|
||||
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
|
||||
);
|
||||
// Call 0 is ApiAuthDialog (isActive: true)
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
useReducer,
|
||||
useContext,
|
||||
} from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Box, Text, type DOMElement } from 'ink';
|
||||
import { useMouseClick } from '../hooks/useMouseClick.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { checkExhaustive, type Question } from '@google/gemini-cli-core';
|
||||
import { BaseSelectionList } from './shared/BaseSelectionList.js';
|
||||
@@ -85,6 +86,24 @@ function autoBoldIfPlain(text: string): string {
|
||||
return text;
|
||||
}
|
||||
|
||||
const ClickableCheckbox: React.FC<{
|
||||
isChecked: boolean;
|
||||
onClick: () => void;
|
||||
}> = ({ isChecked, onClick }) => {
|
||||
const ref = useRef<DOMElement>(null);
|
||||
useMouseClick(ref, () => {
|
||||
onClick();
|
||||
});
|
||||
|
||||
return (
|
||||
<Box ref={ref}>
|
||||
<Text color={isChecked ? theme.status.success : theme.text.secondary}>
|
||||
[{isChecked ? 'x' : ' '}]
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
interface AskUserDialogState {
|
||||
answers: { [key: string]: string };
|
||||
isEditingCustomOption: boolean;
|
||||
@@ -919,13 +938,14 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
{showCheck && (
|
||||
<Text
|
||||
color={
|
||||
isChecked ? theme.status.success : theme.text.secondary
|
||||
}
|
||||
>
|
||||
[{isChecked ? 'x' : ' '}]
|
||||
</Text>
|
||||
<ClickableCheckbox
|
||||
isChecked={isChecked}
|
||||
onClick={() => {
|
||||
if (!context.isSelected) {
|
||||
handleSelect(optionItem);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Text color={theme.text.primary}> </Text>
|
||||
<TextInput
|
||||
@@ -966,13 +986,14 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="row">
|
||||
{showCheck && (
|
||||
<Text
|
||||
color={
|
||||
isChecked ? theme.status.success : theme.text.secondary
|
||||
}
|
||||
>
|
||||
[{isChecked ? 'x' : ' '}]
|
||||
</Text>
|
||||
<ClickableCheckbox
|
||||
isChecked={isChecked}
|
||||
onClick={() => {
|
||||
if (!context.isSelected) {
|
||||
handleSelect(optionItem);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Text color={labelColor} bold={optionItem.type === 'done'}>
|
||||
{' '}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import {
|
||||
BaseSelectionList,
|
||||
@@ -14,8 +15,10 @@ import {
|
||||
import { useSelectionList } from '../../hooks/useSelectionList.js';
|
||||
import { Text } from 'ink';
|
||||
import type { theme } from '../../semantic-colors.js';
|
||||
import { useMouseClick } from '../../hooks/useMouseClick.js';
|
||||
|
||||
vi.mock('../../hooks/useSelectionList.js');
|
||||
vi.mock('../../hooks/useMouseClick.js');
|
||||
|
||||
const mockTheme = {
|
||||
text: { primary: 'COLOR_PRIMARY', secondary: 'COLOR_SECONDARY' },
|
||||
@@ -35,6 +38,7 @@ describe('BaseSelectionList', () => {
|
||||
const mockOnSelect = vi.fn();
|
||||
const mockOnHighlight = vi.fn();
|
||||
const mockRenderItem = vi.fn();
|
||||
const mockSetActiveIndex = vi.fn();
|
||||
|
||||
const items = [
|
||||
{ value: 'A', label: 'Item A', key: 'A' },
|
||||
@@ -54,7 +58,7 @@ describe('BaseSelectionList', () => {
|
||||
) => {
|
||||
vi.mocked(useSelectionList).mockReturnValue({
|
||||
activeIndex,
|
||||
setActiveIndex: vi.fn(),
|
||||
setActiveIndex: mockSetActiveIndex,
|
||||
});
|
||||
|
||||
mockRenderItem.mockImplementation(
|
||||
@@ -484,6 +488,79 @@ describe('BaseSelectionList', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mouse Interaction', () => {
|
||||
it('should register mouse click handler for each item', async () => {
|
||||
const { unmount } = await renderComponent();
|
||||
|
||||
// items are A, B (disabled), C
|
||||
expect(useMouseClick).toHaveBeenCalledTimes(3);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should update activeIndex on first click and call onSelect on second click', async () => {
|
||||
const { unmount, waitUntilReady } = await renderComponent();
|
||||
await waitUntilReady();
|
||||
|
||||
// items[0] is 'A' (enabled)
|
||||
// items[1] is 'B' (disabled)
|
||||
// items[2] is 'C' (enabled)
|
||||
|
||||
// Get the mouse click handler for the third item (index 2)
|
||||
const mouseClickHandler = (useMouseClick as Mock).mock.calls[2][1];
|
||||
|
||||
// First click on item C
|
||||
act(() => {
|
||||
mouseClickHandler();
|
||||
});
|
||||
|
||||
expect(mockSetActiveIndex).toHaveBeenCalledWith(2);
|
||||
expect(mockOnSelect).not.toHaveBeenCalled();
|
||||
|
||||
// Now simulate being on item C (isSelected = true)
|
||||
// Rerender or update mocks for the next check
|
||||
await renderComponent({}, 2);
|
||||
|
||||
// Get the updated mouse click handler for item C
|
||||
// useMouseClick is called 3 more times on rerender
|
||||
const updatedMouseClickHandler = (useMouseClick as Mock).mock.calls[5][1];
|
||||
|
||||
// Second click on item C
|
||||
act(() => {
|
||||
updatedMouseClickHandler();
|
||||
});
|
||||
|
||||
expect(mockOnSelect).toHaveBeenCalledWith('C');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not call onSelect when a disabled item is clicked', async () => {
|
||||
const { unmount, waitUntilReady } = await renderComponent();
|
||||
await waitUntilReady();
|
||||
|
||||
// items[1] is 'B' (disabled)
|
||||
const mouseClickHandler = (useMouseClick as Mock).mock.calls[1][1];
|
||||
|
||||
act(() => {
|
||||
mouseClickHandler();
|
||||
});
|
||||
|
||||
expect(mockSetActiveIndex).not.toHaveBeenCalled();
|
||||
expect(mockOnSelect).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should pass isActive: isFocused to useMouseClick', async () => {
|
||||
const { unmount } = await renderComponent({ isFocused: false });
|
||||
|
||||
expect(useMouseClick).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.any(Function),
|
||||
{ isActive: false },
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scroll Arrows (showScrollArrows)', () => {
|
||||
const longList = Array.from({ length: 10 }, (_, i) => ({
|
||||
value: `Item ${i + 1}`,
|
||||
|
||||
@@ -5,13 +5,14 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import { useState, useRef } from 'react';
|
||||
import { Text, Box, type DOMElement } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import {
|
||||
useSelectionList,
|
||||
type SelectionListItem,
|
||||
} from '../../hooks/useSelectionList.js';
|
||||
import { useMouseClick } from '../../hooks/useMouseClick.js';
|
||||
|
||||
export interface RenderItemContext {
|
||||
isSelected: boolean;
|
||||
@@ -38,6 +39,119 @@ export interface BaseSelectionListProps<
|
||||
renderItem: (item: TItem, context: RenderItemContext) => React.ReactNode;
|
||||
}
|
||||
|
||||
interface SelectionListItemRowProps<
|
||||
T,
|
||||
TItem extends SelectionListItem<T> = SelectionListItem<T>,
|
||||
> {
|
||||
item: TItem;
|
||||
itemIndex: number;
|
||||
isSelected: boolean;
|
||||
isFocused: boolean;
|
||||
showNumbers: boolean;
|
||||
selectedIndicator: string;
|
||||
numberColumnWidth: number;
|
||||
onSelect: (value: T) => void;
|
||||
setActiveIndex: (index: number) => void;
|
||||
renderItem: (item: TItem, context: RenderItemContext) => React.ReactNode;
|
||||
}
|
||||
|
||||
function SelectionListItemRow<
|
||||
T,
|
||||
TItem extends SelectionListItem<T> = SelectionListItem<T>,
|
||||
>({
|
||||
item,
|
||||
itemIndex,
|
||||
isSelected,
|
||||
isFocused,
|
||||
showNumbers,
|
||||
selectedIndicator,
|
||||
numberColumnWidth,
|
||||
onSelect,
|
||||
setActiveIndex,
|
||||
renderItem,
|
||||
}: SelectionListItemRowProps<T, TItem>) {
|
||||
const containerRef = useRef<DOMElement>(null);
|
||||
|
||||
useMouseClick(
|
||||
containerRef,
|
||||
() => {
|
||||
if (!item.disabled) {
|
||||
if (isSelected) {
|
||||
// Second click on the same item triggers submission
|
||||
onSelect(item.value);
|
||||
} else {
|
||||
// First click highlights the item
|
||||
setActiveIndex(itemIndex);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: isFocused },
|
||||
);
|
||||
|
||||
let titleColor = theme.text.primary;
|
||||
let numberColor = theme.text.primary;
|
||||
|
||||
if (isSelected) {
|
||||
titleColor = theme.ui.focus;
|
||||
numberColor = theme.ui.focus;
|
||||
} else if (item.disabled) {
|
||||
titleColor = theme.text.secondary;
|
||||
numberColor = theme.text.secondary;
|
||||
}
|
||||
|
||||
if (!isFocused && !item.disabled) {
|
||||
numberColor = theme.text.secondary;
|
||||
}
|
||||
|
||||
if (!showNumbers) {
|
||||
numberColor = theme.text.secondary;
|
||||
}
|
||||
|
||||
const itemNumberText = `${String(itemIndex + 1).padStart(
|
||||
numberColumnWidth,
|
||||
)}.`;
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={containerRef}
|
||||
key={item.key}
|
||||
alignItems="flex-start"
|
||||
backgroundColor={isSelected ? theme.background.focus : undefined}
|
||||
>
|
||||
{/* Radio button indicator */}
|
||||
<Box minWidth={2} flexShrink={0}>
|
||||
<Text
|
||||
color={isSelected ? theme.ui.focus : theme.text.primary}
|
||||
aria-hidden
|
||||
>
|
||||
{isSelected ? selectedIndicator : ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Item number */}
|
||||
{showNumbers && !item.hideNumber && (
|
||||
<Box
|
||||
marginRight={1}
|
||||
flexShrink={0}
|
||||
minWidth={itemNumberText.length}
|
||||
aria-state={{ checked: isSelected }}
|
||||
>
|
||||
<Text color={numberColor}>{itemNumberText}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Custom content via render prop */}
|
||||
<Box flexGrow={1}>
|
||||
{renderItem(item, {
|
||||
isSelected,
|
||||
titleColor,
|
||||
numberColor,
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base component for selection lists that provides common UI structure
|
||||
* and keyboard navigation logic via the useSelectionList hook.
|
||||
@@ -70,7 +184,7 @@ export function BaseSelectionList<
|
||||
selectedIndicator = '●',
|
||||
renderItem,
|
||||
}: BaseSelectionListProps<T, TItem>): React.JSX.Element {
|
||||
const { activeIndex } = useSelectionList({
|
||||
const { activeIndex, setActiveIndex } = useSelectionList({
|
||||
items,
|
||||
initialIndex,
|
||||
onSelect,
|
||||
@@ -107,10 +221,12 @@ export function BaseSelectionList<
|
||||
);
|
||||
const numberColumnWidth = String(items.length).length;
|
||||
|
||||
const showArrows = showScrollArrows && items.length > maxItemsToShow;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* Use conditional coloring instead of conditional rendering */}
|
||||
{showScrollArrows && items.length > maxItemsToShow && (
|
||||
{showArrows && (
|
||||
<Text
|
||||
color={
|
||||
effectiveScrollOffset > 0
|
||||
@@ -126,71 +242,24 @@ export function BaseSelectionList<
|
||||
const itemIndex = effectiveScrollOffset + index;
|
||||
const isSelected = activeIndex === itemIndex;
|
||||
|
||||
// Determine colors based on selection and disabled state
|
||||
let titleColor = theme.text.primary;
|
||||
let numberColor = theme.text.primary;
|
||||
|
||||
if (isSelected) {
|
||||
titleColor = theme.ui.focus;
|
||||
numberColor = theme.ui.focus;
|
||||
} else if (item.disabled) {
|
||||
titleColor = theme.text.secondary;
|
||||
numberColor = theme.text.secondary;
|
||||
}
|
||||
|
||||
if (!isFocused && !item.disabled) {
|
||||
numberColor = theme.text.secondary;
|
||||
}
|
||||
|
||||
if (!showNumbers) {
|
||||
numberColor = theme.text.secondary;
|
||||
}
|
||||
|
||||
const itemNumberText = `${String(itemIndex + 1).padStart(
|
||||
numberColumnWidth,
|
||||
)}.`;
|
||||
|
||||
return (
|
||||
<Box
|
||||
<SelectionListItemRow
|
||||
key={item.key}
|
||||
alignItems="flex-start"
|
||||
backgroundColor={isSelected ? theme.background.focus : undefined}
|
||||
>
|
||||
{/* Radio button indicator */}
|
||||
<Box minWidth={2} flexShrink={0}>
|
||||
<Text
|
||||
color={isSelected ? theme.ui.focus : theme.text.primary}
|
||||
aria-hidden
|
||||
>
|
||||
{isSelected ? selectedIndicator : ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Item number */}
|
||||
{showNumbers && !item.hideNumber && (
|
||||
<Box
|
||||
marginRight={1}
|
||||
flexShrink={0}
|
||||
minWidth={itemNumberText.length}
|
||||
aria-state={{ checked: isSelected }}
|
||||
>
|
||||
<Text color={numberColor}>{itemNumberText}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Custom content via render prop */}
|
||||
<Box flexGrow={1}>
|
||||
{renderItem(item, {
|
||||
isSelected,
|
||||
titleColor,
|
||||
numberColor,
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
item={item}
|
||||
itemIndex={itemIndex}
|
||||
isSelected={isSelected}
|
||||
isFocused={isFocused}
|
||||
showNumbers={showNumbers}
|
||||
selectedIndicator={selectedIndicator}
|
||||
numberColumnWidth={numberColumnWidth}
|
||||
onSelect={onSelect}
|
||||
setActiveIndex={setActiveIndex}
|
||||
renderItem={renderItem}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{showScrollArrows && items.length > maxItemsToShow && (
|
||||
{showArrows && (
|
||||
<Text
|
||||
color={
|
||||
effectiveScrollOffset + maxItemsToShow < items.length
|
||||
|
||||
@@ -11,12 +11,17 @@ import { act } from 'react';
|
||||
import { TextInput } from './TextInput.js';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { useTextBuffer, type TextBuffer } from './text-buffer.js';
|
||||
import { useMouseClick } from '../../hooks/useMouseClick.js';
|
||||
|
||||
// Mocks
|
||||
vi.mock('../../hooks/useKeypress.js', () => ({
|
||||
useKeypress: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useMouseClick.js', () => ({
|
||||
useMouseClick: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./text-buffer.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./text-buffer.js')>();
|
||||
const mockTextBuffer = {
|
||||
@@ -69,6 +74,7 @@ vi.mock('./text-buffer.js', async (importOriginal) => {
|
||||
|
||||
const mockedUseKeypress = useKeypress as Mock;
|
||||
const mockedUseTextBuffer = useTextBuffer as Mock;
|
||||
const mockedUseMouseClick = useMouseClick as Mock;
|
||||
|
||||
describe('TextInput', () => {
|
||||
const onCancel = vi.fn();
|
||||
@@ -84,6 +90,7 @@ describe('TextInput', () => {
|
||||
cursor: [0, 0],
|
||||
visualCursor: [0, 0],
|
||||
viewportVisualLines: [''],
|
||||
visualScrollRow: 0,
|
||||
pastedContent: {} as Record<string, string>,
|
||||
handleInput: vi.fn((key) => {
|
||||
if (key.sequence) {
|
||||
@@ -408,4 +415,36 @@ describe('TextInput', () => {
|
||||
expect(lastFrame()).toContain('line2');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('registers mouse click handler for free-form text input', async () => {
|
||||
const { unmount } = await render(
|
||||
<TextInput buffer={mockBuffer} onSubmit={onSubmit} onCancel={onCancel} />,
|
||||
);
|
||||
|
||||
expect(mockedUseMouseClick).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ isActive: true, name: 'left-press' }),
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('registers mouse click handler for placeholder view', async () => {
|
||||
mockBuffer.text = '';
|
||||
const { unmount } = await render(
|
||||
<TextInput
|
||||
buffer={mockBuffer}
|
||||
placeholder="test"
|
||||
onSubmit={onSubmit}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(mockedUseMouseClick).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ isActive: true, name: 'left-press' }),
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { Text, Box, type DOMElement } from 'ink';
|
||||
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||
import chalk from 'chalk';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
@@ -14,6 +14,7 @@ import { expandPastePlaceholders, type TextBuffer } from './text-buffer.js';
|
||||
import { cpSlice, cpIndexToOffset } from '../../utils/textUtils.js';
|
||||
import { Command } from '../../key/keyMatchers.js';
|
||||
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
|
||||
import { useMouseClick } from '../../hooks/useMouseClick.js';
|
||||
|
||||
export interface TextInputProps {
|
||||
buffer: TextBuffer;
|
||||
@@ -31,6 +32,8 @@ export function TextInput({
|
||||
focus = true,
|
||||
}: TextInputProps): React.JSX.Element {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const containerRef = useRef<DOMElement>(null);
|
||||
|
||||
const {
|
||||
text,
|
||||
handleInput,
|
||||
@@ -40,6 +43,17 @@ export function TextInput({
|
||||
} = buffer;
|
||||
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] = visualCursor;
|
||||
|
||||
useMouseClick(
|
||||
containerRef,
|
||||
(_event, relativeX, relativeY) => {
|
||||
if (focus) {
|
||||
const visRowAbsolute = visualScrollRow + relativeY;
|
||||
buffer.moveToVisualPosition(visRowAbsolute, relativeX);
|
||||
}
|
||||
},
|
||||
{ isActive: focus, name: 'left-press' },
|
||||
);
|
||||
|
||||
const handleKeyPress = useCallback(
|
||||
(key: Key) => {
|
||||
if (key.name === 'escape' && onCancel) {
|
||||
@@ -64,7 +78,7 @@ export function TextInput({
|
||||
|
||||
if (showPlaceholder) {
|
||||
return (
|
||||
<Box>
|
||||
<Box ref={containerRef}>
|
||||
{focus ? (
|
||||
<Text terminalCursorFocus={focus} terminalCursorPosition={0}>
|
||||
{chalk.inverse(placeholder[0] || ' ')}
|
||||
@@ -78,7 +92,7 @@ export function TextInput({
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box ref={containerRef} flexDirection="column">
|
||||
{viewportVisualLines.map((lineText, idx) => {
|
||||
const currentVisualRow = visualScrollRow + idx;
|
||||
const isCursorLine =
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"description": "Gemini CLI Core",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -20,7 +20,8 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"vendor"
|
||||
],
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
@@ -31,7 +32,6 @@
|
||||
"@google/genai": "1.30.0",
|
||||
"@grpc/grpc-js": "^1.14.3",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@joshua.litt/get-ripgrep": "^0.0.3",
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/api-logs": "^0.211.0",
|
||||
@@ -59,6 +59,7 @@
|
||||
"diff": "^8.0.3",
|
||||
"dotenv": "^17.2.4",
|
||||
"dotenv-expand": "^12.0.3",
|
||||
"execa": "^9.6.1",
|
||||
"fast-levenshtein": "^2.0.6",
|
||||
"fdir": "^6.4.6",
|
||||
"fzf": "^0.5.2",
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
isProjectSkillPatchTarget,
|
||||
validateParsedSkillPatchHeaders,
|
||||
} from '../services/memoryPatchUtils.js';
|
||||
import { readExtractionState } from '../services/memoryService.js';
|
||||
import { readExtractionState } from '../services/defaultMemoryProvider.js';
|
||||
import { refreshServerHierarchicalMemory } from '../utils/memoryDiscovery.js';
|
||||
import type { MessageActionReturn, ToolActionReturn } from './types.js';
|
||||
|
||||
|
||||
@@ -3552,6 +3552,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
registry.registerTool(new RipGrepTool(this, this.messageBus)),
|
||||
);
|
||||
} else {
|
||||
debugLogger.warn(`Ripgrep is not available. Falling back to GrepTool.`);
|
||||
logRipgrepFallback(this, new RipgrepFallbackEvent(errorString));
|
||||
maybeRegister(GrepTool, () =>
|
||||
registry.registerTool(new GrepTool(this, this.messageBus)),
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
import { getCoreSystemPrompt } from './prompts.js';
|
||||
import { DEFAULT_GEMINI_MODEL_AUTO } from '../config/models.js';
|
||||
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
|
||||
import { MemoryService } from '../services/memoryService.js';
|
||||
import { setSimulate429 } from '../utils/testUtils.js';
|
||||
import { tokenLimit } from './tokenLimits.js';
|
||||
import { ideContextStore } from '../ide/ideContext.js';
|
||||
@@ -94,6 +95,29 @@ interface MockTurnContext {
|
||||
}
|
||||
|
||||
const mockTurnRunFn = vi.fn();
|
||||
const memoryServiceMocks = vi.hoisted(() => {
|
||||
const onSessionStart = vi.fn();
|
||||
const getSystemInstructions = vi.fn().mockResolvedValue('');
|
||||
const getTurnContext = vi.fn().mockResolvedValue('');
|
||||
const onTurnComplete = vi.fn();
|
||||
const onSessionEnd = vi.fn();
|
||||
const factory = vi.fn(() => ({
|
||||
onSessionStart,
|
||||
getSystemInstructions,
|
||||
getTurnContext,
|
||||
onTurnComplete,
|
||||
onSessionEnd,
|
||||
}));
|
||||
|
||||
return {
|
||||
onSessionStart,
|
||||
getSystemInstructions,
|
||||
getTurnContext,
|
||||
onTurnComplete,
|
||||
onSessionEnd,
|
||||
factory,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./turn', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./turn.js')>();
|
||||
@@ -117,6 +141,9 @@ vi.mock('./turn', async (importOriginal) => {
|
||||
});
|
||||
|
||||
vi.mock('../config/config.js');
|
||||
vi.mock('../services/memoryService.js', () => ({
|
||||
MemoryService: vi.fn().mockImplementation(() => memoryServiceMocks.factory()),
|
||||
}));
|
||||
vi.mock('./prompts');
|
||||
vi.mock('../utils/getFolderStructure', () => ({
|
||||
getFolderStructure: vi.fn().mockResolvedValue('Mock Folder Structure'),
|
||||
@@ -172,6 +199,15 @@ describe('Gemini Client (client.ts)', () => {
|
||||
vi.resetAllMocks();
|
||||
ClearcutLogger.clearInstance();
|
||||
vi.mocked(uiTelemetryService.setLastPromptTokenCount).mockClear();
|
||||
vi.mocked(MemoryService).mockImplementation(
|
||||
() => memoryServiceMocks.factory() as unknown as MemoryService,
|
||||
);
|
||||
memoryServiceMocks.factory.mockClear();
|
||||
memoryServiceMocks.onSessionStart.mockReset();
|
||||
memoryServiceMocks.getSystemInstructions.mockReset().mockResolvedValue('');
|
||||
memoryServiceMocks.getTurnContext.mockReset().mockResolvedValue('');
|
||||
memoryServiceMocks.onTurnComplete.mockReset();
|
||||
memoryServiceMocks.onSessionEnd.mockReset();
|
||||
|
||||
mockGenerateContentFn = vi.fn().mockResolvedValue({
|
||||
candidates: [{ content: { parts: [{ text: '{"key": "value"}' }] } }],
|
||||
@@ -231,6 +267,8 @@ describe('Gemini Client (client.ts)', () => {
|
||||
minPrunableThresholdTokens: 30000,
|
||||
protectLatestTurn: true,
|
||||
}),
|
||||
getExtensions: vi.fn().mockReturnValue([]),
|
||||
isMemoryManagerEnabled: vi.fn().mockReturnValue(false),
|
||||
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
getProxy: vi.fn().mockReturnValue(undefined),
|
||||
@@ -431,6 +469,112 @@ describe('Gemini Client (client.ts)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('memory service integration', () => {
|
||||
it('does not construct the memory service when the experimental flag is off', async () => {
|
||||
vi.mocked(mockConfig.isMemoryManagerEnabled).mockReturnValue(false);
|
||||
|
||||
client.dispose();
|
||||
client = new GeminiClient(mockConfig as unknown as AgentLoopContext);
|
||||
await client.initialize();
|
||||
|
||||
expect(memoryServiceMocks.factory).not.toHaveBeenCalled();
|
||||
expect(memoryServiceMocks.onSessionStart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('initializes the memory service and injects system instructions when the experimental flag is on', async () => {
|
||||
vi.mocked(mockConfig.isMemoryManagerEnabled).mockReturnValue(true);
|
||||
memoryServiceMocks.getSystemInstructions.mockResolvedValue(
|
||||
'Memory instructions',
|
||||
);
|
||||
vi.mocked(getCoreSystemPrompt).mockReturnValue('Base prompt');
|
||||
|
||||
client.dispose();
|
||||
client = new GeminiClient(mockConfig as unknown as AgentLoopContext);
|
||||
await client.initialize();
|
||||
|
||||
const mockChat = {
|
||||
setSystemInstruction: vi.fn(),
|
||||
} as unknown as GeminiChat;
|
||||
client['chat'] = mockChat;
|
||||
client.updateSystemInstruction();
|
||||
|
||||
expect(memoryServiceMocks.factory).toHaveBeenCalledTimes(1);
|
||||
expect(memoryServiceMocks.onSessionStart).toHaveBeenCalledWith(
|
||||
'test-session-id',
|
||||
);
|
||||
expect(mockChat.setSystemInstruction).toHaveBeenCalledWith(
|
||||
'Base prompt\n\n<memory_system_instructions>\nMemory instructions\n</memory_system_instructions>',
|
||||
);
|
||||
});
|
||||
|
||||
it('injects turn context and completes the prompt lifecycle after the final turn', async () => {
|
||||
vi.mocked(mockConfig.isMemoryManagerEnabled).mockReturnValue(true);
|
||||
memoryServiceMocks.getTurnContext.mockResolvedValue('recalled context');
|
||||
mockTurnRunFn.mockReturnValue(
|
||||
(async function* () {
|
||||
yield {
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: 'STOP', usageMetadata: undefined },
|
||||
};
|
||||
})(),
|
||||
);
|
||||
|
||||
const stream = client.sendMessageStream(
|
||||
[{ text: 'How do I deploy this?' }],
|
||||
new AbortController().signal,
|
||||
'prompt-id-memory',
|
||||
);
|
||||
|
||||
await fromAsync(stream);
|
||||
|
||||
expect(memoryServiceMocks.getTurnContext).toHaveBeenCalledWith(
|
||||
'How do I deploy this?',
|
||||
);
|
||||
expect(mockTurnRunFn).toHaveBeenCalledWith(
|
||||
{ model: 'default-routed-model', isChatModel: true },
|
||||
[
|
||||
{ text: 'How do I deploy this?' },
|
||||
{ text: '<memory_context>recalled context</memory_context>' },
|
||||
],
|
||||
expect.any(AbortSignal),
|
||||
undefined,
|
||||
);
|
||||
expect(memoryServiceMocks.onTurnComplete).toHaveBeenCalledWith(
|
||||
'How do I deploy this?',
|
||||
'Mock Response',
|
||||
);
|
||||
});
|
||||
|
||||
it('invokes onSessionEnd when shutdownSessionServices is called', async () => {
|
||||
vi.mocked(mockConfig.isMemoryManagerEnabled).mockReturnValue(true);
|
||||
|
||||
client.dispose();
|
||||
client = new GeminiClient(mockConfig as unknown as AgentLoopContext);
|
||||
await client.initialize();
|
||||
|
||||
await client.shutdownSessionServices();
|
||||
|
||||
expect(memoryServiceMocks.onSessionEnd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('skips the memory service for subagent-scoped clients', async () => {
|
||||
vi.mocked(mockConfig.isMemoryManagerEnabled).mockReturnValue(true);
|
||||
|
||||
client.dispose();
|
||||
const subagentContext = Object.assign(Object.create(mockConfig), {
|
||||
config: mockConfig,
|
||||
promptId: 'subagent-prompt-id',
|
||||
parentSessionId: 'main-prompt-id',
|
||||
}) as AgentLoopContext;
|
||||
const subagentClient = new GeminiClient(subagentContext);
|
||||
|
||||
await subagentClient.initialize();
|
||||
|
||||
expect(memoryServiceMocks.factory).not.toHaveBeenCalled();
|
||||
subagentClient.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tryCompressChat', () => {
|
||||
const mockGetHistory = vi.fn();
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ import type {
|
||||
} from '../services/chatRecordingService.js';
|
||||
import type { ContentGenerator } from './contentGenerator.js';
|
||||
import { LoopDetectionService } from '../services/loopDetectionService.js';
|
||||
import { MemoryService } from '../services/memoryService.js';
|
||||
import { ChatCompressionService } from '../context/chatCompressionService.js';
|
||||
import { AgentHistoryProvider } from '../context/agentHistoryProvider.js';
|
||||
import { ideContextStore } from '../ide/ideContext.js';
|
||||
@@ -89,6 +90,14 @@ type BeforeAgentHookReturn =
|
||||
| { additionalContext: string | undefined }
|
||||
| undefined;
|
||||
|
||||
interface PromptLifecycleState {
|
||||
originalUserMessage: string;
|
||||
}
|
||||
|
||||
interface SendMessageStreamOptions {
|
||||
internalContinuation?: boolean;
|
||||
}
|
||||
|
||||
export class GeminiClient {
|
||||
private chat?: GeminiChat;
|
||||
private sessionTurnCount = 0;
|
||||
@@ -107,6 +116,10 @@ export class GeminiClient {
|
||||
* being forced and did it fail?
|
||||
*/
|
||||
private hasFailedCompressionAttempt = false;
|
||||
private memoryService?: MemoryService;
|
||||
private memorySystemInstructions = '';
|
||||
private memorySessionStarted = false;
|
||||
private promptLifecycleStateMap = new Map<string, PromptLifecycleState>();
|
||||
|
||||
constructor(private readonly context: AgentLoopContext) {
|
||||
this.loopDetector = new LoopDetectionService(this.config);
|
||||
@@ -248,6 +261,150 @@ export class GeminiClient {
|
||||
}
|
||||
}
|
||||
|
||||
private shouldUseMemoryService(): boolean {
|
||||
if (this.context.parentSessionId) {
|
||||
return false;
|
||||
}
|
||||
// The entire memory subsystem (built-in extractor and any
|
||||
// extension-contributed providers) is gated behind a single
|
||||
// experimental flag while the architecture stabilizes.
|
||||
return this.config.isMemoryManagerEnabled();
|
||||
}
|
||||
|
||||
private getOrCreateMemoryService(): MemoryService | undefined {
|
||||
if (!this.shouldUseMemoryService()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!this.memoryService) {
|
||||
this.memoryService = new MemoryService(this.config);
|
||||
}
|
||||
|
||||
return this.memoryService;
|
||||
}
|
||||
|
||||
private async refreshMemorySystemInstructions(): Promise<void> {
|
||||
if (!this.memoryService) {
|
||||
this.memorySystemInstructions = '';
|
||||
return;
|
||||
}
|
||||
|
||||
this.memorySystemInstructions =
|
||||
await this.memoryService.getSystemInstructions();
|
||||
}
|
||||
|
||||
private async ensureMemoryServiceInitialized(): Promise<void> {
|
||||
const memoryService = this.getOrCreateMemoryService();
|
||||
if (!memoryService) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.memorySessionStarted) {
|
||||
this.memorySessionStarted = true;
|
||||
await memoryService.onSessionStart(this.config.getSessionId());
|
||||
}
|
||||
|
||||
await this.refreshMemorySystemInstructions();
|
||||
}
|
||||
|
||||
private buildSystemInstruction(): string {
|
||||
const systemMemory = this.config.getSystemInstructionMemory();
|
||||
const systemInstruction = getCoreSystemPrompt(this.config, systemMemory);
|
||||
const memoryInstructions = this.memorySystemInstructions.trim();
|
||||
|
||||
if (!memoryInstructions) {
|
||||
return systemInstruction;
|
||||
}
|
||||
|
||||
return `${systemInstruction}\n\n<memory_system_instructions>\n${memoryInstructions}\n</memory_system_instructions>`;
|
||||
}
|
||||
|
||||
private getOrCreatePromptLifecycleState(
|
||||
prompt_id: string,
|
||||
request: PartListUnion,
|
||||
): PromptLifecycleState {
|
||||
let state = this.promptLifecycleStateMap.get(prompt_id);
|
||||
if (!state) {
|
||||
state = {
|
||||
originalUserMessage: partListUnionToString(request),
|
||||
};
|
||||
this.promptLifecycleStateMap.set(prompt_id, state);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
private clearPromptLifecycleState(prompt_id: string): void {
|
||||
this.promptLifecycleStateMap.delete(prompt_id);
|
||||
}
|
||||
|
||||
private requestHasFunctionResponse(request: PartListUnion): boolean {
|
||||
const parts = Array.isArray(request) ? request : [request];
|
||||
return parts.some(
|
||||
(part) =>
|
||||
typeof part === 'object' && part !== null && 'functionResponse' in part,
|
||||
);
|
||||
}
|
||||
|
||||
private appendMemoryContextToRequest(
|
||||
request: PartListUnion,
|
||||
memoryContext: string,
|
||||
): PartListUnion {
|
||||
const trimmedContext = memoryContext.trim();
|
||||
if (!trimmedContext) {
|
||||
return request;
|
||||
}
|
||||
|
||||
const requestParts = Array.isArray(request) ? request : [request];
|
||||
return [
|
||||
...requestParts,
|
||||
{ text: `<memory_context>${trimmedContext}</memory_context>` },
|
||||
];
|
||||
}
|
||||
|
||||
private async withMemoryTurnContext(
|
||||
request: PartListUnion,
|
||||
originalUserMessage: string,
|
||||
isInternalContinuation: boolean,
|
||||
): Promise<PartListUnion> {
|
||||
if (isInternalContinuation || this.requestHasFunctionResponse(request)) {
|
||||
return request;
|
||||
}
|
||||
|
||||
await this.ensureMemoryServiceInitialized();
|
||||
if (!this.memoryService || !originalUserMessage.trim()) {
|
||||
return request;
|
||||
}
|
||||
|
||||
const memoryContext =
|
||||
await this.memoryService.getTurnContext(originalUserMessage);
|
||||
|
||||
return this.appendMemoryContextToRequest(request, memoryContext);
|
||||
}
|
||||
|
||||
private async finalizePromptLifecycle(
|
||||
prompt_id: string,
|
||||
turn: Turn,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const assistantMessage = turn.getResponseText() || '';
|
||||
const lifecycleState = this.promptLifecycleStateMap.get(prompt_id);
|
||||
const userMessage = lifecycleState?.originalUserMessage ?? '';
|
||||
|
||||
if (
|
||||
this.memoryService &&
|
||||
(assistantMessage.trim().length > 0 || turn.finishReason !== undefined)
|
||||
) {
|
||||
// Fire-and-forget by contract — provider does its own async work.
|
||||
this.memoryService.onTurnComplete(userMessage, assistantMessage);
|
||||
await this.refreshMemorySystemInstructions();
|
||||
this.updateSystemInstruction();
|
||||
}
|
||||
} finally {
|
||||
this.clearPromptLifecycleState(prompt_id);
|
||||
}
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
this.chat = await this.startChat();
|
||||
this.updateTelemetryTokenCount();
|
||||
@@ -356,9 +513,7 @@ export class GeminiClient {
|
||||
return;
|
||||
}
|
||||
|
||||
const systemMemory = this.config.getSystemInstructionMemory();
|
||||
const systemInstruction = getCoreSystemPrompt(this.config, systemMemory);
|
||||
this.getChat().setSystemInstruction(systemInstruction);
|
||||
this.getChat().setSystemInstruction(this.buildSystemInstruction());
|
||||
}
|
||||
|
||||
async startChat(
|
||||
@@ -376,8 +531,8 @@ export class GeminiClient {
|
||||
const history = await getInitialChatHistory(this.config, extraHistory);
|
||||
|
||||
try {
|
||||
const systemMemory = this.config.getSystemInstructionMemory();
|
||||
const systemInstruction = getCoreSystemPrompt(this.config, systemMemory);
|
||||
await this.ensureMemoryServiceInitialized();
|
||||
const systemInstruction = this.buildSystemInstruction();
|
||||
const chat = new GeminiChat(
|
||||
this.config,
|
||||
systemInstruction,
|
||||
@@ -839,6 +994,8 @@ export class GeminiClient {
|
||||
boundedTurns - 1,
|
||||
true,
|
||||
displayContent,
|
||||
false,
|
||||
{ internalContinuation: true },
|
||||
);
|
||||
return turn;
|
||||
}
|
||||
@@ -872,6 +1029,8 @@ export class GeminiClient {
|
||||
boundedTurns - 1,
|
||||
false, // isInvalidStreamRetry is false
|
||||
displayContent,
|
||||
false,
|
||||
{ internalContinuation: true },
|
||||
);
|
||||
return turn;
|
||||
}
|
||||
@@ -888,7 +1047,14 @@ export class GeminiClient {
|
||||
isInvalidStreamRetry: boolean = false,
|
||||
displayContent?: PartListUnion,
|
||||
stopHookActive: boolean = false,
|
||||
options?: SendMessageStreamOptions,
|
||||
): AsyncGenerator<ServerGeminiStreamEvent, Turn> {
|
||||
const isInternalContinuation = options?.internalContinuation ?? false;
|
||||
const promptLifecycleState = this.getOrCreatePromptLifecycleState(
|
||||
prompt_id,
|
||||
displayContent ?? request,
|
||||
);
|
||||
|
||||
if (!isInvalidStreamRetry) {
|
||||
this.config.resetTurn();
|
||||
}
|
||||
@@ -910,6 +1076,9 @@ export class GeminiClient {
|
||||
'type' in hookResult &&
|
||||
hookResult.type === GeminiEventType.AgentExecutionStopped
|
||||
) {
|
||||
if (!isInternalContinuation) {
|
||||
this.clearPromptLifecycleState(prompt_id);
|
||||
}
|
||||
// Add user message to history before returning so it's kept in the transcript
|
||||
this.getChat().addHistory(createUserContent(request));
|
||||
yield hookResult;
|
||||
@@ -918,6 +1087,9 @@ export class GeminiClient {
|
||||
'type' in hookResult &&
|
||||
hookResult.type === GeminiEventType.AgentExecutionBlocked
|
||||
) {
|
||||
if (!isInternalContinuation) {
|
||||
this.clearPromptLifecycleState(prompt_id);
|
||||
}
|
||||
yield hookResult;
|
||||
return new Turn(this.getChat(), prompt_id);
|
||||
} else if ('additionalContext' in hookResult) {
|
||||
@@ -933,6 +1105,12 @@ export class GeminiClient {
|
||||
}
|
||||
}
|
||||
|
||||
request = await this.withMemoryTurnContext(
|
||||
request,
|
||||
promptLifecycleState.originalUserMessage,
|
||||
isInternalContinuation,
|
||||
);
|
||||
|
||||
const boundedTurns = Math.min(turns, MAX_TURNS);
|
||||
let turn = new Turn(this.getChat(), prompt_id);
|
||||
let continuationHandled = false;
|
||||
@@ -1008,11 +1186,15 @@ export class GeminiClient {
|
||||
false,
|
||||
displayContent,
|
||||
true, // stopHookActive: signal retry to AfterAgent hooks
|
||||
{ internalContinuation: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal?.aborted || isAbortError(error)) {
|
||||
if (!isInternalContinuation) {
|
||||
this.clearPromptLifecycleState(prompt_id);
|
||||
}
|
||||
yield { type: GeminiEventType.UserCancelled };
|
||||
return turn;
|
||||
}
|
||||
@@ -1035,9 +1217,31 @@ export class GeminiClient {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!isInternalContinuation &&
|
||||
!turn.pendingToolCalls.length &&
|
||||
!signal.aborted
|
||||
) {
|
||||
await this.finalizePromptLifecycle(prompt_id, turn);
|
||||
}
|
||||
|
||||
return turn;
|
||||
}
|
||||
|
||||
async shutdownSessionServices(): Promise<void> {
|
||||
if (!this.memoryService) {
|
||||
this.promptLifecycleStateMap.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.memoryService.onSessionEnd();
|
||||
await this.refreshMemorySystemInstructions();
|
||||
} finally {
|
||||
this.promptLifecycleStateMap.clear();
|
||||
}
|
||||
}
|
||||
|
||||
async generateContent(
|
||||
modelConfigKey: ModelConfigKey,
|
||||
contents: Content[],
|
||||
@@ -1277,6 +1481,8 @@ export class GeminiClient {
|
||||
boundedTurns - 1,
|
||||
isInvalidStreamRetry,
|
||||
displayContent,
|
||||
false,
|
||||
{ internalContinuation: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,4 +519,70 @@ describe('GeminiChat Network Retries', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should retry on OpenSSL 3.x SSL error during stream iteration (ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC)', async () => {
|
||||
// OpenSSL 3.x produces a different error code format than OpenSSL 1.x
|
||||
const sslError = new Error(
|
||||
'request to https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent failed',
|
||||
) as NodeJS.ErrnoException & { type?: string };
|
||||
sslError.type = 'system';
|
||||
sslError.errno =
|
||||
'ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC' as unknown as number;
|
||||
sslError.code = 'ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC';
|
||||
|
||||
vi.mocked(mockContentGenerator.generateContentStream)
|
||||
.mockImplementationOnce(async () =>
|
||||
(async function* () {
|
||||
yield {
|
||||
candidates: [
|
||||
{ content: { parts: [{ text: 'Partial response...' }] } },
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
throw sslError;
|
||||
})(),
|
||||
)
|
||||
.mockImplementationOnce(async () =>
|
||||
(async function* () {
|
||||
yield {
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ text: 'Complete response after retry' }] },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
})(),
|
||||
);
|
||||
|
||||
const stream = await chat.sendMessageStream(
|
||||
{ model: 'test-model' },
|
||||
'test message',
|
||||
'prompt-id-ssl3-mid-stream',
|
||||
new AbortController().signal,
|
||||
LlmRole.MAIN,
|
||||
);
|
||||
|
||||
const events: StreamEvent[] = [];
|
||||
for await (const event of stream) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
const retryEvent = events.find((e) => e.type === StreamEventType.RETRY);
|
||||
expect(retryEvent).toBeDefined();
|
||||
|
||||
const successChunk = events.find(
|
||||
(e) =>
|
||||
e.type === StreamEventType.CHUNK &&
|
||||
e.value.candidates?.[0]?.content?.parts?.[0]?.text ===
|
||||
'Complete response after retry',
|
||||
);
|
||||
expect(successChunk).toBeDefined();
|
||||
|
||||
expect(mockLogNetworkRetryAttempt).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
error_type: 'ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -141,9 +141,10 @@ export * from './services/modelConfigService.js';
|
||||
export * from './sandbox/windows/WindowsSandboxManager.js';
|
||||
export * from './services/sessionSummaryUtils.js';
|
||||
export {
|
||||
startMemoryService,
|
||||
DefaultMemoryProvider,
|
||||
validatePatches,
|
||||
} from './services/memoryService.js';
|
||||
} from './services/defaultMemoryProvider.js';
|
||||
export { MemoryService } from './services/memoryService.js';
|
||||
export { isProjectSkillPatchTarget } from './services/memoryPatchUtils.js';
|
||||
export * from './context/memoryContextManager.js';
|
||||
export * from './services/trackerService.js';
|
||||
|
||||
@@ -1762,6 +1762,39 @@ describe('PolicyEngine', () => {
|
||||
});
|
||||
|
||||
describe('shell command parsing failure', () => {
|
||||
it('should return ALLOW in YOLO mode for dangerous commands due to heuristics override', async () => {
|
||||
// Create an engine with YOLO mode and a sandbox manager that flags a command as dangerous
|
||||
const rules: PolicyRule[] = [
|
||||
{
|
||||
toolName: '*',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: 999,
|
||||
modes: [ApprovalMode.YOLO],
|
||||
},
|
||||
];
|
||||
|
||||
const mockSandboxManager = new NoopSandboxManager();
|
||||
mockSandboxManager.isDangerousCommand = vi.fn().mockReturnValue(true);
|
||||
mockSandboxManager.isKnownSafeCommand = vi.fn().mockReturnValue(false);
|
||||
|
||||
engine = new PolicyEngine({
|
||||
rules,
|
||||
approvalMode: ApprovalMode.YOLO,
|
||||
sandboxManager: mockSandboxManager,
|
||||
});
|
||||
|
||||
const result = await engine.check(
|
||||
{
|
||||
name: 'run_shell_command',
|
||||
args: { command: 'powershell echo "dangerous"' },
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Even though the command is flagged as dangerous, YOLO mode should preserve the ALLOW decision
|
||||
expect(result.decision).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
|
||||
it('should return ALLOW in YOLO mode even if shell command parsing fails', async () => {
|
||||
const { splitCommands } = await import('../utils/shell-utils.js');
|
||||
const rules: PolicyRule[] = [
|
||||
|
||||
@@ -312,6 +312,13 @@ export class PolicyEngine {
|
||||
const parsedArgs = parsedObjArgs.map(extractStringFromParseEntry);
|
||||
|
||||
if (this.sandboxManager.isDangerousCommand(parsedArgs)) {
|
||||
if (this.approvalMode === ApprovalMode.YOLO) {
|
||||
debugLogger.debug(
|
||||
`[PolicyEngine.check] Command evaluated as dangerous, but YOLO mode is active. Preserving decision: ${command}`,
|
||||
);
|
||||
return decision;
|
||||
}
|
||||
|
||||
debugLogger.debug(
|
||||
`[PolicyEngine.check] Command evaluated as dangerous, forcing ASK_USER: ${command}`,
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,756 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { MemoryProvider } from './memoryProvider.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { constants as fsConstants } from 'node:fs';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import * as Diff from 'diff';
|
||||
import {
|
||||
SESSION_FILE_PREFIX,
|
||||
type ConversationRecord,
|
||||
} from './chatRecordingService.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { coreEvents } from '../utils/events.js';
|
||||
import { isNodeError } from '../utils/errors.js';
|
||||
import { FRONTMATTER_REGEX, parseFrontmatter } from '../skills/skillLoader.js';
|
||||
import { LocalAgentExecutor } from '../agents/local-executor.js';
|
||||
import { SkillExtractionAgent } from '../agents/skill-extraction-agent.js';
|
||||
import { getModelConfigAlias } from '../agents/registry.js';
|
||||
import { ExecutionLifecycleService } from './executionLifecycleService.js';
|
||||
import { PromptRegistry } from '../prompts/prompt-registry.js';
|
||||
import { ResourceRegistry } from '../resources/resource-registry.js';
|
||||
import { PolicyEngine } from '../policy/policy-engine.js';
|
||||
import { PolicyDecision } from '../policy/types.js';
|
||||
import { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import type { AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import {
|
||||
applyParsedSkillPatches,
|
||||
hasParsedPatchHunks,
|
||||
} from './memoryPatchUtils.js';
|
||||
|
||||
const LOCK_FILENAME = '.extraction.lock';
|
||||
const STATE_FILENAME = '.extraction-state.json';
|
||||
const LOCK_STALE_MS = 35 * 60 * 1000; // 35 minutes (exceeds agent's 30-min time limit)
|
||||
const MIN_USER_MESSAGES = 10;
|
||||
const MIN_IDLE_MS = 3 * 60 * 60 * 1000; // 3 hours
|
||||
const MAX_SESSION_INDEX_SIZE = 50;
|
||||
|
||||
// ... keeping all existing helper types and functions above startMemoryService
|
||||
|
||||
interface LockInfo {
|
||||
pid: number;
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
export interface ExtractionRun {
|
||||
runAt: string;
|
||||
sessionIds: string[];
|
||||
skillsCreated: string[];
|
||||
}
|
||||
|
||||
export interface ExtractionState {
|
||||
runs: ExtractionRun[];
|
||||
}
|
||||
|
||||
export function getProcessedSessionIds(state: ExtractionState): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const run of state.runs) {
|
||||
for (const id of run.sessionIds) {
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function isLockInfo(value: unknown): value is LockInfo {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'pid' in value &&
|
||||
typeof value.pid === 'number' &&
|
||||
'startedAt' in value &&
|
||||
typeof value.startedAt === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function isConversationRecord(value: unknown): value is ConversationRecord {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'sessionId' in value &&
|
||||
typeof value.sessionId === 'string' &&
|
||||
'messages' in value &&
|
||||
Array.isArray(value.messages) &&
|
||||
'projectHash' in value &&
|
||||
'startTime' in value &&
|
||||
'lastUpdated' in value
|
||||
);
|
||||
}
|
||||
|
||||
function isExtractionRun(value: unknown): value is ExtractionRun {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'runAt' in value &&
|
||||
typeof value.runAt === 'string' &&
|
||||
'sessionIds' in value &&
|
||||
Array.isArray(value.sessionIds) &&
|
||||
'skillsCreated' in value &&
|
||||
Array.isArray(value.skillsCreated)
|
||||
);
|
||||
}
|
||||
|
||||
function isExtractionState(value: unknown): value is { runs: unknown[] } {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'runs' in value &&
|
||||
Array.isArray(value.runs)
|
||||
);
|
||||
}
|
||||
|
||||
export async function tryAcquireLock(
|
||||
lockPath: string,
|
||||
retries = 1,
|
||||
): Promise<boolean> {
|
||||
const lockInfo: LockInfo = {
|
||||
pid: process.pid,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
try {
|
||||
const fd = await fs.open(
|
||||
lockPath,
|
||||
fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY,
|
||||
);
|
||||
try {
|
||||
await fd.writeFile(JSON.stringify(lockInfo));
|
||||
} finally {
|
||||
await fd.close();
|
||||
}
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
if (isNodeError(error) && error.code === 'EEXIST') {
|
||||
if (retries > 0 && (await isLockStale(lockPath))) {
|
||||
debugLogger.debug('[MemoryService] Cleaning up stale lock file');
|
||||
await releaseLock(lockPath);
|
||||
return tryAcquireLock(lockPath, retries - 1);
|
||||
}
|
||||
debugLogger.debug(
|
||||
'[MemoryService] Lock held by another instance, skipping',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function isLockStale(lockPath: string): Promise<boolean> {
|
||||
try {
|
||||
const content = await fs.readFile(lockPath, 'utf-8');
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
if (!isLockInfo(parsed)) {
|
||||
return true;
|
||||
}
|
||||
const lockInfo = parsed;
|
||||
|
||||
try {
|
||||
process.kill(lockInfo.pid, 0);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
|
||||
const lockAge = Date.now() - new Date(lockInfo.startedAt).getTime();
|
||||
if (lockAge > LOCK_STALE_MS) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export async function releaseLock(lockPath: string): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(lockPath);
|
||||
} catch (error: unknown) {
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
return;
|
||||
}
|
||||
debugLogger.warn(
|
||||
`[MemoryService] Failed to release lock: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function readExtractionState(
|
||||
statePath: string,
|
||||
): Promise<ExtractionState> {
|
||||
try {
|
||||
const content = await fs.readFile(statePath, 'utf-8');
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
if (!isExtractionState(parsed)) {
|
||||
return { runs: [] };
|
||||
}
|
||||
|
||||
const runs: ExtractionRun[] = [];
|
||||
for (const run of parsed.runs) {
|
||||
if (!isExtractionRun(run)) continue;
|
||||
runs.push({
|
||||
runAt: run.runAt,
|
||||
sessionIds: run.sessionIds.filter(
|
||||
(sid): sid is string => typeof sid === 'string',
|
||||
),
|
||||
skillsCreated: run.skillsCreated.filter(
|
||||
(sk): sk is string => typeof sk === 'string',
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return { runs };
|
||||
} catch (error) {
|
||||
debugLogger.debug(
|
||||
'[MemoryService] Failed to read extraction state:',
|
||||
error,
|
||||
);
|
||||
return { runs: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeExtractionState(
|
||||
statePath: string,
|
||||
state: ExtractionState,
|
||||
): Promise<void> {
|
||||
const tmpPath = `${statePath}.tmp`;
|
||||
await fs.writeFile(tmpPath, JSON.stringify(state, null, 2));
|
||||
await fs.rename(tmpPath, statePath);
|
||||
}
|
||||
|
||||
function shouldProcessConversation(parsed: ConversationRecord): boolean {
|
||||
if (parsed.kind === 'subagent') return false;
|
||||
|
||||
const lastUpdated = new Date(parsed.lastUpdated).getTime();
|
||||
if (Date.now() - lastUpdated < MIN_IDLE_MS) return false;
|
||||
|
||||
const userMessageCount = parsed.messages.filter(
|
||||
(m) => m.type === 'user',
|
||||
).length;
|
||||
if (userMessageCount < MIN_USER_MESSAGES) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function scanEligibleSessions(
|
||||
chatsDir: string,
|
||||
): Promise<Array<{ conversation: ConversationRecord; filePath: string }>> {
|
||||
let allFiles: string[];
|
||||
try {
|
||||
allFiles = await fs.readdir(chatsDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sessionFiles = allFiles.filter(
|
||||
(f) => f.startsWith(SESSION_FILE_PREFIX) && f.endsWith('.json'),
|
||||
);
|
||||
|
||||
sessionFiles.sort((a, b) => b.localeCompare(a));
|
||||
|
||||
const results: Array<{ conversation: ConversationRecord; filePath: string }> =
|
||||
[];
|
||||
|
||||
for (const file of sessionFiles) {
|
||||
if (results.length >= MAX_SESSION_INDEX_SIZE) break;
|
||||
|
||||
const filePath = path.join(chatsDir, file);
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
if (!isConversationRecord(parsed)) continue;
|
||||
if (!shouldProcessConversation(parsed)) continue;
|
||||
|
||||
results.push({ conversation: parsed, filePath });
|
||||
} catch {
|
||||
// Skip unreadable files
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function buildSessionIndex(
|
||||
chatsDir: string,
|
||||
state: ExtractionState,
|
||||
): Promise<{ sessionIndex: string; newSessionIds: string[] }> {
|
||||
const processedSet = getProcessedSessionIds(state);
|
||||
const eligible = await scanEligibleSessions(chatsDir);
|
||||
|
||||
if (eligible.length === 0) {
|
||||
return { sessionIndex: '', newSessionIds: [] };
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
const newSessionIds: string[] = [];
|
||||
|
||||
for (const { conversation, filePath } of eligible) {
|
||||
const userMessageCount = conversation.messages.filter(
|
||||
(m) => m.type === 'user',
|
||||
).length;
|
||||
const isNew = !processedSet.has(conversation.sessionId);
|
||||
if (isNew) {
|
||||
newSessionIds.push(conversation.sessionId);
|
||||
}
|
||||
|
||||
const status = isNew ? '[NEW]' : '[old]';
|
||||
const summary = conversation.summary ?? '(no summary)';
|
||||
lines.push(
|
||||
`${status} ${summary} (${userMessageCount} user msgs) — ${filePath}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { sessionIndex: lines.join('\n'), newSessionIds };
|
||||
}
|
||||
|
||||
async function buildExistingSkillsSummary(
|
||||
skillsDir: string,
|
||||
config: Config,
|
||||
): Promise<string> {
|
||||
const sections: string[] = [];
|
||||
|
||||
const memorySkills: string[] = [];
|
||||
try {
|
||||
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const skillPath = path.join(skillsDir, entry.name, 'SKILL.md');
|
||||
try {
|
||||
const content = await fs.readFile(skillPath, 'utf-8');
|
||||
const match = content.match(FRONTMATTER_REGEX);
|
||||
if (match) {
|
||||
const parsed = parseFrontmatter(match[1]);
|
||||
const name = parsed?.name ?? entry.name;
|
||||
const desc = parsed?.description ?? '';
|
||||
memorySkills.push(`- **${name}**: ${desc}`);
|
||||
} else {
|
||||
memorySkills.push(`- **${entry.name}**`);
|
||||
}
|
||||
} catch {
|
||||
// Skip unreadable files
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip unreadable files
|
||||
}
|
||||
|
||||
if (memorySkills.length > 0) {
|
||||
sections.push(
|
||||
`## Previously Extracted Skills (in ${skillsDir})\n${memorySkills.join('\n')}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const discoveredSkills = config.getSkillManager().getSkills();
|
||||
if (discoveredSkills.length > 0) {
|
||||
const userSkillsDir = Storage.getUserSkillsDir();
|
||||
const globalSkills: string[] = [];
|
||||
const workspaceSkills: string[] = [];
|
||||
const extensionSkills: string[] = [];
|
||||
const builtinSkills: string[] = [];
|
||||
|
||||
for (const s of discoveredSkills) {
|
||||
const loc = s.location;
|
||||
if (loc.includes('/bundle/') || loc.includes('\\bundle\\')) {
|
||||
builtinSkills.push(`- **${s.name}**: ${s.description}`);
|
||||
} else if (loc.startsWith(userSkillsDir)) {
|
||||
globalSkills.push(`- **${s.name}**: ${s.description} (${loc})`);
|
||||
} else if (
|
||||
loc.includes('/extensions/') ||
|
||||
loc.includes('\\extensions\\')
|
||||
) {
|
||||
extensionSkills.push(`- **${s.name}**: ${s.description}`);
|
||||
} else {
|
||||
workspaceSkills.push(`- **${s.name}**: ${s.description} (${loc})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (globalSkills.length > 0) {
|
||||
sections.push(
|
||||
`## Global Skills (~/.gemini/skills — do NOT duplicate)\n${globalSkills.join('\n')}`,
|
||||
);
|
||||
}
|
||||
if (workspaceSkills.length > 0) {
|
||||
sections.push(
|
||||
`## Workspace Skills (.gemini/skills — do NOT duplicate)\n${workspaceSkills.join('\n')}`,
|
||||
);
|
||||
}
|
||||
if (extensionSkills.length > 0) {
|
||||
sections.push(
|
||||
`## Extension Skills (from installed extensions — do NOT duplicate)\n${extensionSkills.join('\n')}`,
|
||||
);
|
||||
}
|
||||
if (builtinSkills.length > 0) {
|
||||
sections.push(
|
||||
`## Builtin Skills (bundled with CLI — do NOT duplicate)\n${builtinSkills.join('\n')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip unreadable files
|
||||
}
|
||||
|
||||
return sections.join('\n\n');
|
||||
}
|
||||
|
||||
function buildAgentLoopContext(config: Config): AgentLoopContext {
|
||||
const autoApprovePolicy = new PolicyEngine({
|
||||
rules: [
|
||||
{
|
||||
toolName: '*',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: 100,
|
||||
},
|
||||
],
|
||||
});
|
||||
const autoApproveBus = new MessageBus(autoApprovePolicy);
|
||||
|
||||
return {
|
||||
config,
|
||||
promptId: `skill-extraction-${randomUUID().slice(0, 8)}`,
|
||||
toolRegistry: config.getToolRegistry(),
|
||||
promptRegistry: new PromptRegistry(),
|
||||
resourceRegistry: new ResourceRegistry(),
|
||||
messageBus: autoApproveBus,
|
||||
geminiClient: config.getGeminiClient(),
|
||||
sandboxManager: config.sandboxManager,
|
||||
};
|
||||
}
|
||||
|
||||
export async function validatePatches(
|
||||
skillsDir: string,
|
||||
config: Config,
|
||||
): Promise<string[]> {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fs.readdir(skillsDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const patchFiles = entries.filter((e) => e.endsWith('.patch'));
|
||||
const validPatches: string[] = [];
|
||||
|
||||
for (const patchFile of patchFiles) {
|
||||
const patchPath = path.join(skillsDir, patchFile);
|
||||
let valid = true;
|
||||
let reason = '';
|
||||
|
||||
try {
|
||||
const patchContent = await fs.readFile(patchPath, 'utf-8');
|
||||
const parsedPatches = Diff.parsePatch(patchContent);
|
||||
|
||||
if (!hasParsedPatchHunks(parsedPatches)) {
|
||||
valid = false;
|
||||
reason = 'no hunks found in patch';
|
||||
} else {
|
||||
const applied = await applyParsedSkillPatches(parsedPatches, config);
|
||||
if (!applied.success) {
|
||||
valid = false;
|
||||
switch (applied.reason) {
|
||||
case 'missingTargetPath':
|
||||
reason = 'missing target file path in patch header';
|
||||
break;
|
||||
case 'invalidPatchHeaders':
|
||||
reason = 'invalid diff headers';
|
||||
break;
|
||||
case 'outsideAllowedRoots':
|
||||
reason = `target file is outside skill roots: ${applied.targetPath}`;
|
||||
break;
|
||||
case 'newFileAlreadyExists':
|
||||
reason = `new file target already exists: ${applied.targetPath}`;
|
||||
break;
|
||||
case 'targetNotFound':
|
||||
reason = `target file not found: ${applied.targetPath}`;
|
||||
break;
|
||||
case 'doesNotApply':
|
||||
reason = `patch does not apply cleanly to ${applied.targetPath}`;
|
||||
break;
|
||||
default:
|
||||
reason = 'unknown patch validation failure';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
valid = false;
|
||||
reason = `failed to read or parse patch: ${err}`;
|
||||
}
|
||||
|
||||
if (valid) {
|
||||
validPatches.push(patchFile);
|
||||
debugLogger.log(`[MemoryService] Patch validated: ${patchFile}`);
|
||||
} else {
|
||||
debugLogger.warn(
|
||||
`[MemoryService] Removing invalid patch ${patchFile}: ${reason}`,
|
||||
);
|
||||
try {
|
||||
await fs.unlink(patchPath);
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return validPatches;
|
||||
}
|
||||
|
||||
export class DefaultMemoryProvider implements MemoryProvider {
|
||||
readonly id = 'gemini-cli-builtin-memory';
|
||||
|
||||
onSessionStart(config: Config, _sessionId: string): void {
|
||||
void this.startSkillExtractionTask(config).catch((error) => {
|
||||
debugLogger.warn(
|
||||
'[MemoryService] Failed to start background skill extraction:',
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
getSystemInstructions(): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
getTurnContext(_query: string): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
onTurnComplete(_userMessage: string, _assistantMessage: string): void {}
|
||||
|
||||
onSessionEnd(): void {}
|
||||
|
||||
private async startSkillExtractionTask(config: Config): Promise<void> {
|
||||
const memoryDir = config.storage.getProjectMemoryTempDir();
|
||||
const skillsDir = config.storage.getProjectSkillsMemoryDir();
|
||||
const lockPath = path.join(memoryDir, LOCK_FILENAME);
|
||||
const statePath = path.join(memoryDir, STATE_FILENAME);
|
||||
const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats');
|
||||
|
||||
await fs.mkdir(skillsDir, { recursive: true });
|
||||
|
||||
debugLogger.log(`[MemoryService] Starting. Skills dir: ${skillsDir}`);
|
||||
|
||||
if (!(await tryAcquireLock(lockPath))) {
|
||||
debugLogger.log('[MemoryService] Skipped: lock held by another instance');
|
||||
return;
|
||||
}
|
||||
debugLogger.log('[MemoryService] Lock acquired');
|
||||
|
||||
const abortController = new AbortController();
|
||||
const handle = ExecutionLifecycleService.createExecution(
|
||||
'',
|
||||
() => abortController.abort(),
|
||||
'none',
|
||||
undefined,
|
||||
'Skill extraction',
|
||||
'silent',
|
||||
);
|
||||
const executionId = handle.pid;
|
||||
|
||||
const startTime = Date.now();
|
||||
let completionResult: { error: Error } | undefined;
|
||||
try {
|
||||
const state = await readExtractionState(statePath);
|
||||
const previousRuns = state.runs.length;
|
||||
const previouslyProcessed = getProcessedSessionIds(state).size;
|
||||
debugLogger.log(
|
||||
`[MemoryService] State loaded: ${previousRuns} previous run(s), ${previouslyProcessed} session(s) already processed`,
|
||||
);
|
||||
|
||||
const { sessionIndex, newSessionIds } = await buildSessionIndex(
|
||||
chatsDir,
|
||||
state,
|
||||
);
|
||||
|
||||
const totalInIndex = sessionIndex ? sessionIndex.split('\n').length : 0;
|
||||
debugLogger.log(
|
||||
`[MemoryService] Session scan: ${totalInIndex} eligible session(s) found, ${newSessionIds.length} new`,
|
||||
);
|
||||
|
||||
if (newSessionIds.length === 0) {
|
||||
debugLogger.log('[MemoryService] Skipped: no new sessions to process');
|
||||
return;
|
||||
}
|
||||
|
||||
const skillsBefore = new Set<string>();
|
||||
const patchContentsBefore = new Map<string, string>();
|
||||
try {
|
||||
const entries = await fs.readdir(skillsDir);
|
||||
for (const e of entries) {
|
||||
if (e.endsWith('.patch')) {
|
||||
try {
|
||||
patchContentsBefore.set(
|
||||
e,
|
||||
await fs.readFile(path.join(skillsDir, e), 'utf-8'),
|
||||
);
|
||||
} catch {
|
||||
// Skip unreadable files
|
||||
}
|
||||
continue;
|
||||
}
|
||||
skillsBefore.add(e);
|
||||
}
|
||||
} catch {
|
||||
// Skip unreadable files
|
||||
}
|
||||
debugLogger.log(
|
||||
`[MemoryService] ${skillsBefore.size} existing skill(s) in memory`,
|
||||
);
|
||||
|
||||
const existingSkillsSummary = await buildExistingSkillsSummary(
|
||||
skillsDir,
|
||||
config,
|
||||
);
|
||||
if (existingSkillsSummary) {
|
||||
debugLogger.log(
|
||||
`[MemoryService] Existing skills context:\n${existingSkillsSummary}`,
|
||||
);
|
||||
}
|
||||
|
||||
const agentDefinition = SkillExtractionAgent(
|
||||
skillsDir,
|
||||
sessionIndex,
|
||||
existingSkillsSummary,
|
||||
);
|
||||
|
||||
const context = buildAgentLoopContext(config);
|
||||
|
||||
const modelAlias = getModelConfigAlias(agentDefinition);
|
||||
config.modelConfigService.registerRuntimeModelConfig(modelAlias, {
|
||||
modelConfig: agentDefinition.modelConfig,
|
||||
});
|
||||
debugLogger.log(
|
||||
`[MemoryService] Starting extraction agent (model: ${agentDefinition.modelConfig.model}, maxTurns: 30, maxTime: 30min)`,
|
||||
);
|
||||
|
||||
const executor = await LocalAgentExecutor.create(
|
||||
agentDefinition,
|
||||
context,
|
||||
);
|
||||
|
||||
await executor.run(
|
||||
{ request: 'Extract skills from the provided sessions.' },
|
||||
abortController.signal,
|
||||
);
|
||||
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
|
||||
const skillsCreated: string[] = [];
|
||||
try {
|
||||
const entriesAfter = await fs.readdir(skillsDir);
|
||||
for (const e of entriesAfter) {
|
||||
if (!skillsBefore.has(e) && !e.endsWith('.patch')) {
|
||||
skillsCreated.push(e);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip unreadable files
|
||||
}
|
||||
|
||||
const validPatches = await validatePatches(skillsDir, config);
|
||||
const patchesCreatedThisRun: string[] = [];
|
||||
for (const patchFile of validPatches) {
|
||||
const patchPath = path.join(skillsDir, patchFile);
|
||||
let currentContent: string;
|
||||
try {
|
||||
currentContent = await fs.readFile(patchPath, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (patchContentsBefore.get(patchFile) !== currentContent) {
|
||||
patchesCreatedThisRun.push(patchFile);
|
||||
}
|
||||
}
|
||||
if (validPatches.length > 0) {
|
||||
debugLogger.log(
|
||||
`[MemoryService] ${validPatches.length} valid patch(es) currently in inbox; ${patchesCreatedThisRun.length} created or updated this run`,
|
||||
);
|
||||
}
|
||||
|
||||
const run: ExtractionRun = {
|
||||
runAt: new Date().toISOString(),
|
||||
sessionIds: newSessionIds,
|
||||
skillsCreated,
|
||||
};
|
||||
const updatedState: ExtractionState = {
|
||||
runs: [...state.runs, run],
|
||||
};
|
||||
await writeExtractionState(statePath, updatedState);
|
||||
|
||||
if (skillsCreated.length > 0 || patchesCreatedThisRun.length > 0) {
|
||||
const completionParts: string[] = [];
|
||||
if (skillsCreated.length > 0) {
|
||||
completionParts.push(
|
||||
`created ${skillsCreated.length} skill(s): ${skillsCreated.join(', ')}`,
|
||||
);
|
||||
}
|
||||
if (patchesCreatedThisRun.length > 0) {
|
||||
completionParts.push(
|
||||
`prepared ${patchesCreatedThisRun.length} patch(es): ${patchesCreatedThisRun.join(', ')}`,
|
||||
);
|
||||
}
|
||||
debugLogger.log(
|
||||
`[MemoryService] Completed in ${elapsed}s. ${completionParts.join('; ')} (processed ${newSessionIds.length} session(s))`,
|
||||
);
|
||||
const feedbackParts: string[] = [];
|
||||
if (skillsCreated.length > 0) {
|
||||
feedbackParts.push(
|
||||
`${skillsCreated.length} new skill${skillsCreated.length > 1 ? 's' : ''} extracted from past sessions: ${skillsCreated.join(', ')}`,
|
||||
);
|
||||
}
|
||||
if (patchesCreatedThisRun.length > 0) {
|
||||
feedbackParts.push(
|
||||
`${patchesCreatedThisRun.length} skill update${patchesCreatedThisRun.length > 1 ? 's' : ''} extracted from past sessions`,
|
||||
);
|
||||
}
|
||||
coreEvents.emitFeedback(
|
||||
'info',
|
||||
`${feedbackParts.join('. ')}. Use /memory inbox to review.`,
|
||||
);
|
||||
} else {
|
||||
debugLogger.log(
|
||||
`[MemoryService] Completed in ${elapsed}s. No new skills or patches created (processed ${newSessionIds.length} session(s))`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
if (abortController.signal.aborted) {
|
||||
debugLogger.log(`[MemoryService] Cancelled after ${elapsed}s`);
|
||||
} else {
|
||||
debugLogger.log(
|
||||
`[MemoryService] Failed after ${elapsed}s: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
completionResult = {
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
};
|
||||
return;
|
||||
} finally {
|
||||
await releaseLock(lockPath);
|
||||
debugLogger.log('[MemoryService] Lock released');
|
||||
if (executionId !== undefined) {
|
||||
ExecutionLifecycleService.completeExecution(
|
||||
executionId,
|
||||
completionResult,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ vi.mock('../utils/events.js', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../utils/debugLogger.js', () => ({
|
||||
debugLogger: { log: vi.fn() },
|
||||
debugLogger: { debug: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
@@ -153,14 +153,14 @@ describe('KeychainService', () => {
|
||||
|
||||
// Because it falls back to FileKeychain, it is always available.
|
||||
expect(available).toBe(true);
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect(debugLogger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('encountered an error'),
|
||||
'locked',
|
||||
);
|
||||
expect(coreEvents.emitTelemetryKeychainAvailability).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ available: false }),
|
||||
);
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect(debugLogger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Using FileKeychain fallback'),
|
||||
);
|
||||
expect(FileKeychain).toHaveBeenCalled();
|
||||
@@ -173,7 +173,7 @@ describe('KeychainService', () => {
|
||||
const available = await service.isAvailable();
|
||||
|
||||
expect(available).toBe(true);
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect(debugLogger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('failed structural validation'),
|
||||
expect.objectContaining({ getPassword: expect.any(Array) }),
|
||||
);
|
||||
@@ -191,7 +191,7 @@ describe('KeychainService', () => {
|
||||
const available = await service.isAvailable();
|
||||
|
||||
expect(available).toBe(true);
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect(debugLogger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('functional verification failed'),
|
||||
);
|
||||
expect(FileKeychain).toHaveBeenCalled();
|
||||
@@ -243,7 +243,7 @@ describe('KeychainService', () => {
|
||||
);
|
||||
expect(mockKeytar.setPassword).not.toHaveBeenCalled();
|
||||
expect(FileKeychain).toHaveBeenCalled();
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect(debugLogger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('MacOS default keychain not found'),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -114,7 +114,7 @@ export class KeychainService {
|
||||
}
|
||||
|
||||
// If native failed or was skipped, return the secure file fallback.
|
||||
debugLogger.log('Using FileKeychain fallback for secure storage.');
|
||||
debugLogger.debug('Using FileKeychain fallback for secure storage.');
|
||||
return new FileKeychain();
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ export class KeychainService {
|
||||
|
||||
// Probing macOS prevents process-blocking popups when no keychain exists.
|
||||
if (os.platform() === 'darwin' && !this.isMacOSKeychainAvailable()) {
|
||||
debugLogger.log(
|
||||
debugLogger.debug(
|
||||
'MacOS default keychain not found; skipping functional verification.',
|
||||
);
|
||||
return null;
|
||||
@@ -140,12 +140,15 @@ export class KeychainService {
|
||||
return keychainModule;
|
||||
}
|
||||
|
||||
debugLogger.log('Keychain functional verification failed');
|
||||
debugLogger.debug('Keychain functional verification failed');
|
||||
return null;
|
||||
} catch (error) {
|
||||
// Avoid logging full error objects to prevent PII exposure.
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
debugLogger.log('Keychain initialization encountered an error:', message);
|
||||
debugLogger.debug(
|
||||
'Keychain initialization encountered an error:',
|
||||
message,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -162,7 +165,7 @@ export class KeychainService {
|
||||
return potential as Keychain;
|
||||
}
|
||||
|
||||
debugLogger.log(
|
||||
debugLogger.debug(
|
||||
'Keychain module failed structural validation:',
|
||||
result.error.flatten().fieldErrors,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Config } from '../config/config.js';
|
||||
|
||||
/**
|
||||
* Internal contract implemented by the memory backend that {@link MemoryService}
|
||||
* delegates to. This interface is intentionally *not* exported from the
|
||||
* package's public surface (`src/index.ts`) and is *not* surfaced via
|
||||
* `GeminiCLIExtension` — it exists purely as a typing seam between
|
||||
* `MemoryService` and its concrete implementation so that the two can evolve
|
||||
* independently and be substituted in tests.
|
||||
*/
|
||||
export interface MemoryProvider {
|
||||
/** Stable identifier used in diagnostic logs. */
|
||||
readonly id: string;
|
||||
|
||||
/**
|
||||
* Invoked once per session, when the owning `GeminiClient` initializes the
|
||||
* memory subsystem. Implementations may kick off background work but must
|
||||
* return synchronously.
|
||||
*/
|
||||
onSessionStart(config: Config, sessionId: string): void;
|
||||
|
||||
/**
|
||||
* Returns static instructions to inject into the LLM's system prompt.
|
||||
* Called once per session and again after `onTurnComplete`.
|
||||
*/
|
||||
getSystemInstructions(): string;
|
||||
|
||||
/**
|
||||
* Returns dynamic, recalled context for the current turn based on the
|
||||
* user's query. Called once per non-internal turn.
|
||||
*/
|
||||
getTurnContext(query: string): string;
|
||||
|
||||
/**
|
||||
* Invoked after the LLM completes a turn. Must return synchronously; any
|
||||
* persistence work should be fired fire-and-forget by the implementation.
|
||||
*/
|
||||
onTurnComplete(userMessage: string, assistantMessage: string): void;
|
||||
|
||||
/**
|
||||
* Invoked when the owning session is shutting down gracefully.
|
||||
*/
|
||||
onSessionEnd(): void;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,814 +4,81 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { constants as fsConstants } from 'node:fs';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import * as Diff from 'diff';
|
||||
import type { Config } from '../config/config.js';
|
||||
import {
|
||||
SESSION_FILE_PREFIX,
|
||||
type ConversationRecord,
|
||||
} from './chatRecordingService.js';
|
||||
import { DefaultMemoryProvider } from './defaultMemoryProvider.js';
|
||||
import type { MemoryProvider } from './memoryProvider.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { coreEvents } from '../utils/events.js';
|
||||
import { isNodeError } from '../utils/errors.js';
|
||||
import { FRONTMATTER_REGEX, parseFrontmatter } from '../skills/skillLoader.js';
|
||||
import { LocalAgentExecutor } from '../agents/local-executor.js';
|
||||
import { SkillExtractionAgent } from '../agents/skill-extraction-agent.js';
|
||||
import { getModelConfigAlias } from '../agents/registry.js';
|
||||
import { ExecutionLifecycleService } from './executionLifecycleService.js';
|
||||
import { PromptRegistry } from '../prompts/prompt-registry.js';
|
||||
import { ResourceRegistry } from '../resources/resource-registry.js';
|
||||
import { PolicyEngine } from '../policy/policy-engine.js';
|
||||
import { PolicyDecision } from '../policy/types.js';
|
||||
import { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import type { AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import {
|
||||
applyParsedSkillPatches,
|
||||
hasParsedPatchHunks,
|
||||
} from './memoryPatchUtils.js';
|
||||
|
||||
const LOCK_FILENAME = '.extraction.lock';
|
||||
const STATE_FILENAME = '.extraction-state.json';
|
||||
const LOCK_STALE_MS = 35 * 60 * 1000; // 35 minutes (exceeds agent's 30-min time limit)
|
||||
const MIN_USER_MESSAGES = 10;
|
||||
const MIN_IDLE_MS = 3 * 60 * 60 * 1000; // 3 hours
|
||||
const MAX_SESSION_INDEX_SIZE = 50;
|
||||
|
||||
/**
|
||||
* Lock file content for coordinating across CLI instances.
|
||||
*/
|
||||
interface LockInfo {
|
||||
pid: number;
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata for a single extraction run.
|
||||
*/
|
||||
export interface ExtractionRun {
|
||||
runAt: string;
|
||||
sessionIds: string[];
|
||||
skillsCreated: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks extraction history with per-run metadata.
|
||||
*/
|
||||
export interface ExtractionState {
|
||||
runs: ExtractionRun[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all session IDs that have been processed across all runs.
|
||||
*/
|
||||
export function getProcessedSessionIds(state: ExtractionState): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const run of state.runs) {
|
||||
for (const id of run.sessionIds) {
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function isLockInfo(value: unknown): value is LockInfo {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'pid' in value &&
|
||||
typeof value.pid === 'number' &&
|
||||
'startedAt' in value &&
|
||||
typeof value.startedAt === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function isConversationRecord(value: unknown): value is ConversationRecord {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'sessionId' in value &&
|
||||
typeof value.sessionId === 'string' &&
|
||||
'messages' in value &&
|
||||
Array.isArray(value.messages) &&
|
||||
'projectHash' in value &&
|
||||
'startTime' in value &&
|
||||
'lastUpdated' in value
|
||||
);
|
||||
}
|
||||
|
||||
function isExtractionRun(value: unknown): value is ExtractionRun {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'runAt' in value &&
|
||||
typeof value.runAt === 'string' &&
|
||||
'sessionIds' in value &&
|
||||
Array.isArray(value.sessionIds) &&
|
||||
'skillsCreated' in value &&
|
||||
Array.isArray(value.skillsCreated)
|
||||
);
|
||||
}
|
||||
|
||||
function isExtractionState(value: unknown): value is { runs: unknown[] } {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'runs' in value &&
|
||||
Array.isArray(value.runs)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to acquire an exclusive lock file using O_CREAT | O_EXCL.
|
||||
* Returns true if the lock was acquired, false if another instance owns it.
|
||||
*/
|
||||
export async function tryAcquireLock(
|
||||
lockPath: string,
|
||||
retries = 1,
|
||||
): Promise<boolean> {
|
||||
const lockInfo: LockInfo = {
|
||||
pid: process.pid,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
try {
|
||||
// Atomic create-if-not-exists
|
||||
const fd = await fs.open(
|
||||
lockPath,
|
||||
fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY,
|
||||
);
|
||||
try {
|
||||
await fd.writeFile(JSON.stringify(lockInfo));
|
||||
} finally {
|
||||
await fd.close();
|
||||
}
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
if (isNodeError(error) && error.code === 'EEXIST') {
|
||||
// Lock exists — check if it's stale
|
||||
if (retries > 0 && (await isLockStale(lockPath))) {
|
||||
debugLogger.debug('[MemoryService] Cleaning up stale lock file');
|
||||
await releaseLock(lockPath);
|
||||
return tryAcquireLock(lockPath, retries - 1);
|
||||
}
|
||||
debugLogger.debug(
|
||||
'[MemoryService] Lock held by another instance, skipping',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a lock file is stale (owner PID is dead or lock is too old).
|
||||
*/
|
||||
export async function isLockStale(lockPath: string): Promise<boolean> {
|
||||
try {
|
||||
const content = await fs.readFile(lockPath, 'utf-8');
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
if (!isLockInfo(parsed)) {
|
||||
return true; // Invalid lock data — treat as stale
|
||||
}
|
||||
const lockInfo = parsed;
|
||||
|
||||
// Check if PID is still alive
|
||||
try {
|
||||
process.kill(lockInfo.pid, 0);
|
||||
} catch {
|
||||
// PID is dead — lock is stale
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if lock is too old
|
||||
const lockAge = Date.now() - new Date(lockInfo.startedAt).getTime();
|
||||
if (lockAge > LOCK_STALE_MS) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
// Can't read lock — treat as stale
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases the lock file.
|
||||
*/
|
||||
export async function releaseLock(lockPath: string): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(lockPath);
|
||||
} catch (error: unknown) {
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
return; // Already removed
|
||||
}
|
||||
debugLogger.warn(
|
||||
`[MemoryService] Failed to release lock: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the extraction state file, or returns a default state.
|
||||
*/
|
||||
export async function readExtractionState(
|
||||
statePath: string,
|
||||
): Promise<ExtractionState> {
|
||||
try {
|
||||
const content = await fs.readFile(statePath, 'utf-8');
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
if (!isExtractionState(parsed)) {
|
||||
return { runs: [] };
|
||||
}
|
||||
|
||||
const runs: ExtractionRun[] = [];
|
||||
for (const run of parsed.runs) {
|
||||
if (!isExtractionRun(run)) continue;
|
||||
runs.push({
|
||||
runAt: run.runAt,
|
||||
sessionIds: run.sessionIds.filter(
|
||||
(sid): sid is string => typeof sid === 'string',
|
||||
),
|
||||
skillsCreated: run.skillsCreated.filter(
|
||||
(sk): sk is string => typeof sk === 'string',
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return { runs };
|
||||
} catch (error) {
|
||||
debugLogger.debug(
|
||||
'[MemoryService] Failed to read extraction state:',
|
||||
error,
|
||||
);
|
||||
return { runs: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the extraction state atomically (temp file + rename).
|
||||
*/
|
||||
export async function writeExtractionState(
|
||||
statePath: string,
|
||||
state: ExtractionState,
|
||||
): Promise<void> {
|
||||
const tmpPath = `${statePath}.tmp`;
|
||||
await fs.writeFile(tmpPath, JSON.stringify(state, null, 2));
|
||||
await fs.rename(tmpPath, statePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a conversation record should be considered for processing.
|
||||
* Filters out subagent sessions, sessions that haven't been idle long enough,
|
||||
* and sessions with too few user messages.
|
||||
*/
|
||||
function shouldProcessConversation(parsed: ConversationRecord): boolean {
|
||||
// Skip subagent sessions
|
||||
if (parsed.kind === 'subagent') return false;
|
||||
|
||||
// Skip sessions that are still active (not idle for 3+ hours)
|
||||
const lastUpdated = new Date(parsed.lastUpdated).getTime();
|
||||
if (Date.now() - lastUpdated < MIN_IDLE_MS) return false;
|
||||
|
||||
// Skip sessions with too few user messages
|
||||
const userMessageCount = parsed.messages.filter(
|
||||
(m) => m.type === 'user',
|
||||
).length;
|
||||
if (userMessageCount < MIN_USER_MESSAGES) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans the chats directory for eligible session files (sorted most-recent-first,
|
||||
* capped at MAX_SESSION_INDEX_SIZE). Shared by buildSessionIndex.
|
||||
*/
|
||||
async function scanEligibleSessions(
|
||||
chatsDir: string,
|
||||
): Promise<Array<{ conversation: ConversationRecord; filePath: string }>> {
|
||||
let allFiles: string[];
|
||||
try {
|
||||
allFiles = await fs.readdir(chatsDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sessionFiles = allFiles.filter(
|
||||
(f) => f.startsWith(SESSION_FILE_PREFIX) && f.endsWith('.json'),
|
||||
);
|
||||
|
||||
// Sort by filename descending (most recent first)
|
||||
sessionFiles.sort((a, b) => b.localeCompare(a));
|
||||
|
||||
const results: Array<{ conversation: ConversationRecord; filePath: string }> =
|
||||
[];
|
||||
|
||||
for (const file of sessionFiles) {
|
||||
if (results.length >= MAX_SESSION_INDEX_SIZE) break;
|
||||
|
||||
const filePath = path.join(chatsDir, file);
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
if (!isConversationRecord(parsed)) continue;
|
||||
if (!shouldProcessConversation(parsed)) continue;
|
||||
|
||||
results.push({ conversation: parsed, filePath });
|
||||
} catch {
|
||||
// Skip unreadable files
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a session index for the extraction agent: a compact listing of all
|
||||
* eligible sessions with their summary, file path, and new/previously-processed status.
|
||||
* The agent can use read_file on paths to inspect sessions that look promising.
|
||||
* Orchestrates the memory subsystem for a single GeminiClient. The service
|
||||
* owns a `DefaultMemoryProvider` and exposes a stable lifecycle surface
|
||||
* (`onSessionStart`, `getSystemInstructions`, `getTurnContext`,
|
||||
* `onTurnComplete`, `onSessionEnd`) so that GeminiClient can stay agnostic
|
||||
* of the underlying implementation. Each lifecycle call is wrapped in a
|
||||
* try/catch so a buggy provider can never crash a turn.
|
||||
*
|
||||
* Returns the index text and the list of new (unprocessed) session IDs.
|
||||
* The methods return `Promise`s for callsite consistency even though the
|
||||
* current provider is synchronous; this keeps the boundary stable should
|
||||
* the provider ever need to do asynchronous work.
|
||||
*/
|
||||
export async function buildSessionIndex(
|
||||
chatsDir: string,
|
||||
state: ExtractionState,
|
||||
): Promise<{ sessionIndex: string; newSessionIds: string[] }> {
|
||||
const processedSet = getProcessedSessionIds(state);
|
||||
const eligible = await scanEligibleSessions(chatsDir);
|
||||
export class MemoryService {
|
||||
private readonly provider: MemoryProvider = new DefaultMemoryProvider();
|
||||
|
||||
if (eligible.length === 0) {
|
||||
return { sessionIndex: '', newSessionIds: [] };
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
const newSessionIds: string[] = [];
|
||||
|
||||
for (const { conversation, filePath } of eligible) {
|
||||
const userMessageCount = conversation.messages.filter(
|
||||
(m) => m.type === 'user',
|
||||
).length;
|
||||
const isNew = !processedSet.has(conversation.sessionId);
|
||||
if (isNew) {
|
||||
newSessionIds.push(conversation.sessionId);
|
||||
}
|
||||
|
||||
const status = isNew ? '[NEW]' : '[old]';
|
||||
const summary = conversation.summary ?? '(no summary)';
|
||||
lines.push(
|
||||
`${status} ${summary} (${userMessageCount} user msgs) — ${filePath}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { sessionIndex: lines.join('\n'), newSessionIds };
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a summary of all existing skills — both memory-extracted skills
|
||||
* in the skillsDir and globally/workspace-discovered skills from the SkillManager.
|
||||
* This prevents the extraction agent from duplicating already-available skills.
|
||||
*/
|
||||
async function buildExistingSkillsSummary(
|
||||
skillsDir: string,
|
||||
config: Config,
|
||||
): Promise<string> {
|
||||
const sections: string[] = [];
|
||||
|
||||
// 1. Memory-extracted skills (from previous runs)
|
||||
const memorySkills: string[] = [];
|
||||
try {
|
||||
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const skillPath = path.join(skillsDir, entry.name, 'SKILL.md');
|
||||
try {
|
||||
const content = await fs.readFile(skillPath, 'utf-8');
|
||||
const match = content.match(FRONTMATTER_REGEX);
|
||||
if (match) {
|
||||
const parsed = parseFrontmatter(match[1]);
|
||||
const name = parsed?.name ?? entry.name;
|
||||
const desc = parsed?.description ?? '';
|
||||
memorySkills.push(`- **${name}**: ${desc}`);
|
||||
} else {
|
||||
memorySkills.push(`- **${entry.name}**`);
|
||||
}
|
||||
} catch {
|
||||
// Skill directory without SKILL.md, skip
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skills directory doesn't exist yet
|
||||
}
|
||||
|
||||
if (memorySkills.length > 0) {
|
||||
sections.push(
|
||||
`## Previously Extracted Skills (in ${skillsDir})\n${memorySkills.join('\n')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Discovered skills — categorize by source location
|
||||
try {
|
||||
const discoveredSkills = config.getSkillManager().getSkills();
|
||||
if (discoveredSkills.length > 0) {
|
||||
const userSkillsDir = Storage.getUserSkillsDir();
|
||||
const globalSkills: string[] = [];
|
||||
const workspaceSkills: string[] = [];
|
||||
const extensionSkills: string[] = [];
|
||||
const builtinSkills: string[] = [];
|
||||
|
||||
for (const s of discoveredSkills) {
|
||||
const loc = s.location;
|
||||
if (loc.includes('/bundle/') || loc.includes('\\bundle\\')) {
|
||||
builtinSkills.push(`- **${s.name}**: ${s.description}`);
|
||||
} else if (loc.startsWith(userSkillsDir)) {
|
||||
globalSkills.push(`- **${s.name}**: ${s.description} (${loc})`);
|
||||
} else if (
|
||||
loc.includes('/extensions/') ||
|
||||
loc.includes('\\extensions\\')
|
||||
) {
|
||||
extensionSkills.push(`- **${s.name}**: ${s.description}`);
|
||||
} else {
|
||||
workspaceSkills.push(`- **${s.name}**: ${s.description} (${loc})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (globalSkills.length > 0) {
|
||||
sections.push(
|
||||
`## Global Skills (~/.gemini/skills — do NOT duplicate)\n${globalSkills.join('\n')}`,
|
||||
);
|
||||
}
|
||||
if (workspaceSkills.length > 0) {
|
||||
sections.push(
|
||||
`## Workspace Skills (.gemini/skills — do NOT duplicate)\n${workspaceSkills.join('\n')}`,
|
||||
);
|
||||
}
|
||||
if (extensionSkills.length > 0) {
|
||||
sections.push(
|
||||
`## Extension Skills (from installed extensions — do NOT duplicate)\n${extensionSkills.join('\n')}`,
|
||||
);
|
||||
}
|
||||
if (builtinSkills.length > 0) {
|
||||
sections.push(
|
||||
`## Builtin Skills (bundled with CLI — do NOT duplicate)\n${builtinSkills.join('\n')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// SkillManager not available
|
||||
}
|
||||
|
||||
return sections.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an AgentLoopContext from a Config for background agent execution.
|
||||
*/
|
||||
function buildAgentLoopContext(config: Config): AgentLoopContext {
|
||||
// Create a PolicyEngine that auto-approves all tool calls so the
|
||||
// background sub-agent never prompts the user for confirmation.
|
||||
const autoApprovePolicy = new PolicyEngine({
|
||||
rules: [
|
||||
{
|
||||
toolName: '*',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: 100,
|
||||
},
|
||||
],
|
||||
});
|
||||
const autoApproveBus = new MessageBus(autoApprovePolicy);
|
||||
|
||||
return {
|
||||
config,
|
||||
promptId: `skill-extraction-${randomUUID().slice(0, 8)}`,
|
||||
toolRegistry: config.getToolRegistry(),
|
||||
promptRegistry: new PromptRegistry(),
|
||||
resourceRegistry: new ResourceRegistry(),
|
||||
messageBus: autoApproveBus,
|
||||
geminiClient: config.getGeminiClient(),
|
||||
sandboxManager: config.sandboxManager,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates all .patch files in the skills directory using the `diff` library.
|
||||
* Parses each patch, reads the target file(s), and attempts a dry-run apply.
|
||||
* Removes patches that fail validation. Returns the filenames of valid patches.
|
||||
*/
|
||||
export async function validatePatches(
|
||||
skillsDir: string,
|
||||
config: Config,
|
||||
): Promise<string[]> {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fs.readdir(skillsDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const patchFiles = entries.filter((e) => e.endsWith('.patch'));
|
||||
const validPatches: string[] = [];
|
||||
|
||||
for (const patchFile of patchFiles) {
|
||||
const patchPath = path.join(skillsDir, patchFile);
|
||||
let valid = true;
|
||||
let reason = '';
|
||||
constructor(private readonly config: Config) {}
|
||||
|
||||
async onSessionStart(sessionId: string): Promise<void> {
|
||||
try {
|
||||
const patchContent = await fs.readFile(patchPath, 'utf-8');
|
||||
const parsedPatches = Diff.parsePatch(patchContent);
|
||||
|
||||
if (!hasParsedPatchHunks(parsedPatches)) {
|
||||
valid = false;
|
||||
reason = 'no hunks found in patch';
|
||||
} else {
|
||||
const applied = await applyParsedSkillPatches(parsedPatches, config);
|
||||
if (!applied.success) {
|
||||
valid = false;
|
||||
switch (applied.reason) {
|
||||
case 'missingTargetPath':
|
||||
reason = 'missing target file path in patch header';
|
||||
break;
|
||||
case 'invalidPatchHeaders':
|
||||
reason = 'invalid diff headers';
|
||||
break;
|
||||
case 'outsideAllowedRoots':
|
||||
reason = `target file is outside skill roots: ${applied.targetPath}`;
|
||||
break;
|
||||
case 'newFileAlreadyExists':
|
||||
reason = `new file target already exists: ${applied.targetPath}`;
|
||||
break;
|
||||
case 'targetNotFound':
|
||||
reason = `target file not found: ${applied.targetPath}`;
|
||||
break;
|
||||
case 'doesNotApply':
|
||||
reason = `patch does not apply cleanly to ${applied.targetPath}`;
|
||||
break;
|
||||
default:
|
||||
reason = 'unknown patch validation failure';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
valid = false;
|
||||
reason = `failed to read or parse patch: ${err}`;
|
||||
}
|
||||
|
||||
if (valid) {
|
||||
validPatches.push(patchFile);
|
||||
debugLogger.log(`[MemoryService] Patch validated: ${patchFile}`);
|
||||
} else {
|
||||
this.provider.onSessionStart(this.config, sessionId);
|
||||
} catch (error) {
|
||||
debugLogger.warn(
|
||||
`[MemoryService] Removing invalid patch ${patchFile}: ${reason}`,
|
||||
`[MemoryService] Provider "${this.provider.id}" threw during onSessionStart:`,
|
||||
error,
|
||||
);
|
||||
try {
|
||||
await fs.unlink(patchPath);
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return validPatches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point for the skill extraction background task.
|
||||
* Designed to be called fire-and-forget on session startup.
|
||||
*
|
||||
* Coordinates across multiple CLI instances via a lock file,
|
||||
* scans past sessions for reusable patterns, and runs a sub-agent
|
||||
* to extract and write SKILL.md files.
|
||||
*/
|
||||
export async function startMemoryService(config: Config): Promise<void> {
|
||||
const memoryDir = config.storage.getProjectMemoryTempDir();
|
||||
const skillsDir = config.storage.getProjectSkillsMemoryDir();
|
||||
const lockPath = path.join(memoryDir, LOCK_FILENAME);
|
||||
const statePath = path.join(memoryDir, STATE_FILENAME);
|
||||
const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats');
|
||||
|
||||
// Ensure directories exist
|
||||
await fs.mkdir(skillsDir, { recursive: true });
|
||||
|
||||
debugLogger.log(`[MemoryService] Starting. Skills dir: ${skillsDir}`);
|
||||
|
||||
// Try to acquire exclusive lock
|
||||
if (!(await tryAcquireLock(lockPath))) {
|
||||
debugLogger.log('[MemoryService] Skipped: lock held by another instance');
|
||||
return;
|
||||
async getSystemInstructions(): Promise<string> {
|
||||
try {
|
||||
return this.provider.getSystemInstructions();
|
||||
} catch (error) {
|
||||
debugLogger.warn(
|
||||
`[MemoryService] Provider "${this.provider.id}" threw during getSystemInstructions:`,
|
||||
error,
|
||||
);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
debugLogger.log('[MemoryService] Lock acquired');
|
||||
|
||||
// Register with ExecutionLifecycleService for background tracking
|
||||
const abortController = new AbortController();
|
||||
const handle = ExecutionLifecycleService.createExecution(
|
||||
'', // no initial output
|
||||
() => abortController.abort(), // onKill
|
||||
'none',
|
||||
undefined, // no format injection
|
||||
'Skill extraction',
|
||||
'silent',
|
||||
);
|
||||
const executionId = handle.pid;
|
||||
|
||||
const startTime = Date.now();
|
||||
let completionResult: { error: Error } | undefined;
|
||||
try {
|
||||
// Read extraction state
|
||||
const state = await readExtractionState(statePath);
|
||||
const previousRuns = state.runs.length;
|
||||
const previouslyProcessed = getProcessedSessionIds(state).size;
|
||||
debugLogger.log(
|
||||
`[MemoryService] State loaded: ${previousRuns} previous run(s), ${previouslyProcessed} session(s) already processed`,
|
||||
);
|
||||
|
||||
// Build session index: all eligible sessions with summaries + file paths.
|
||||
// The agent decides which to read in full via read_file.
|
||||
const { sessionIndex, newSessionIds } = await buildSessionIndex(
|
||||
chatsDir,
|
||||
state,
|
||||
);
|
||||
|
||||
const totalInIndex = sessionIndex ? sessionIndex.split('\n').length : 0;
|
||||
debugLogger.log(
|
||||
`[MemoryService] Session scan: ${totalInIndex} eligible session(s) found, ${newSessionIds.length} new`,
|
||||
);
|
||||
|
||||
if (newSessionIds.length === 0) {
|
||||
debugLogger.log('[MemoryService] Skipped: no new sessions to process');
|
||||
return;
|
||||
}
|
||||
|
||||
// Snapshot existing skill directories before extraction
|
||||
const skillsBefore = new Set<string>();
|
||||
const patchContentsBefore = new Map<string, string>();
|
||||
async getTurnContext(query: string): Promise<string> {
|
||||
try {
|
||||
const entries = await fs.readdir(skillsDir);
|
||||
for (const e of entries) {
|
||||
if (e.endsWith('.patch')) {
|
||||
try {
|
||||
patchContentsBefore.set(
|
||||
e,
|
||||
await fs.readFile(path.join(skillsDir, e), 'utf-8'),
|
||||
);
|
||||
} catch {
|
||||
// Ignore unreadable existing patches.
|
||||
}
|
||||
continue;
|
||||
}
|
||||
skillsBefore.add(e);
|
||||
}
|
||||
} catch {
|
||||
// Empty skills dir
|
||||
}
|
||||
debugLogger.log(
|
||||
`[MemoryService] ${skillsBefore.size} existing skill(s) in memory`,
|
||||
);
|
||||
|
||||
// Read existing skills for context (memory-extracted + global/workspace)
|
||||
const existingSkillsSummary = await buildExistingSkillsSummary(
|
||||
skillsDir,
|
||||
config,
|
||||
);
|
||||
if (existingSkillsSummary) {
|
||||
debugLogger.log(
|
||||
`[MemoryService] Existing skills context:\n${existingSkillsSummary}`,
|
||||
return this.provider.getTurnContext(query);
|
||||
} catch (error) {
|
||||
debugLogger.warn(
|
||||
`[MemoryService] Provider "${this.provider.id}" threw during getTurnContext:`,
|
||||
error,
|
||||
);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Build agent definition and context
|
||||
const agentDefinition = SkillExtractionAgent(
|
||||
skillsDir,
|
||||
sessionIndex,
|
||||
existingSkillsSummary,
|
||||
);
|
||||
|
||||
const context = buildAgentLoopContext(config);
|
||||
|
||||
// Register the agent's model config since it's not going through AgentRegistry.
|
||||
const modelAlias = getModelConfigAlias(agentDefinition);
|
||||
config.modelConfigService.registerRuntimeModelConfig(modelAlias, {
|
||||
modelConfig: agentDefinition.modelConfig,
|
||||
});
|
||||
debugLogger.log(
|
||||
`[MemoryService] Starting extraction agent (model: ${agentDefinition.modelConfig.model}, maxTurns: 30, maxTime: 30min)`,
|
||||
);
|
||||
|
||||
// Create and run the extraction agent
|
||||
const executor = await LocalAgentExecutor.create(agentDefinition, context);
|
||||
|
||||
await executor.run(
|
||||
{ request: 'Extract skills from the provided sessions.' },
|
||||
abortController.signal,
|
||||
);
|
||||
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
|
||||
// Diff skills directory to find newly created skills
|
||||
const skillsCreated: string[] = [];
|
||||
onTurnComplete(userMessage: string, assistantMessage: string): void {
|
||||
try {
|
||||
const entriesAfter = await fs.readdir(skillsDir);
|
||||
for (const e of entriesAfter) {
|
||||
if (!skillsBefore.has(e) && !e.endsWith('.patch')) {
|
||||
skillsCreated.push(e);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skills dir read failed
|
||||
this.provider.onTurnComplete(userMessage, assistantMessage);
|
||||
} catch (error) {
|
||||
debugLogger.warn(
|
||||
`[MemoryService] Provider "${this.provider.id}" threw during onTurnComplete:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate any .patch files the agent generated
|
||||
const validPatches = await validatePatches(skillsDir, config);
|
||||
const patchesCreatedThisRun: string[] = [];
|
||||
for (const patchFile of validPatches) {
|
||||
const patchPath = path.join(skillsDir, patchFile);
|
||||
let currentContent: string;
|
||||
try {
|
||||
currentContent = await fs.readFile(patchPath, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (patchContentsBefore.get(patchFile) !== currentContent) {
|
||||
patchesCreatedThisRun.push(patchFile);
|
||||
}
|
||||
}
|
||||
if (validPatches.length > 0) {
|
||||
debugLogger.log(
|
||||
`[MemoryService] ${validPatches.length} valid patch(es) currently in inbox; ${patchesCreatedThisRun.length} created or updated this run`,
|
||||
);
|
||||
}
|
||||
|
||||
// Record the run with full metadata
|
||||
const run: ExtractionRun = {
|
||||
runAt: new Date().toISOString(),
|
||||
sessionIds: newSessionIds,
|
||||
skillsCreated,
|
||||
};
|
||||
const updatedState: ExtractionState = {
|
||||
runs: [...state.runs, run],
|
||||
};
|
||||
await writeExtractionState(statePath, updatedState);
|
||||
|
||||
if (skillsCreated.length > 0 || patchesCreatedThisRun.length > 0) {
|
||||
const completionParts: string[] = [];
|
||||
if (skillsCreated.length > 0) {
|
||||
completionParts.push(
|
||||
`created ${skillsCreated.length} skill(s): ${skillsCreated.join(', ')}`,
|
||||
);
|
||||
}
|
||||
if (patchesCreatedThisRun.length > 0) {
|
||||
completionParts.push(
|
||||
`prepared ${patchesCreatedThisRun.length} patch(es): ${patchesCreatedThisRun.join(', ')}`,
|
||||
);
|
||||
}
|
||||
debugLogger.log(
|
||||
`[MemoryService] Completed in ${elapsed}s. ${completionParts.join('; ')} (processed ${newSessionIds.length} session(s))`,
|
||||
);
|
||||
const feedbackParts: string[] = [];
|
||||
if (skillsCreated.length > 0) {
|
||||
feedbackParts.push(
|
||||
`${skillsCreated.length} new skill${skillsCreated.length > 1 ? 's' : ''} extracted from past sessions: ${skillsCreated.join(', ')}`,
|
||||
);
|
||||
}
|
||||
if (patchesCreatedThisRun.length > 0) {
|
||||
feedbackParts.push(
|
||||
`${patchesCreatedThisRun.length} skill update${patchesCreatedThisRun.length > 1 ? 's' : ''} extracted from past sessions`,
|
||||
);
|
||||
}
|
||||
coreEvents.emitFeedback(
|
||||
'info',
|
||||
`${feedbackParts.join('. ')}. Use /memory inbox to review.`,
|
||||
);
|
||||
} else {
|
||||
debugLogger.log(
|
||||
`[MemoryService] Completed in ${elapsed}s. No new skills or patches created (processed ${newSessionIds.length} session(s))`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
if (abortController.signal.aborted) {
|
||||
debugLogger.log(`[MemoryService] Cancelled after ${elapsed}s`);
|
||||
} else {
|
||||
debugLogger.log(
|
||||
`[MemoryService] Failed after ${elapsed}s: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
completionResult = {
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
};
|
||||
return;
|
||||
} finally {
|
||||
await releaseLock(lockPath);
|
||||
debugLogger.log('[MemoryService] Lock released');
|
||||
if (executionId !== undefined) {
|
||||
ExecutionLifecycleService.completeExecution(
|
||||
executionId,
|
||||
completionResult,
|
||||
async onSessionEnd(): Promise<void> {
|
||||
try {
|
||||
this.provider.onSessionEnd();
|
||||
} catch (error) {
|
||||
debugLogger.warn(
|
||||
`[MemoryService] Provider "${this.provider.id}" threw during onSessionEnd:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,20 +4,13 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
afterAll,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
canUseRipgrep,
|
||||
RipGrepTool,
|
||||
ensureRgPath,
|
||||
type RipGrepToolParams,
|
||||
getRipgrepPath,
|
||||
} from './ripGrep.js';
|
||||
import type { GrepResult } from './tools.js';
|
||||
import path from 'node:path';
|
||||
@@ -25,18 +18,21 @@ import { isSubpath } from '../utils/paths.js';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import { GEMINI_IGNORE_FILE_NAME } from '../config/constants.js';
|
||||
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { PassThrough, Readable } from 'node:stream';
|
||||
import EventEmitter from 'node:events';
|
||||
import { downloadRipGrep } from '@joshua.litt/get-ripgrep';
|
||||
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
|
||||
// Mock dependencies for canUseRipgrep
|
||||
vi.mock('@joshua.litt/get-ripgrep', () => ({
|
||||
downloadRipGrep: vi.fn(),
|
||||
}));
|
||||
import { fileExists } from '../utils/fileUtils.js';
|
||||
|
||||
vi.mock('../utils/fileUtils.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../utils/fileUtils.js')>();
|
||||
return {
|
||||
...actual,
|
||||
fileExists: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock child_process for ripgrep calls
|
||||
vi.mock('child_process', () => ({
|
||||
@@ -44,161 +40,42 @@ vi.mock('child_process', () => ({
|
||||
}));
|
||||
|
||||
const mockSpawn = vi.mocked(spawn);
|
||||
const downloadRipGrepMock = vi.mocked(downloadRipGrep);
|
||||
const originalGetGlobalBinDir = Storage.getGlobalBinDir.bind(Storage);
|
||||
const storageSpy = vi.spyOn(Storage, 'getGlobalBinDir');
|
||||
|
||||
function getRipgrepBinaryName() {
|
||||
return process.platform === 'win32' ? 'rg.exe' : 'rg';
|
||||
}
|
||||
|
||||
describe('canUseRipgrep', () => {
|
||||
let tempRootDir: string;
|
||||
let binDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
downloadRipGrepMock.mockReset();
|
||||
downloadRipGrepMock.mockResolvedValue(undefined);
|
||||
tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ripgrep-bin-'));
|
||||
binDir = path.join(tempRootDir, 'bin');
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
storageSpy.mockImplementation(() => binDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
storageSpy.mockImplementation(() => originalGetGlobalBinDir());
|
||||
await fs.rm(tempRootDir, { recursive: true, force: true });
|
||||
beforeEach(() => {
|
||||
vi.mocked(fileExists).mockReset();
|
||||
});
|
||||
|
||||
it('should return true if ripgrep already exists', async () => {
|
||||
const existingPath = path.join(binDir, getRipgrepBinaryName());
|
||||
await fs.writeFile(existingPath, '');
|
||||
|
||||
vi.mocked(fileExists).mockResolvedValue(true);
|
||||
const result = await canUseRipgrep();
|
||||
expect(result).toBe(true);
|
||||
expect(downloadRipGrepMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should download ripgrep and return true if it does not exist initially', async () => {
|
||||
const expectedPath = path.join(binDir, getRipgrepBinaryName());
|
||||
|
||||
downloadRipGrepMock.mockImplementation(async () => {
|
||||
await fs.writeFile(expectedPath, '');
|
||||
});
|
||||
|
||||
it('should return false if file does not exist', async () => {
|
||||
vi.mocked(fileExists).mockResolvedValue(false);
|
||||
const result = await canUseRipgrep();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(downloadRipGrep).toHaveBeenCalledWith(binDir);
|
||||
await expect(fs.access(expectedPath)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return false if download fails and file does not exist', async () => {
|
||||
const result = await canUseRipgrep();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(downloadRipGrep).toHaveBeenCalledWith(binDir);
|
||||
});
|
||||
|
||||
it('should propagate errors from downloadRipGrep', async () => {
|
||||
const error = new Error('Download failed');
|
||||
downloadRipGrepMock.mockRejectedValue(error);
|
||||
|
||||
await expect(canUseRipgrep()).rejects.toThrow(error);
|
||||
expect(downloadRipGrep).toHaveBeenCalledWith(binDir);
|
||||
});
|
||||
|
||||
it('should only download once when called concurrently', async () => {
|
||||
const expectedPath = path.join(binDir, getRipgrepBinaryName());
|
||||
|
||||
downloadRipGrepMock.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
fs.writeFile(expectedPath, '')
|
||||
.then(() => resolve())
|
||||
.catch(reject);
|
||||
}, 0);
|
||||
}),
|
||||
);
|
||||
|
||||
const firstCall = ensureRgPath();
|
||||
const secondCall = ensureRgPath();
|
||||
|
||||
const [pathOne, pathTwo] = await Promise.all([firstCall, secondCall]);
|
||||
|
||||
expect(pathOne).toBe(expectedPath);
|
||||
expect(pathTwo).toBe(expectedPath);
|
||||
expect(downloadRipGrepMock).toHaveBeenCalledTimes(1);
|
||||
await expect(fs.access(expectedPath)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureRgPath', () => {
|
||||
let tempRootDir: string;
|
||||
let binDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
downloadRipGrepMock.mockReset();
|
||||
downloadRipGrepMock.mockResolvedValue(undefined);
|
||||
tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ripgrep-bin-'));
|
||||
binDir = path.join(tempRootDir, 'bin');
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
storageSpy.mockImplementation(() => binDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
storageSpy.mockImplementation(() => originalGetGlobalBinDir());
|
||||
await fs.rm(tempRootDir, { recursive: true, force: true });
|
||||
beforeEach(() => {
|
||||
vi.mocked(fileExists).mockReset();
|
||||
});
|
||||
|
||||
it('should return rg path if ripgrep already exists', async () => {
|
||||
const existingPath = path.join(binDir, getRipgrepBinaryName());
|
||||
await fs.writeFile(existingPath, '');
|
||||
|
||||
vi.mocked(fileExists).mockResolvedValue(true);
|
||||
const rgPath = await ensureRgPath();
|
||||
expect(rgPath).toBe(existingPath);
|
||||
expect(downloadRipGrep).not.toHaveBeenCalled();
|
||||
expect(rgPath).toBe(await getRipgrepPath());
|
||||
});
|
||||
|
||||
it('should return rg path if ripgrep is downloaded successfully', async () => {
|
||||
const expectedPath = path.join(binDir, getRipgrepBinaryName());
|
||||
|
||||
downloadRipGrepMock.mockImplementation(async () => {
|
||||
await fs.writeFile(expectedPath, '');
|
||||
});
|
||||
|
||||
const rgPath = await ensureRgPath();
|
||||
expect(rgPath).toBe(expectedPath);
|
||||
expect(downloadRipGrep).toHaveBeenCalledTimes(1);
|
||||
await expect(fs.access(expectedPath)).resolves.toBeUndefined();
|
||||
it('should throw an error if ripgrep cannot be used', async () => {
|
||||
vi.mocked(fileExists).mockResolvedValue(false);
|
||||
await expect(ensureRgPath()).rejects.toThrow(
|
||||
/Cannot find bundled ripgrep binary/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if ripgrep cannot be used after download attempt', async () => {
|
||||
await expect(ensureRgPath()).rejects.toThrow('Cannot use ripgrep.');
|
||||
expect(downloadRipGrep).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should propagate errors from downloadRipGrep', async () => {
|
||||
const error = new Error('Download failed');
|
||||
downloadRipGrepMock.mockRejectedValue(error);
|
||||
|
||||
await expect(ensureRgPath()).rejects.toThrow(error);
|
||||
expect(downloadRipGrep).toHaveBeenCalledWith(binDir);
|
||||
});
|
||||
|
||||
it.runIf(process.platform === 'win32')(
|
||||
'should detect ripgrep when only rg.exe exists on Windows',
|
||||
async () => {
|
||||
const expectedRgExePath = path.join(binDir, 'rg.exe');
|
||||
await fs.writeFile(expectedRgExePath, '');
|
||||
|
||||
const rgPath = await ensureRgPath();
|
||||
expect(rgPath).toBe(expectedRgExePath);
|
||||
expect(downloadRipGrep).not.toHaveBeenCalled();
|
||||
await expect(fs.access(expectedRgExePath)).resolves.toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Helper function to create mock spawn implementations
|
||||
@@ -247,9 +124,6 @@ function createMockSpawn(
|
||||
|
||||
describe('RipGrepTool', () => {
|
||||
let tempRootDir: string;
|
||||
let tempBinRoot: string;
|
||||
let binDir: string;
|
||||
let ripgrepBinaryPath: string;
|
||||
let grepTool: RipGrepTool;
|
||||
const abortSignal = new AbortController().signal;
|
||||
|
||||
@@ -266,19 +140,12 @@ describe('RipGrepTool', () => {
|
||||
} as unknown as Config;
|
||||
|
||||
beforeEach(async () => {
|
||||
downloadRipGrepMock.mockReset();
|
||||
downloadRipGrepMock.mockResolvedValue(undefined);
|
||||
mockSpawn.mockReset();
|
||||
mockSpawn.mockImplementation(createMockSpawn());
|
||||
tempBinRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'ripgrep-bin-'));
|
||||
binDir = path.join(tempBinRoot, 'bin');
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
const binaryName = process.platform === 'win32' ? 'rg.exe' : 'rg';
|
||||
ripgrepBinaryPath = path.join(binDir, binaryName);
|
||||
await fs.writeFile(ripgrepBinaryPath, '');
|
||||
storageSpy.mockImplementation(() => binDir);
|
||||
tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grep-tool-root-'));
|
||||
|
||||
vi.mocked(fileExists).mockResolvedValue(true);
|
||||
|
||||
mockConfig = {
|
||||
getTargetDir: () => tempRootDir,
|
||||
getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir),
|
||||
@@ -335,9 +202,7 @@ describe('RipGrepTool', () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
storageSpy.mockImplementation(() => originalGetGlobalBinDir());
|
||||
await fs.rm(tempRootDir, { recursive: true, force: true });
|
||||
await fs.rm(tempBinRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('validateToolParams', () => {
|
||||
@@ -834,16 +699,16 @@ describe('RipGrepTool', () => {
|
||||
});
|
||||
|
||||
it('should throw an error if ripgrep is not available', async () => {
|
||||
await fs.rm(ripgrepBinaryPath, { force: true });
|
||||
downloadRipGrepMock.mockResolvedValue(undefined);
|
||||
vi.mocked(fileExists).mockResolvedValue(false);
|
||||
|
||||
const params: RipGrepToolParams = { pattern: 'world' };
|
||||
const invocation = grepTool.build(params);
|
||||
|
||||
expect(await invocation.execute({ abortSignal })).toStrictEqual({
|
||||
llmContent: 'Error during grep search operation: Cannot use ripgrep.',
|
||||
returnDisplay: 'Error: Cannot use ripgrep.',
|
||||
});
|
||||
const result = await invocation.execute({ abortSignal });
|
||||
expect(result.llmContent).toContain('Cannot find bundled ripgrep binary');
|
||||
|
||||
// restore the mock for subsequent tests
|
||||
vi.mocked(fileExists).mockResolvedValue(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2080,6 +1945,68 @@ describe('RipGrepTool', () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
storageSpy.mockRestore();
|
||||
describe('getRipgrepPath', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('OS/Architecture Resolution', () => {
|
||||
it.each([
|
||||
{ platform: 'darwin', arch: 'arm64', expectedBin: 'rg-darwin-arm64' },
|
||||
{ platform: 'darwin', arch: 'x64', expectedBin: 'rg-darwin-x64' },
|
||||
{ platform: 'linux', arch: 'arm64', expectedBin: 'rg-linux-arm64' },
|
||||
{ platform: 'linux', arch: 'x64', expectedBin: 'rg-linux-x64' },
|
||||
{ platform: 'win32', arch: 'x64', expectedBin: 'rg-win32-x64.exe' },
|
||||
])(
|
||||
'should map $platform $arch to $expectedBin',
|
||||
async ({ platform, arch, expectedBin }) => {
|
||||
vi.spyOn(os, 'platform').mockReturnValue(platform as NodeJS.Platform);
|
||||
vi.spyOn(os, 'arch').mockReturnValue(arch);
|
||||
vi.mocked(fileExists).mockImplementation(async (checkPath) =>
|
||||
checkPath.endsWith(expectedBin),
|
||||
);
|
||||
|
||||
const resolvedPath = await getRipgrepPath();
|
||||
expect(resolvedPath).not.toBeNull();
|
||||
expect(resolvedPath?.endsWith(expectedBin)).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Path Fallback Logic', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(os, 'platform').mockReturnValue('linux');
|
||||
vi.spyOn(os, 'arch').mockReturnValue('x64');
|
||||
});
|
||||
|
||||
it('should resolve the SEA (flattened) path first', async () => {
|
||||
vi.mocked(fileExists).mockImplementation(async (checkPath) =>
|
||||
checkPath.includes(path.normalize('tools/vendor/ripgrep')),
|
||||
);
|
||||
|
||||
const resolvedPath = await getRipgrepPath();
|
||||
expect(resolvedPath).not.toBeNull();
|
||||
expect(resolvedPath).toContain(path.normalize('tools/vendor/ripgrep'));
|
||||
});
|
||||
|
||||
it('should fall back to the Dev path if SEA path is missing', async () => {
|
||||
vi.mocked(fileExists).mockImplementation(
|
||||
async (checkPath) =>
|
||||
checkPath.includes(path.normalize('core/vendor/ripgrep')) &&
|
||||
!checkPath.includes('tools'),
|
||||
);
|
||||
|
||||
const resolvedPath = await getRipgrepPath();
|
||||
expect(resolvedPath).not.toBeNull();
|
||||
expect(resolvedPath).toContain(path.normalize('core/vendor/ripgrep'));
|
||||
expect(resolvedPath).not.toContain('tools');
|
||||
});
|
||||
|
||||
it('should return null if binary is missing from both paths', async () => {
|
||||
vi.mocked(fileExists).mockResolvedValue(false);
|
||||
|
||||
const resolvedPath = await getRipgrepPath();
|
||||
expect(resolvedPath).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,8 @@ import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import fs from 'node:fs';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { downloadRipGrep } from '@joshua.litt/get-ripgrep';
|
||||
import os from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
BaseDeclarativeTool,
|
||||
BaseToolInvocation,
|
||||
@@ -22,7 +23,6 @@ import { makeRelative, shortenPath } from '../utils/paths.js';
|
||||
import { getErrorMessage, isNodeError } from '../utils/errors.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { fileExists } from '../utils/fileUtils.js';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import { GREP_TOOL_NAME } from './tool-names.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import {
|
||||
@@ -39,73 +39,48 @@ import { RIP_GREP_DEFINITION } from './definitions/coreTools.js';
|
||||
import { resolveToolDeclaration } from './definitions/resolver.js';
|
||||
import { type GrepMatch, formatGrepResults } from './grep-utils.js';
|
||||
|
||||
function getRgCandidateFilenames(): readonly string[] {
|
||||
return process.platform === 'win32' ? ['rg.exe', 'rg'] : ['rg'];
|
||||
}
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
async function resolveExistingRgPath(): Promise<string | null> {
|
||||
const binDir = Storage.getGlobalBinDir();
|
||||
for (const fileName of getRgCandidateFilenames()) {
|
||||
const candidatePath = path.join(binDir, fileName);
|
||||
if (await fileExists(candidatePath)) {
|
||||
return candidatePath;
|
||||
export async function getRipgrepPath(): Promise<string | null> {
|
||||
const platform = os.platform();
|
||||
const arch = os.arch();
|
||||
|
||||
// Map to the correct bundled binary
|
||||
const binName = `rg-${platform}-${arch}${platform === 'win32' ? '.exe' : ''}`;
|
||||
|
||||
const candidatePaths = [
|
||||
// 1. SEA runtime layout: everything is flattened into the root dir
|
||||
path.resolve(__dirname, 'vendor/ripgrep', binName),
|
||||
// 2. Dev/Dist layout: packages/core/dist/tools/ripGrep.js -> packages/core/vendor/ripgrep
|
||||
path.resolve(__dirname, '../../vendor/ripgrep', binName),
|
||||
];
|
||||
|
||||
for (const candidate of candidatePaths) {
|
||||
if (await fileExists(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
let ripgrepAcquisitionPromise: Promise<string | null> | null = null;
|
||||
/**
|
||||
* Ensures a ripgrep binary is available.
|
||||
*
|
||||
* NOTE:
|
||||
* - The Gemini CLI currently prefers a managed ripgrep binary downloaded
|
||||
* into its global bin directory.
|
||||
* - Even if ripgrep is available on the system PATH, it is intentionally
|
||||
* not used at this time.
|
||||
*
|
||||
* Preference for system-installed ripgrep is blocked on:
|
||||
* - checksum verification of external binaries
|
||||
* - internalization of the get-ripgrep dependency
|
||||
*
|
||||
* See:
|
||||
* - feat(core): Prefer rg in system path (#11847)
|
||||
* - Move get-ripgrep to third_party (#12099)
|
||||
*/
|
||||
async function ensureRipgrepAvailable(): Promise<string | null> {
|
||||
const existingPath = await resolveExistingRgPath();
|
||||
if (existingPath) {
|
||||
return existingPath;
|
||||
}
|
||||
if (!ripgrepAcquisitionPromise) {
|
||||
ripgrepAcquisitionPromise = (async () => {
|
||||
try {
|
||||
await downloadRipGrep(Storage.getGlobalBinDir());
|
||||
return await resolveExistingRgPath();
|
||||
} finally {
|
||||
ripgrepAcquisitionPromise = null;
|
||||
}
|
||||
})();
|
||||
}
|
||||
return ripgrepAcquisitionPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if `rg` exists, if not then attempt to download it.
|
||||
* Checks if `rg` exists in the bundled vendor directory.
|
||||
*/
|
||||
export async function canUseRipgrep(): Promise<boolean> {
|
||||
return (await ensureRipgrepAvailable()) !== null;
|
||||
const binPath = await getRipgrepPath();
|
||||
return binPath !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures `rg` is downloaded, or throws.
|
||||
* Ensures `rg` is available, or throws.
|
||||
*/
|
||||
export async function ensureRgPath(): Promise<string> {
|
||||
const downloadedPath = await ensureRipgrepAvailable();
|
||||
if (downloadedPath) {
|
||||
return downloadedPath;
|
||||
const binPath = await getRipgrepPath();
|
||||
if (binPath !== null) {
|
||||
return binPath;
|
||||
}
|
||||
throw new Error('Cannot use ripgrep.');
|
||||
throw new Error(`Cannot find bundled ripgrep binary.`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
} from '../utils/shell-utils.js';
|
||||
import { SHELL_TOOL_NAME } from './tool-names.js';
|
||||
import { PARAM_ADDITIONAL_PERMISSIONS } from './definitions/base-declarations.js';
|
||||
import { ApprovalMode } from '../policy/types.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import { getShellDefinition } from './definitions/coreTools.js';
|
||||
import { resolveToolDeclaration } from './definitions/resolver.js';
|
||||
@@ -249,6 +250,10 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
abortSignal: AbortSignal,
|
||||
forcedDecision?: ForcedToolDecision,
|
||||
): Promise<ToolCallConfirmationDetails | false> {
|
||||
if (this.context.config.getApprovalMode() === ApprovalMode.YOLO) {
|
||||
return super.shouldConfirmExecute(abortSignal, forcedDecision);
|
||||
}
|
||||
|
||||
if (this.params[PARAM_ADDITIONAL_PERMISSIONS]) {
|
||||
return this.getConfirmationDetails(abortSignal);
|
||||
}
|
||||
|
||||
@@ -195,6 +195,7 @@ describe('compatibility', () => {
|
||||
desc: '256 colors are not supported',
|
||||
},
|
||||
])('should return $expected when $desc', ({ depth, term, expected }) => {
|
||||
vi.stubEnv('COLORTERM', '');
|
||||
process.stdout.getColorDepth = vi.fn().mockReturnValue(depth);
|
||||
if (term !== undefined) {
|
||||
vi.stubEnv('TERM', term);
|
||||
@@ -203,6 +204,13 @@ describe('compatibility', () => {
|
||||
}
|
||||
expect(supports256Colors()).toBe(expected);
|
||||
});
|
||||
|
||||
it('should return true when COLORTERM is kmscon', () => {
|
||||
process.stdout.getColorDepth = vi.fn().mockReturnValue(4);
|
||||
vi.stubEnv('TERM', 'linux');
|
||||
vi.stubEnv('COLORTERM', 'kmscon');
|
||||
expect(supports256Colors()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('supportsTrueColor', () => {
|
||||
@@ -230,6 +238,12 @@ describe('compatibility', () => {
|
||||
expected: true,
|
||||
desc: 'getColorDepth returns >= 24',
|
||||
},
|
||||
{
|
||||
colorterm: 'kmscon',
|
||||
depth: 4,
|
||||
expected: true,
|
||||
desc: 'COLORTERM is kmscon',
|
||||
},
|
||||
{
|
||||
colorterm: '',
|
||||
depth: 8,
|
||||
@@ -409,6 +423,18 @@ describe('compatibility', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should return no color warnings for kmscon terminal', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.stubEnv('TERMINAL_EMULATOR', '');
|
||||
vi.stubEnv('TERM', 'linux');
|
||||
vi.stubEnv('COLORTERM', 'kmscon');
|
||||
process.stdout.getColorDepth = vi.fn().mockReturnValue(4);
|
||||
|
||||
const warnings = getCompatibilityWarnings();
|
||||
expect(warnings.find((w) => w.id === '256-color')).toBeUndefined();
|
||||
expect(warnings.find((w) => w.id === 'true-color')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return no warnings in a standard environment with true color', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
vi.stubEnv('TERMINAL_EMULATOR', '');
|
||||
|
||||
@@ -85,6 +85,11 @@ export function supports256Colors(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Terminals supporting true color (like kmscon) also support 256 colors
|
||||
if (supportsTrueColor()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -95,7 +100,8 @@ export function supportsTrueColor(): boolean {
|
||||
// Check COLORTERM environment variable
|
||||
if (
|
||||
process.env['COLORTERM'] === 'truecolor' ||
|
||||
process.env['COLORTERM'] === '24bit'
|
||||
process.env['COLORTERM'] === '24bit' ||
|
||||
process.env['COLORTERM'] === 'kmscon'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -511,6 +511,40 @@ describe('retryWithBackoff', () => {
|
||||
expect(mockFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should retry on OpenSSL 3.x SSL error code (ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC)', async () => {
|
||||
const error = new Error('SSL error');
|
||||
(error as any).code = 'ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC';
|
||||
const mockFn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(error)
|
||||
.mockResolvedValue('success');
|
||||
|
||||
const promise = retryWithBackoff(mockFn, {
|
||||
initialDelayMs: 1,
|
||||
maxDelayMs: 1,
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
await expect(promise).resolves.toBe('success');
|
||||
expect(mockFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should retry on unknown SSL BAD_RECORD_MAC variant via substring fallback', async () => {
|
||||
const error = new Error('SSL error');
|
||||
(error as any).code = 'ERR_SSL_SOME_FUTURE_BAD_RECORD_MAC';
|
||||
const mockFn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(error)
|
||||
.mockResolvedValue('success');
|
||||
|
||||
const promise = retryWithBackoff(mockFn, {
|
||||
initialDelayMs: 1,
|
||||
maxDelayMs: 1,
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
await expect(promise).resolves.toBe('success');
|
||||
expect(mockFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should retry on gaxios-style SSL error with code property', async () => {
|
||||
// This matches the exact structure from issue #17318
|
||||
const error = new Error(
|
||||
|
||||
@@ -53,14 +53,30 @@ const RETRYABLE_NETWORK_CODES = [
|
||||
'ENOTFOUND',
|
||||
'EAI_AGAIN',
|
||||
'ECONNREFUSED',
|
||||
// SSL/TLS transient errors
|
||||
'ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC',
|
||||
'ERR_SSL_WRONG_VERSION_NUMBER',
|
||||
'ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC',
|
||||
'ERR_SSL_BAD_RECORD_MAC',
|
||||
'EPROTO', // Generic protocol error (often SSL-related)
|
||||
];
|
||||
|
||||
// Node.js builds SSL error codes by prepending ERR_SSL_ to the uppercased
|
||||
// OpenSSL reason string with spaces replaced by underscores (see
|
||||
// TLSWrap::ClearOut in node/src/crypto/crypto_tls.cc). The reason string
|
||||
// format varies by OpenSSL version (e.g. ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC
|
||||
// on OpenSSL 1.x, ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC on OpenSSL 3.x), so
|
||||
// match the stable suffix instead of enumerating every variant.
|
||||
const RETRYABLE_SSL_ERROR_PATTERN = /^ERR_SSL_.*BAD_RECORD_MAC/i;
|
||||
|
||||
/**
|
||||
* Returns true if the error code should be retried: either an exact match
|
||||
* against RETRYABLE_NETWORK_CODES, or an SSL BAD_RECORD_MAC variant (the
|
||||
* OpenSSL reason-string portion of the code varies across OpenSSL versions).
|
||||
*/
|
||||
function isRetryableSslErrorCode(code: string): boolean {
|
||||
return (
|
||||
RETRYABLE_NETWORK_CODES.includes(code) ||
|
||||
RETRYABLE_SSL_ERROR_PATTERN.test(code)
|
||||
);
|
||||
}
|
||||
|
||||
function getNetworkErrorCode(error: unknown): string | undefined {
|
||||
const getCode = (obj: unknown): string | undefined => {
|
||||
if (typeof obj !== 'object' || obj === null) {
|
||||
@@ -112,7 +128,7 @@ export function getRetryErrorType(error: unknown): string {
|
||||
}
|
||||
|
||||
const errorCode = getNetworkErrorCode(error);
|
||||
if (errorCode && RETRYABLE_NETWORK_CODES.includes(errorCode)) {
|
||||
if (errorCode && isRetryableSslErrorCode(errorCode)) {
|
||||
return errorCode;
|
||||
}
|
||||
|
||||
@@ -153,7 +169,7 @@ export function isRetryableError(
|
||||
): boolean {
|
||||
// Check for common network error codes
|
||||
const errorCode = getNetworkErrorCode(error);
|
||||
if (errorCode && RETRYABLE_NETWORK_CODES.includes(errorCode)) {
|
||||
if (errorCode && isRetryableSslErrorCode(errorCode)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-devtools",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"main": "dist/src/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-sdk",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"description": "Gemini CLI SDK",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"displayName": "Gemini CLI Companion",
|
||||
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"publisher": "google",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
"cpuTotalUs": 12157,
|
||||
"timestamp": "2026-04-08T22:28:19.098Z"
|
||||
},
|
||||
"asian-language-conv": {
|
||||
"wallClockMs": 2315.1,
|
||||
"cpuTotalUs": 6283,
|
||||
"timestamp": "2026-04-14T15:22:56.133Z"
|
||||
},
|
||||
"skill-loading-time": {
|
||||
"wallClockMs": 930.0920409999962,
|
||||
"cpuTotalUs": 1323,
|
||||
|
||||
@@ -98,6 +98,36 @@ describe('CPU Performance Tests', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('asian-language-conv: verify perf is acceptable ', async () => {
|
||||
const result = await harness.runScenario(
|
||||
'asian-language-conv',
|
||||
async () => {
|
||||
const rig = new TestRig();
|
||||
try {
|
||||
rig.setup('perf-asian-language', {
|
||||
fakeResponsesPath: join(__dirname, 'perf.asian-language.responses'),
|
||||
});
|
||||
|
||||
return await harness.measure('asian-language', async () => {
|
||||
await rig.run({
|
||||
args: ['嗨'],
|
||||
timeout: 120000,
|
||||
env: { GEMINI_API_KEY: 'fake-perf-test-key' },
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
await rig.cleanup();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (UPDATE_BASELINES) {
|
||||
harness.updateScenarioBaseline(result);
|
||||
} else {
|
||||
harness.assertWithinBaseline(result);
|
||||
}
|
||||
});
|
||||
|
||||
it('skill-loading-time: startup with many skills within baseline', async () => {
|
||||
const SKILL_COUNT = 20;
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"你好!我是 Gemini CLI,你的 AI 编程助手"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":20648,"candidatesTokenCount":12,"totalTokenCount":20769,"promptTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]}
|
||||
@@ -108,4 +108,16 @@ if (!existsSync(bundleMcpSrc)) {
|
||||
cpSync(bundleMcpSrc, bundleMcpDest, { recursive: true, dereference: true });
|
||||
console.log('Copied bundled chrome-devtools-mcp to bundle/bundled/');
|
||||
|
||||
// 7. Copy pre-built ripgrep vendor binaries
|
||||
const ripgrepVendorSrc = join(root, 'packages/core/vendor/ripgrep');
|
||||
const ripgrepVendorDest = join(bundleDir, 'vendor', 'ripgrep');
|
||||
if (existsSync(ripgrepVendorSrc)) {
|
||||
mkdirSync(ripgrepVendorDest, { recursive: true });
|
||||
cpSync(ripgrepVendorSrc, ripgrepVendorDest, {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
});
|
||||
console.log('Copied ripgrep vendor binaries to bundle/vendor/ripgrep/');
|
||||
}
|
||||
|
||||
console.log('Assets copied to bundle/');
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview This script downloads pre-built ripgrep binaries for all supported
|
||||
* architectures and platforms. These binaries are checked into the repository
|
||||
* under packages/core/vendor/ripgrep.
|
||||
*
|
||||
* Maintainers should periodically run this script to upgrade the version
|
||||
* of ripgrep being distributed.
|
||||
*
|
||||
* Usage: npx tsx scripts/download-ripgrep-binaries.ts
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import { Readable } from 'node:stream';
|
||||
import type { ReadableStream } from 'node:stream/web';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CORE_VENDOR_DIR = path.join(__dirname, '../packages/core/vendor/ripgrep');
|
||||
const VERSION = 'v13.0.0-10';
|
||||
|
||||
interface Target {
|
||||
platform: string;
|
||||
arch: string;
|
||||
file: string;
|
||||
}
|
||||
|
||||
const targets: Target[] = [
|
||||
{ platform: 'darwin', arch: 'arm64', file: 'aarch64-apple-darwin.tar.gz' },
|
||||
{ platform: 'darwin', arch: 'x64', file: 'x86_64-apple-darwin.tar.gz' },
|
||||
{
|
||||
platform: 'linux',
|
||||
arch: 'arm64',
|
||||
file: 'aarch64-unknown-linux-gnu.tar.gz',
|
||||
},
|
||||
{ platform: 'linux', arch: 'x64', file: 'x86_64-unknown-linux-musl.tar.gz' },
|
||||
{ platform: 'win32', arch: 'x64', file: 'x86_64-pc-windows-msvc.zip' },
|
||||
];
|
||||
|
||||
async function downloadBinary() {
|
||||
await fsPromises.mkdir(CORE_VENDOR_DIR, { recursive: true });
|
||||
|
||||
for (const target of targets) {
|
||||
const url = `https://github.com/microsoft/ripgrep-prebuilt/releases/download/${VERSION}/ripgrep-${VERSION}-${target.file}`;
|
||||
const archivePath = path.join(CORE_VENDOR_DIR, target.file);
|
||||
const binName = `rg-${target.platform}-${target.arch}${target.platform === 'win32' ? '.exe' : ''}`;
|
||||
const finalBinPath = path.join(CORE_VENDOR_DIR, binName);
|
||||
|
||||
if (fs.existsSync(finalBinPath)) {
|
||||
console.log(`[Cache] ${binName} already exists.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`[Download] ${url} -> ${archivePath}`);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${url}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error(`Response body is null for ${url}`);
|
||||
}
|
||||
|
||||
const fileStream = createWriteStream(archivePath);
|
||||
|
||||
// Node 18+ global fetch response.body is a ReadableStream (web stream)
|
||||
// pipeline(Readable.fromWeb(response.body), fileStream) works in Node 18+
|
||||
await pipeline(
|
||||
Readable.fromWeb(response.body as ReadableStream),
|
||||
fileStream,
|
||||
);
|
||||
|
||||
console.log(`[Extract] Extracting ${archivePath}...`);
|
||||
// Extract using shell commands for simplicity
|
||||
if (target.file.endsWith('.tar.gz')) {
|
||||
execFileSync('tar', ['-xzf', archivePath, '-C', CORE_VENDOR_DIR]);
|
||||
// Microsoft's ripgrep release extracts directly to `rg` inside the current directory sometimes
|
||||
const sourceBin = path.join(CORE_VENDOR_DIR, 'rg');
|
||||
if (fs.existsSync(sourceBin)) {
|
||||
await fsPromises.rename(sourceBin, finalBinPath);
|
||||
} else {
|
||||
// Fallback for sub-directory if it happens
|
||||
const extractedDirName = `ripgrep-${VERSION}-${target.file.replace('.tar.gz', '')}`;
|
||||
const fallbackSourceBin = path.join(
|
||||
CORE_VENDOR_DIR,
|
||||
extractedDirName,
|
||||
'rg',
|
||||
);
|
||||
if (fs.existsSync(fallbackSourceBin)) {
|
||||
await fsPromises.rename(fallbackSourceBin, finalBinPath);
|
||||
await fsPromises.rm(path.join(CORE_VENDOR_DIR, extractedDirName), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
} else {
|
||||
throw new Error(
|
||||
`Could not find extracted 'rg' binary for ${target.platform} ${target.arch}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (target.file.endsWith('.zip')) {
|
||||
execFileSync('unzip', ['-o', '-q', archivePath, '-d', CORE_VENDOR_DIR]);
|
||||
const sourceBin = path.join(CORE_VENDOR_DIR, 'rg.exe');
|
||||
if (fs.existsSync(sourceBin)) {
|
||||
await fsPromises.rename(sourceBin, finalBinPath);
|
||||
} else {
|
||||
const extractedDirName = `ripgrep-${VERSION}-${target.file.replace('.zip', '')}`;
|
||||
const fallbackSourceBin = path.join(
|
||||
CORE_VENDOR_DIR,
|
||||
extractedDirName,
|
||||
'rg.exe',
|
||||
);
|
||||
if (fs.existsSync(fallbackSourceBin)) {
|
||||
await fsPromises.rename(fallbackSourceBin, finalBinPath);
|
||||
await fsPromises.rm(path.join(CORE_VENDOR_DIR, extractedDirName), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
} else {
|
||||
throw new Error(
|
||||
`Could not find extracted 'rg.exe' binary for ${target.platform} ${target.arch}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up archive
|
||||
await fsPromises.unlink(archivePath);
|
||||
console.log(`[Success] Saved to ${finalBinPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
downloadBinary().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user