Compare commits

...

12 Commits

Author SHA1 Message Date
Sandy Tao 366036a197 feat(core): introduce MemoryProvider seam and MemoryService orchestrator
Refactors the existing background skill extractor into a layered memory
subsystem that GeminiClient can drive uniformly across interactive and
non-interactive sessions.

- Adds an internal `MemoryProvider` interface (not exported, not surfaced
  via extensions) as a typing seam between the orchestrator and its
  concrete implementation
- Refactors the existing skill-extraction logic into `DefaultMemoryProvider`,
  the sole implementation of the seam
- Introduces `MemoryService` as a thin orchestrator that owns a
  `DefaultMemoryProvider` and wraps every lifecycle call in try/catch so
  a buggy provider can't crash a turn
- Moves MemoryService ownership into `GeminiClient` (private field exposed
  via optional methods) so non-interactive entry points share the same
  lifecycle as the interactive UI
- Keeps builtin memory extraction asynchronous on startup to avoid
  blocking the first turn
- Adds unit tests for MemoryService, DefaultMemoryProvider, and the
  GeminiClient integration

The whole subsystem remains gated behind the existing
`isMemoryManagerEnabled()` experimental flag.
2026-04-17 11:08:33 -07:00
Tommaso Sciortino 55620235c0 feat: bundle ripgrep binaries into SEA for offline support (#25342) 2026-04-15 06:28:06 +00:00
Gal Zahavi 366f9e4766 fix(core): prevent YOLO mode from being downgraded (#25341) 2026-04-15 06:27:36 +00:00
Rob Clevenger 06e7621b26 Fix(core): retry additional OpenSSL 3.x SSL errors during streaming (#16075) (#25187) 2026-04-15 02:50:22 +00:00
gemini-cli-robot 8d05bdbe49 chore(release): bump version to 0.40.0-nightly.20260414.g5b1f7375a (#25420) 2026-04-15 00:06:35 +00:00
gemini-cli-robot 5b1f7375a3 Changelog for v0.37.2 (#25336)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-04-14 21:44:49 +00:00
Tommaso Sciortino d613dd05db use macos-latest-large runner where applicable. (#25413) 2026-04-14 14:05:25 -07:00
Adam Weidman a6d43cba2d ci: add agent session drift check workflow (#25389) 2026-04-14 19:31:48 +00:00
Clay 161ba28966 fix(core): detect kmscon terminal as supporting true color (#25282)
Co-authored-by: Adib234 <30782825+Adib234@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-04-14 19:08:51 +00:00
Adib234 05aa1465fe feat(cli): enable mouse clicking for cursor positioning in AskUser multi-line answers (#24630) 2026-04-14 19:07:00 +00:00
cynthialong0-0 8f6edc50c1 feat(test): add a performance test in asian language (#25392) 2026-04-14 18:43:36 +00:00
Emily Hedlund 88ddcab616 fix(core): use debug level for keychain fallback logging (#25398) 2026-04-14 18:33:01 +00:00
55 changed files with 3827 additions and 2752 deletions
@@ -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,
});
}
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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'"
+1 -1
View File
@@ -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'
+6 -3
View File
@@ -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
+15 -328
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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",
+2 -2
View File
@@ -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",
+1
View File
@@ -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);
+1 -8
View File
@@ -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);
+16 -11
View File
@@ -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 =
+4 -3
View File
@@ -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",
+1 -1
View File
@@ -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';
+1
View File
@@ -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)),
+144
View File
@@ -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();
+211 -5
View File
@@ -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',
}),
);
});
});
+3 -2
View File
@@ -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
+52 -785
View File
@@ -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,
);
}
}
+97 -170
View File
@@ -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();
});
});
});
+29 -54
View File
@@ -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.`);
}
/**
+5
View File
@@ -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', '');
+7 -1
View File
@@ -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;
}
+34
View File
@@ -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(
+22 -6
View File
@@ -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
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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",
+1 -1
View File
@@ -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": {
+5
View File
@@ -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,
+30
View File
@@ -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;
+2
View File
@@ -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}]}}]}
+12
View File
@@ -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/');
+146
View File
@@ -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);
});