mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-13 12:57:12 -07:00
feat(akl): implement Agent Knowledge Layer (AKL) - clean implementation
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
# ADR-001: Initial Design of the Agent Knowledge Layer (AKL)
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
|
||||
## Context
|
||||
|
||||
Gemini CLI agents lack persistent, high-level situational awareness across
|
||||
long-running workstreams (Epics/Features) and tend to repeat mistakes (loops) or
|
||||
lose track of established patterns. Current hierarchical memory (`GEMINI.md`) is
|
||||
rarely updated by agents.
|
||||
|
||||
## Decision
|
||||
|
||||
We will implement an "Agent Knowledge Layer" (AKL) that provides:
|
||||
|
||||
1. **Multi-Layered Storage:**
|
||||
- `machine-learnings.md` at Global, Project, and Micro levels for patterns
|
||||
and optimizations.
|
||||
- `.gemini/epics/<id>/` for situational awareness tied to branches/issues.
|
||||
2. **GitHub-Aware Discovery:** Syncing context from GitHub issues (including
|
||||
parent issues) at session start.
|
||||
3. **Active Synthesis:** Tools for agents to record ADRs (`record_decision`),
|
||||
update Epic state (`update_epic_state`), and record learnings
|
||||
(`record_learning`).
|
||||
4. **Experimental Gating:** Feature flag `experimental.akl`.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Pros:** Improved consistency across complex task loops, reduced repetition
|
||||
of failure patterns, clear documentation of architectural decisions.
|
||||
- **Cons:** Slight overhead at session start for discovery; potential for
|
||||
context pollution if not indexed effectively.
|
||||
@@ -12,6 +12,7 @@
|
||||
!.gemini/config.yaml
|
||||
!.gemini/commands/
|
||||
!.gemini/skills/
|
||||
!.gemini/epics/
|
||||
!.gemini/settings.json
|
||||
|
||||
# Note: .gemini-clipboard/ is NOT in gitignore so Gemini can access pasted images
|
||||
|
||||
@@ -150,6 +150,7 @@ they appear in the UI.
|
||||
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Plan | `experimental.plan` | Enable Plan Mode. | `true` |
|
||||
| Agent Knowledge Layer | `experimental.akl` | Enable the Agent Knowledge Layer for persistent situational awareness. | `false` |
|
||||
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
|
||||
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
|
||||
| Topic & Update Narration | `experimental.topicUpdateNarration` | Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting. | `false` |
|
||||
|
||||
@@ -1215,6 +1215,12 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.akl`** (boolean):
|
||||
- **Description:** Enable the Agent Knowledge Layer for persistent situational
|
||||
awareness.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.modelSteering`** (boolean):
|
||||
- **Description:** Enable model steering (user hints) to guide the model
|
||||
during tool execution.
|
||||
|
||||
Generated
+1
-26
@@ -2195,7 +2195,6 @@
|
||||
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@octokit/auth-token": "^6.0.0",
|
||||
"@octokit/graphql": "^9.0.2",
|
||||
@@ -2376,7 +2375,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
@@ -2426,7 +2424,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz",
|
||||
"integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
@@ -2801,7 +2798,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz",
|
||||
"integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.5.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
@@ -2835,7 +2831,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz",
|
||||
"integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.5.0",
|
||||
"@opentelemetry/resources": "2.5.0"
|
||||
@@ -2890,7 +2885,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz",
|
||||
"integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.5.0",
|
||||
"@opentelemetry/resources": "2.5.0",
|
||||
@@ -4127,7 +4121,6 @@
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -4402,7 +4395,6 @@
|
||||
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.35.0",
|
||||
"@typescript-eslint/types": "8.35.0",
|
||||
@@ -5276,7 +5268,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -7995,7 +7986,6 @@
|
||||
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -8513,7 +8503,6 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@@ -9826,7 +9815,6 @@
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
|
||||
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
@@ -10105,7 +10093,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.11.tgz",
|
||||
"integrity": "sha512-93LQlzT7vvZ1XJcmOMwN4s+6W334QegendeHOMnEJBlhnpIzr8bws6/aOEHG8ZCuVD/vNeeea5m1msHIdAY6ig==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.2.1",
|
||||
"ansi-escapes": "^7.0.0",
|
||||
@@ -13863,7 +13850,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -13874,7 +13860,6 @@
|
||||
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"shell-quote": "^1.6.1",
|
||||
"ws": "^7"
|
||||
@@ -16024,7 +16009,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -16247,9 +16231,7 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD",
|
||||
"peer": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.20.3",
|
||||
@@ -16257,7 +16239,6 @@
|
||||
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.25.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -16423,7 +16404,6 @@
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -16646,7 +16626,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
|
||||
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -16760,7 +16739,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -16773,7 +16751,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -17421,7 +17398,6 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
@@ -17968,7 +17944,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
||||
@@ -90,6 +90,7 @@ export interface CliArgs {
|
||||
includeDirectories: string[] | undefined;
|
||||
screenReader: boolean | undefined;
|
||||
useWriteTodos: boolean | undefined;
|
||||
akl: boolean | undefined;
|
||||
outputFormat: string | undefined;
|
||||
fakeResponses: string | undefined;
|
||||
recordResponses: string | undefined;
|
||||
@@ -272,6 +273,10 @@ export async function parseArguments(
|
||||
type: 'boolean',
|
||||
description: 'Enable screen reader mode for accessibility.',
|
||||
})
|
||||
.option('akl', {
|
||||
type: 'boolean',
|
||||
description: 'Enable the Agent Knowledge Layer (AKL).',
|
||||
})
|
||||
.option('output-format', {
|
||||
alias: 'o',
|
||||
type: 'string',
|
||||
@@ -835,6 +840,7 @@ export async function loadCliConfig(
|
||||
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
|
||||
eventEmitter: coreEvents,
|
||||
useWriteTodos: argv.useWriteTodos ?? settings.useWriteTodos,
|
||||
akl: argv.akl ?? settings.experimental?.akl,
|
||||
output: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
format: (argv.outputFormat ?? settings.output?.format) as OutputFormat,
|
||||
|
||||
@@ -93,6 +93,11 @@ describe('ExtensionManager theme loading', () => {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const mockConfig = {
|
||||
getAklEnabled: () => false,
|
||||
getImportFormat: () => 'tree' as const,
|
||||
getFileFilteringOptions: () => ({}),
|
||||
getDiscoveryMaxDirs: () => 200,
|
||||
getAklFilePaths: async () => [],
|
||||
getEnableExtensionReloading: () => false,
|
||||
getMcpClientManager: () => ({
|
||||
startExtension: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -140,7 +145,6 @@ describe('ExtensionManager theme loading', () => {
|
||||
getExtensions: () => [],
|
||||
}),
|
||||
isTrustedFolder: () => true,
|
||||
getImportFormat: () => 'tree',
|
||||
reloadSkills: vi.fn(),
|
||||
} as unknown as Config;
|
||||
|
||||
@@ -192,13 +196,12 @@ describe('ExtensionManager theme loading', () => {
|
||||
getExtensionLoader: () => ({
|
||||
getExtensions: () => [],
|
||||
}),
|
||||
isTrustedFolder: () => true,
|
||||
getImportFormat: () => 'tree',
|
||||
getFileFilteringOptions: () => ({
|
||||
respectGitIgnore: true,
|
||||
respectGeminiIgnore: true,
|
||||
}),
|
||||
getAklEnabled: () => false,
|
||||
getAklFilePaths: async () => [],
|
||||
getImportFormat: () => 'tree' as const,
|
||||
getFileFilteringOptions: () => ({}),
|
||||
getDiscoveryMaxDirs: () => 200,
|
||||
isTrustedFolder: () => true,
|
||||
getMcpClientManager: () => ({
|
||||
getMcpInstructions: () => '',
|
||||
startExtension: vi.fn().mockResolvedValue(undefined),
|
||||
|
||||
@@ -1936,6 +1936,16 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Enable task tracker tools.',
|
||||
showInDialog: false,
|
||||
},
|
||||
akl: {
|
||||
type: 'boolean',
|
||||
label: 'Agent Knowledge Layer',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Enable the Agent Knowledge Layer for persistent situational awareness.',
|
||||
showInDialog: true,
|
||||
},
|
||||
modelSteering: {
|
||||
type: 'boolean',
|
||||
label: 'Model Steering',
|
||||
|
||||
@@ -499,15 +499,17 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
allowedMcpServerNames: undefined,
|
||||
allowedTools: undefined,
|
||||
experimentalAcp: undefined,
|
||||
acp: undefined,
|
||||
extensions: undefined,
|
||||
listExtensions: undefined,
|
||||
resume: undefined,
|
||||
includeDirectories: undefined,
|
||||
screenReader: undefined,
|
||||
useWriteTodos: undefined,
|
||||
resume: undefined,
|
||||
akl: undefined,
|
||||
outputFormat: undefined,
|
||||
listSessions: undefined,
|
||||
deleteSession: undefined,
|
||||
outputFormat: undefined,
|
||||
fakeResponses: undefined,
|
||||
recordResponses: undefined,
|
||||
rawOutput: undefined,
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
* Creates a mocked Config object with default values and allows overrides.
|
||||
*/
|
||||
export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
({
|
||||
getSandbox: vi.fn(() => undefined),
|
||||
getQuestion: vi.fn(() => ''),
|
||||
@@ -170,6 +169,12 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getBlockedMcpServers: vi.fn().mockReturnValue([]),
|
||||
getExperiments: vi.fn().mockReturnValue(undefined),
|
||||
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
|
||||
getAklEnabled: vi.fn().mockReturnValue(false),
|
||||
getAklDiscoveryService: vi.fn().mockReturnValue({}),
|
||||
getActiveEpicId: vi.fn().mockReturnValue(undefined),
|
||||
setActiveEpicId: vi.fn(),
|
||||
getImportFormat: vi.fn().mockReturnValue('tree'),
|
||||
getDiscoveryMaxDirs: vi.fn().mockReturnValue(200),
|
||||
validatePathAccess: vi.fn().mockReturnValue(null),
|
||||
getUseAlternateBuffer: vi.fn().mockReturnValue(false),
|
||||
...overrides,
|
||||
@@ -182,11 +187,9 @@ export function createMockSettings(
|
||||
overrides: Record<string, unknown> = {},
|
||||
): LoadedSettings {
|
||||
const merged = createTestMergedSettings(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(overrides['merged'] as Partial<Settings>) || {},
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return {
|
||||
system: { settings: {} },
|
||||
systemDefaults: { settings: {} },
|
||||
|
||||
@@ -31,6 +31,10 @@ import { ShellTool } from '../tools/shell.js';
|
||||
import { WriteFileTool } from '../tools/write-file.js';
|
||||
import { WebFetchTool } from '../tools/web-fetch.js';
|
||||
import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js';
|
||||
import { RecordLearningTool } from '../tools/recordLearningTool.js';
|
||||
import { RecordDecisionTool } from '../tools/recordDecisionTool.js';
|
||||
import { UpdateEpicStateTool } from '../tools/updateEpicStateTool.js';
|
||||
import { QueryKnowledgeTool } from '../tools/queryKnowledgeTool.js';
|
||||
import { WebSearchTool } from '../tools/web-search.js';
|
||||
import { AskUserTool } from '../tools/ask-user.js';
|
||||
import { ExitPlanModeTool } from '../tools/exit-plan-mode.js';
|
||||
@@ -41,6 +45,7 @@ import { LocalLiteRtLmClient } from '../core/localLiteRtLmClient.js';
|
||||
import type { HookDefinition, HookEventName } from '../hooks/types.js';
|
||||
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
|
||||
import { GitService } from '../services/gitService.js';
|
||||
import { AklDiscoveryService } from '../services/aklDiscoveryService.js';
|
||||
import {
|
||||
createSandboxManager,
|
||||
type SandboxManager,
|
||||
@@ -591,6 +596,8 @@ export interface ConfigParameters {
|
||||
truncateToolOutputThreshold?: number;
|
||||
eventEmitter?: EventEmitter;
|
||||
useWriteTodos?: boolean;
|
||||
akl?: boolean;
|
||||
activeEpicId?: string;
|
||||
workspacePoliciesDir?: string;
|
||||
policyEngineConfig?: PolicyEngineConfig;
|
||||
directWebFetch?: boolean;
|
||||
@@ -791,6 +798,8 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly fileExclusions: FileExclusions;
|
||||
private readonly eventEmitter?: EventEmitter;
|
||||
private readonly useWriteTodos: boolean;
|
||||
private readonly akl: boolean;
|
||||
private activeEpicId: string | undefined;
|
||||
private readonly workspacePoliciesDir: string | undefined;
|
||||
private readonly _messageBus: MessageBus;
|
||||
private readonly policyEngine: PolicyEngine;
|
||||
@@ -858,6 +867,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private latestApiRequest: GenerateContentParameters | undefined;
|
||||
private lastModeSwitchTime: number = performance.now();
|
||||
readonly injectionService: InjectionService;
|
||||
private readonly aklDiscoveryService: AklDiscoveryService;
|
||||
private approvedPlanPath: string | undefined;
|
||||
|
||||
constructor(params: ConfigParameters) {
|
||||
@@ -1054,6 +1064,9 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.useWriteTodos = isPreviewModel(this.model, this)
|
||||
? false
|
||||
: (params.useWriteTodos ?? true);
|
||||
this.akl = params.akl ?? false;
|
||||
this.activeEpicId = params.activeEpicId;
|
||||
this.aklDiscoveryService = new AklDiscoveryService(this);
|
||||
this.workspacePoliciesDir = params.workspacePoliciesDir;
|
||||
this.enableHooksUI = params.enableHooksUI ?? true;
|
||||
this.enableHooks = params.enableHooks ?? true;
|
||||
@@ -2925,6 +2938,22 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.useWriteTodos;
|
||||
}
|
||||
|
||||
getAklEnabled(): boolean {
|
||||
return this.akl;
|
||||
}
|
||||
|
||||
getAklDiscoveryService(): AklDiscoveryService {
|
||||
return this.aklDiscoveryService;
|
||||
}
|
||||
|
||||
getActiveEpicId(): string | undefined {
|
||||
return this.activeEpicId;
|
||||
}
|
||||
|
||||
setActiveEpicId(epicId: string): void {
|
||||
this.activeEpicId = epicId;
|
||||
}
|
||||
|
||||
getOutputFormat(): OutputFormat {
|
||||
return this.outputSettings?.format
|
||||
? this.outputSettings.format
|
||||
@@ -3090,12 +3119,24 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
maybeRegister(WebFetchTool, () =>
|
||||
registry.registerTool(new WebFetchTool(this, this.messageBus)),
|
||||
);
|
||||
maybeRegister(ShellTool, () =>
|
||||
registry.registerTool(new ShellTool(this, this.messageBus)),
|
||||
);
|
||||
maybeRegister(MemoryTool, () =>
|
||||
registry.registerTool(new MemoryTool(this.messageBus)),
|
||||
);
|
||||
maybeRegister(RecordLearningTool, () =>
|
||||
registry.registerTool(new RecordLearningTool(this.messageBus)),
|
||||
);
|
||||
maybeRegister(RecordDecisionTool, () =>
|
||||
registry.registerTool(new RecordDecisionTool(this, this.messageBus)),
|
||||
);
|
||||
maybeRegister(UpdateEpicStateTool, () =>
|
||||
registry.registerTool(new UpdateEpicStateTool(this, this.messageBus)),
|
||||
);
|
||||
maybeRegister(QueryKnowledgeTool, () =>
|
||||
registry.registerTool(new QueryKnowledgeTool(this, this.messageBus)),
|
||||
);
|
||||
maybeRegister(ShellTool, () =>
|
||||
registry.registerTool(new ShellTool(this, this.messageBus)),
|
||||
);
|
||||
maybeRegister(WebSearchTool, () =>
|
||||
registry.registerTool(new WebSearchTool(this, this.messageBus)),
|
||||
);
|
||||
@@ -3104,7 +3145,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
);
|
||||
if (this.getUseWriteTodos()) {
|
||||
maybeRegister(WriteTodosTool, () =>
|
||||
registry.registerTool(new WriteTodosTool(this.messageBus)),
|
||||
registry.registerTool(new WriteTodosTool(this, this.messageBus)),
|
||||
);
|
||||
}
|
||||
if (this.isPlanEnabled()) {
|
||||
|
||||
@@ -273,6 +273,18 @@ describe('Gemini Client (client.ts)', () => {
|
||||
},
|
||||
},
|
||||
isInteractive: vi.fn().mockReturnValue(false),
|
||||
getAklEnabled: vi.fn().mockReturnValue(false),
|
||||
getAklDiscoveryService: vi.fn(),
|
||||
getActiveEpicId: vi.fn().mockReturnValue(undefined),
|
||||
setActiveEpicId: vi.fn(),
|
||||
getGitService: vi.fn().mockResolvedValue({
|
||||
getCurrentBranch: vi.fn().mockResolvedValue('main'),
|
||||
getRepoRoot: vi.fn().mockReturnValue('/test/project/root'),
|
||||
}),
|
||||
getImportFormat: vi.fn().mockReturnValue('tree'),
|
||||
getFileFilteringOptions: vi.fn().mockReturnValue(undefined),
|
||||
getDiscoveryMaxDirs: vi.fn().mockReturnValue(200),
|
||||
getMcpClientManager: vi.fn().mockReturnValue(undefined),
|
||||
getExperiments: () => {},
|
||||
getActiveModel: vi.fn().mockReturnValue('test-model'),
|
||||
setActiveModel: vi.fn(),
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
createUserContent,
|
||||
type GenerateContentConfig,
|
||||
@@ -75,6 +77,8 @@ import {
|
||||
resolveModel,
|
||||
isGemini2Model,
|
||||
} from '../config/models.js';
|
||||
import { AklDiscoveryService } from '../services/aklDiscoveryService.js';
|
||||
import { KnowledgeIndexingService } from '../services/knowledgeIndexingService.js';
|
||||
import { partToString } from '../utils/partUtils.js';
|
||||
import { coreEvents, CoreEvent } from '../utils/events.js';
|
||||
|
||||
@@ -99,6 +103,7 @@ export class GeminiClient {
|
||||
private readonly loopDetector: LoopDetectionService;
|
||||
private readonly compressionService: ChatCompressionService;
|
||||
private readonly toolOutputMaskingService: ToolOutputMaskingService;
|
||||
private readonly aklDiscoveryService: AklDiscoveryService;
|
||||
private lastPromptId: string;
|
||||
private currentSequenceModel: string | null = null;
|
||||
private lastSentIdeContext: IdeContext | undefined;
|
||||
@@ -114,6 +119,7 @@ export class GeminiClient {
|
||||
this.loopDetector = new LoopDetectionService(this.config);
|
||||
this.compressionService = new ChatCompressionService();
|
||||
this.toolOutputMaskingService = new ToolOutputMaskingService();
|
||||
this.aklDiscoveryService = new AklDiscoveryService(this.config);
|
||||
this.lastPromptId = this.config.getSessionId();
|
||||
|
||||
coreEvents.on(CoreEvent.ModelChanged, this.handleModelChanged);
|
||||
@@ -238,10 +244,52 @@ export class GeminiClient {
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
if (this.config.getAklEnabled()) {
|
||||
await this.runAklDiscovery();
|
||||
// Update the knowledge index in the background
|
||||
const indexer = new KnowledgeIndexingService(this.config);
|
||||
indexer
|
||||
.updateIndex()
|
||||
.catch((err) =>
|
||||
debugLogger.debug(
|
||||
`AKL: Failed to update knowledge index: ${String(err)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
this.chat = await this.startChat();
|
||||
this.updateTelemetryTokenCount();
|
||||
}
|
||||
|
||||
private async runAklDiscovery() {
|
||||
const epic = await this.aklDiscoveryService.discoverActiveEpic();
|
||||
if (epic) {
|
||||
debugLogger.debug(`AKL: Active Epic detected: ${epic.epicId}`);
|
||||
this.config.setActiveEpicId(epic.epicId);
|
||||
|
||||
// Sync GitHub context if possible
|
||||
if (epic.issueId) {
|
||||
const githubContext = await this.aklDiscoveryService.syncGitHubContext(
|
||||
epic.issueId,
|
||||
);
|
||||
if (githubContext) {
|
||||
debugLogger.debug(`AKL: Synced GitHub context for #${epic.issueId}`);
|
||||
// Add to situational context if in a worktree
|
||||
const contextPath = path.join(
|
||||
this.config.getProjectRoot(),
|
||||
'.gemini',
|
||||
'situational-context.md',
|
||||
);
|
||||
await fs.writeFile(
|
||||
contextPath,
|
||||
`# Epic Context: ${epic.epicId}\n\n${githubContext}`,
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getContentGeneratorOrFail(): ContentGenerator {
|
||||
if (!this.config.getContentGenerator()) {
|
||||
throw new Error('Content generator not initialized');
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { spawnAsync } from '../utils/shell-utils.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
|
||||
export interface EpicContext {
|
||||
epicId: string;
|
||||
path: string;
|
||||
branchName?: string;
|
||||
issueId?: string;
|
||||
}
|
||||
|
||||
export class AklDiscoveryService {
|
||||
private readonly config: Config;
|
||||
|
||||
constructor(config: Config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovers the active Epic context for the current session.
|
||||
*/
|
||||
async discoverActiveEpic(): Promise<EpicContext | null> {
|
||||
if (!this.config.getAklEnabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const gitService = await this.config.getGitService();
|
||||
const branchName = await gitService.getCurrentBranch();
|
||||
const repoRoot = gitService.getRepoRoot();
|
||||
|
||||
// 1. Try to find issue ID from GitHub
|
||||
const issueId = await this.detectGitHubIssue(branchName);
|
||||
|
||||
// 2. Search for existing epic folder
|
||||
const epicId = issueId || branchName;
|
||||
if (!epicId || epicId === 'HEAD') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const epicPath = path.join(repoRoot, '.gemini', 'epics', epicId);
|
||||
|
||||
try {
|
||||
await fs.access(epicPath);
|
||||
return { epicId, path: epicPath, branchName, issueId };
|
||||
} catch {
|
||||
// Epic folder doesn't exist yet
|
||||
debugLogger.debug(`Epic folder not found at ${epicPath}`);
|
||||
return { epicId, path: epicPath, branchName, issueId };
|
||||
}
|
||||
}
|
||||
|
||||
private async detectGitHubIssue(
|
||||
branchName: string,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
// Use 'gh pr view' to find linked issue if it's a PR branch
|
||||
const { stdout } = await spawnAsync('gh', [
|
||||
'pr',
|
||||
'view',
|
||||
'--json',
|
||||
'number,title,body',
|
||||
]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const prData = JSON.parse(stdout) as { body: string; title: string };
|
||||
|
||||
// Often issues are linked in the PR body or title
|
||||
const issueMatch =
|
||||
prData.body.match(/#(\d+)/) || prData.title.match(/#(\d+)/);
|
||||
if (issueMatch) {
|
||||
return issueMatch[1];
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.debug(
|
||||
`Failed to detect GitHub issue via gh: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback: look for issue ID in branch name (e.g., 'fix/123-bug' or '123-feature')
|
||||
const branchIssueMatch = branchName.match(/(?:^|[/-])(\d+)(?:-|$)/);
|
||||
return branchIssueMatch ? branchIssueMatch[1] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches full context for a GitHub issue and its parents.
|
||||
*/
|
||||
async syncGitHubContext(issueId: string): Promise<string> {
|
||||
try {
|
||||
// Use 'gh issue view' to find parent/original issue
|
||||
const { stdout } = await spawnAsync('gh', [
|
||||
'issue',
|
||||
'view',
|
||||
issueId,
|
||||
'--json',
|
||||
'title,body,comments',
|
||||
]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const issueData = JSON.parse(stdout) as {
|
||||
title: string;
|
||||
body: string;
|
||||
comments: Array<{ author: { login: string }; body: string }>;
|
||||
};
|
||||
|
||||
let context = `## GitHub Issue #${issueId}: ${issueData.title}\n\n${issueData.body}\n`;
|
||||
|
||||
if (issueData.comments?.length > 0) {
|
||||
context += `\n### Relevant Comments\n`;
|
||||
for (const comment of issueData.comments) {
|
||||
context += `\n- **${comment.author.login}**: ${comment.body.substring(0, 500)}${comment.body.length > 500 ? '...' : ''}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return context;
|
||||
} catch (error) {
|
||||
debugLogger.debug(
|
||||
`Failed to sync GitHub context for issue ${issueId}: ${String(error)}`,
|
||||
);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,22 @@ export class GitService {
|
||||
return hash.trim();
|
||||
}
|
||||
|
||||
async getCurrentBranch(): Promise<string> {
|
||||
try {
|
||||
const branch = await this.shadowGitRepository.revparse([
|
||||
'--abbrev-ref',
|
||||
'HEAD',
|
||||
]);
|
||||
return branch.trim();
|
||||
} catch {
|
||||
return 'HEAD';
|
||||
}
|
||||
}
|
||||
|
||||
getRepoRoot(): string {
|
||||
return this.projectRoot;
|
||||
}
|
||||
|
||||
async createFileSnapshot(message: string): Promise<string> {
|
||||
try {
|
||||
const repo = this.shadowGitRepository;
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
|
||||
export interface KnowledgeIndexEntry {
|
||||
path: string;
|
||||
level: 'global' | 'project' | 'micro' | 'epic';
|
||||
summary: string;
|
||||
tags: string[];
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
export interface KnowledgeIndex {
|
||||
entries: KnowledgeIndexEntry[];
|
||||
version: string;
|
||||
}
|
||||
|
||||
export class KnowledgeIndexingService {
|
||||
private readonly config: Config;
|
||||
private readonly indexPath: string;
|
||||
|
||||
constructor(config: Config) {
|
||||
this.config = config;
|
||||
this.indexPath = path.join(
|
||||
config.getProjectRoot(),
|
||||
'.gemini',
|
||||
'knowledge_index.json',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans the workspace and updates the knowledge index.
|
||||
*/
|
||||
async updateIndex(): Promise<void> {
|
||||
debugLogger.debug('AKL: Updating knowledge index...');
|
||||
const projectRoot = this.config.getProjectRoot();
|
||||
const entries: KnowledgeIndexEntry[] = [];
|
||||
|
||||
// 1. Scan for machine-learnings.md and GEMINI.md
|
||||
// We'll use a manual walk since findFiles is missing or has a different name
|
||||
const files: string[] = [];
|
||||
const geminiMdFiles: string[] = [];
|
||||
|
||||
const walk = async (dir: string) => {
|
||||
const items = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const item of items) {
|
||||
const fullPath = path.join(dir, item.name);
|
||||
if (this.config.getFileService().shouldIgnoreFile(fullPath)) continue;
|
||||
|
||||
if (item.isDirectory()) {
|
||||
await walk(fullPath);
|
||||
} else if (item.name === 'machine-learnings.md') {
|
||||
files.push(fullPath);
|
||||
} else if (item.name === 'GEMINI.md') {
|
||||
geminiMdFiles.push(fullPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await walk(projectRoot);
|
||||
|
||||
// 2. Index Machine Learnings
|
||||
for (const file of [...files, ...geminiMdFiles]) {
|
||||
const entry = await this.createEntry(file, projectRoot);
|
||||
if (entry) entries.push(entry);
|
||||
}
|
||||
|
||||
// 3. Index Epics
|
||||
const epicDir = path.join(projectRoot, '.gemini', 'epics');
|
||||
try {
|
||||
const epics = await fs.readdir(epicDir);
|
||||
for (const epicId of epics) {
|
||||
const entry = await this.createEpicEntry(epicId, projectRoot);
|
||||
if (entry) entries.push(entry);
|
||||
}
|
||||
} catch {
|
||||
// Epics dir might not exist
|
||||
}
|
||||
|
||||
const index: KnowledgeIndex = {
|
||||
entries,
|
||||
version: '1.0.0',
|
||||
};
|
||||
|
||||
await fs.mkdir(path.dirname(this.indexPath), { recursive: true });
|
||||
await fs.writeFile(this.indexPath, JSON.stringify(index, null, 2), 'utf-8');
|
||||
debugLogger.debug(`AKL: Index updated with ${entries.length} entries.`);
|
||||
}
|
||||
|
||||
private async createEntry(
|
||||
filePath: string,
|
||||
projectRoot: string,
|
||||
): Promise<KnowledgeIndexEntry | null> {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const stats = await fs.stat(filePath);
|
||||
const isGeminiMd = filePath.endsWith('GEMINI.md');
|
||||
|
||||
// Simple heuristic for summary: first few bullet points or section headers
|
||||
const lines = content.split('\n');
|
||||
const learningsLine = lines.findIndex(
|
||||
(l) =>
|
||||
l.includes('## Agent Machine Learnings') ||
|
||||
l.includes('## Gemini Added Memories'),
|
||||
);
|
||||
const summaryLines =
|
||||
learningsLine !== -1
|
||||
? lines.slice(learningsLine + 1, learningsLine + 5)
|
||||
: lines.slice(0, 5);
|
||||
|
||||
const summary = summaryLines.join(' ').trim().substring(0, 200);
|
||||
const relativePath = path.relative(projectRoot, filePath);
|
||||
|
||||
return {
|
||||
path: relativePath,
|
||||
level: this.determineLevel(relativePath, isGeminiMd),
|
||||
summary:
|
||||
summary ||
|
||||
(isGeminiMd
|
||||
? 'Project mandates and instructions'
|
||||
: 'Machine learnings and optimizations'),
|
||||
tags: this.extractTags(content),
|
||||
lastUpdated: stats.mtime.toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
debugLogger.debug(`AKL: Failed to index ${filePath}: ${String(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async createEpicEntry(
|
||||
epicId: string,
|
||||
projectRoot: string,
|
||||
): Promise<KnowledgeIndexEntry | null> {
|
||||
const contextPath = path.join(
|
||||
projectRoot,
|
||||
'.gemini',
|
||||
'epics',
|
||||
epicId,
|
||||
'context.md',
|
||||
);
|
||||
try {
|
||||
const content = await fs.readFile(contextPath, 'utf-8');
|
||||
const stats = await fs.stat(contextPath);
|
||||
|
||||
return {
|
||||
path: path.join('.gemini', 'epics', epicId),
|
||||
level: 'epic',
|
||||
summary: content
|
||||
.split('\n')
|
||||
.slice(0, 5)
|
||||
.join(' ')
|
||||
.trim()
|
||||
.substring(0, 200),
|
||||
tags: [epicId, 'epic'],
|
||||
lastUpdated: stats.mtime.toISOString(),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private determineLevel(
|
||||
relativePath: string,
|
||||
_isGeminiMd: boolean,
|
||||
): 'global' | 'project' | 'micro' {
|
||||
if (relativePath.includes('..')) return 'global';
|
||||
if (!relativePath.includes(path.sep)) return 'project';
|
||||
return 'micro';
|
||||
}
|
||||
|
||||
private extractTags(content: string): string[] {
|
||||
const tags = new Set<string>();
|
||||
const matches = content.match(/#\w+/g);
|
||||
if (matches) matches.forEach((m) => tags.add(m.substring(1)));
|
||||
return Array.from(tags);
|
||||
}
|
||||
|
||||
async loadIndex(): Promise<KnowledgeIndex | null> {
|
||||
try {
|
||||
const content = await fs.readFile(this.indexPath, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,29 @@ export const READ_MANY_PARAM_USE_DEFAULT_EXCLUDES = 'useDefaultExcludes';
|
||||
export const MEMORY_TOOL_NAME = 'save_memory';
|
||||
export const MEMORY_PARAM_FACT = 'fact';
|
||||
|
||||
// -- record_learning --
|
||||
export const RECORD_LEARNING_TOOL_NAME = 'record_learning';
|
||||
export const RECORD_LEARNING_PARAM_FACT = 'fact';
|
||||
export const RECORD_LEARNING_PARAM_LEVEL = 'level';
|
||||
export const RECORD_LEARNING_PARAM_DIRECTORY = 'directory';
|
||||
|
||||
// -- record_decision --
|
||||
export const RECORD_DECISION_TOOL_NAME = 'record_decision';
|
||||
export const RECORD_DECISION_PARAM_TITLE = 'title';
|
||||
export const RECORD_DECISION_PARAM_CONTEXT = 'context';
|
||||
export const RECORD_DECISION_PARAM_DECISION = 'decision';
|
||||
export const RECORD_DECISION_PARAM_CONSEQUENCES = 'consequences';
|
||||
|
||||
// -- update_epic_state --
|
||||
export const UPDATE_EPIC_STATE_TOOL_NAME = 'update_epic_state';
|
||||
export const UPDATE_EPIC_STATE_PARAM_TYPE = 'update_type';
|
||||
export const UPDATE_EPIC_STATE_PARAM_CONTENT = 'content';
|
||||
|
||||
// -- query_knowledge --
|
||||
export const QUERY_KNOWLEDGE_TOOL_NAME = 'query_knowledge';
|
||||
export const QUERY_KNOWLEDGE_PARAM_QUERY = 'query';
|
||||
export const QUERY_KNOWLEDGE_PARAM_LEVEL = 'level';
|
||||
|
||||
// -- get_internal_docs --
|
||||
export const GET_INTERNAL_DOCS_TOOL_NAME = 'get_internal_docs';
|
||||
export const DOCS_PARAM_PATH = 'path';
|
||||
|
||||
@@ -75,6 +75,20 @@ export {
|
||||
READ_MANY_PARAM_RECURSIVE,
|
||||
READ_MANY_PARAM_USE_DEFAULT_EXCLUDES,
|
||||
MEMORY_PARAM_FACT,
|
||||
RECORD_LEARNING_TOOL_NAME,
|
||||
RECORD_LEARNING_PARAM_FACT,
|
||||
RECORD_LEARNING_PARAM_LEVEL,
|
||||
RECORD_DECISION_TOOL_NAME,
|
||||
RECORD_DECISION_PARAM_TITLE,
|
||||
RECORD_DECISION_PARAM_CONTEXT,
|
||||
RECORD_DECISION_PARAM_DECISION,
|
||||
RECORD_DECISION_PARAM_CONSEQUENCES,
|
||||
UPDATE_EPIC_STATE_TOOL_NAME,
|
||||
UPDATE_EPIC_STATE_PARAM_TYPE,
|
||||
UPDATE_EPIC_STATE_PARAM_CONTENT,
|
||||
QUERY_KNOWLEDGE_TOOL_NAME,
|
||||
QUERY_KNOWLEDGE_PARAM_QUERY,
|
||||
QUERY_KNOWLEDGE_PARAM_LEVEL,
|
||||
TODOS_PARAM_TODOS,
|
||||
TODOS_ITEM_PARAM_DESCRIPTION,
|
||||
TODOS_ITEM_PARAM_STATUS,
|
||||
@@ -221,6 +235,34 @@ export const ENTER_PLAN_MODE_DEFINITION: ToolDefinition = {
|
||||
overrides: (modelId) => getToolSet(modelId).enter_plan_mode,
|
||||
};
|
||||
|
||||
export const RECORD_LEARNING_DEFINITION: ToolDefinition = {
|
||||
get base() {
|
||||
return DEFAULT_LEGACY_SET.record_learning;
|
||||
},
|
||||
overrides: (modelId) => getToolSet(modelId).record_learning,
|
||||
};
|
||||
|
||||
export const RECORD_DECISION_DEFINITION: ToolDefinition = {
|
||||
get base() {
|
||||
return DEFAULT_LEGACY_SET.record_decision;
|
||||
},
|
||||
overrides: (modelId) => getToolSet(modelId).record_decision,
|
||||
};
|
||||
|
||||
export const UPDATE_EPIC_STATE_DEFINITION: ToolDefinition = {
|
||||
get base() {
|
||||
return DEFAULT_LEGACY_SET.update_epic_state;
|
||||
},
|
||||
overrides: (modelId) => getToolSet(modelId).update_epic_state,
|
||||
};
|
||||
|
||||
export const QUERY_KNOWLEDGE_DEFINITION: ToolDefinition = {
|
||||
get base() {
|
||||
return DEFAULT_LEGACY_SET.query_knowledge;
|
||||
},
|
||||
overrides: (modelId) => getToolSet(modelId).query_knowledge,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// DYNAMIC TOOL DEFINITIONS (LEGACY EXPORTS)
|
||||
// ============================================================================
|
||||
|
||||
@@ -59,6 +59,21 @@ import {
|
||||
READ_MANY_PARAM_RECURSIVE,
|
||||
READ_MANY_PARAM_USE_DEFAULT_EXCLUDES,
|
||||
MEMORY_PARAM_FACT,
|
||||
RECORD_LEARNING_TOOL_NAME,
|
||||
RECORD_LEARNING_PARAM_FACT,
|
||||
RECORD_LEARNING_PARAM_LEVEL,
|
||||
RECORD_LEARNING_PARAM_DIRECTORY,
|
||||
RECORD_DECISION_TOOL_NAME,
|
||||
RECORD_DECISION_PARAM_TITLE,
|
||||
RECORD_DECISION_PARAM_CONTEXT,
|
||||
RECORD_DECISION_PARAM_DECISION,
|
||||
RECORD_DECISION_PARAM_CONSEQUENCES,
|
||||
UPDATE_EPIC_STATE_TOOL_NAME,
|
||||
UPDATE_EPIC_STATE_PARAM_TYPE,
|
||||
UPDATE_EPIC_STATE_PARAM_CONTENT,
|
||||
QUERY_KNOWLEDGE_TOOL_NAME,
|
||||
QUERY_KNOWLEDGE_PARAM_QUERY,
|
||||
QUERY_KNOWLEDGE_PARAM_LEVEL,
|
||||
TODOS_PARAM_TODOS,
|
||||
TODOS_ITEM_PARAM_DESCRIPTION,
|
||||
TODOS_ITEM_PARAM_STATUS,
|
||||
@@ -526,6 +541,116 @@ NEVER save workspace-specific context, local paths, or commands (e.g. "The entry
|
||||
},
|
||||
},
|
||||
|
||||
record_learning: {
|
||||
name: RECORD_LEARNING_TOOL_NAME,
|
||||
description: `
|
||||
Saves a machine-learning insight to the project or global store. These learnings are used by future agents to avoid past mistakes or follow established local patterns.
|
||||
|
||||
Levels:
|
||||
- 'global': Universal patterns for all projects (~/.gemini/machine-learnings.md).
|
||||
- 'project': Architectural decisions or project-wide gotchas (./machine-learnings.md).
|
||||
- 'micro': Directory-specific optimizations (.//**/machine-learnings.md).`,
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
[RECORD_LEARNING_PARAM_FACT]: {
|
||||
type: 'string',
|
||||
description: 'The machine-learning insight to record.',
|
||||
},
|
||||
[RECORD_LEARNING_PARAM_LEVEL]: {
|
||||
type: 'string',
|
||||
enum: ['global', 'project', 'micro'],
|
||||
description: 'The scope/level of the learning.',
|
||||
},
|
||||
[RECORD_LEARNING_PARAM_DIRECTORY]: {
|
||||
type: 'string',
|
||||
description:
|
||||
'The directory to save the learning in (only used for "micro" level). Defaults to current directory.',
|
||||
},
|
||||
},
|
||||
required: [RECORD_LEARNING_PARAM_FACT, RECORD_LEARNING_PARAM_LEVEL],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
|
||||
record_decision: {
|
||||
name: RECORD_DECISION_TOOL_NAME,
|
||||
description: `
|
||||
Creates an Architecture Decision Record (ADR) in the active Epic context. Use this for significant technical choices made during a workstream.`,
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
[RECORD_DECISION_PARAM_TITLE]: {
|
||||
type: 'string',
|
||||
description: 'Short title of the decision.',
|
||||
},
|
||||
[RECORD_DECISION_PARAM_CONTEXT]: {
|
||||
type: 'string',
|
||||
description: 'The background and motivation for this decision.',
|
||||
},
|
||||
[RECORD_DECISION_PARAM_DECISION]: {
|
||||
type: 'string',
|
||||
description: 'The actual decision made.',
|
||||
},
|
||||
[RECORD_DECISION_PARAM_CONSEQUENCES]: {
|
||||
type: 'string',
|
||||
description: 'The pros, cons, and side effects of this decision.',
|
||||
},
|
||||
},
|
||||
required: [
|
||||
RECORD_DECISION_PARAM_TITLE,
|
||||
RECORD_DECISION_PARAM_CONTEXT,
|
||||
RECORD_DECISION_PARAM_DECISION,
|
||||
RECORD_DECISION_PARAM_CONSEQUENCES,
|
||||
],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
|
||||
update_epic_state: {
|
||||
name: UPDATE_EPIC_STATE_TOOL_NAME,
|
||||
description: `
|
||||
Updates the situational awareness files for the active Epic.`,
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
[UPDATE_EPIC_STATE_PARAM_TYPE]: {
|
||||
type: 'string',
|
||||
enum: ['context', 'task_log', 'notes'],
|
||||
description: 'Which part of the epic state to update.',
|
||||
},
|
||||
[UPDATE_EPIC_STATE_PARAM_CONTENT]: {
|
||||
type: 'string',
|
||||
description: 'The new content or summary to add/update.',
|
||||
},
|
||||
},
|
||||
required: [UPDATE_EPIC_STATE_PARAM_TYPE, UPDATE_EPIC_STATE_PARAM_CONTENT],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
|
||||
query_knowledge: {
|
||||
name: QUERY_KNOWLEDGE_TOOL_NAME,
|
||||
description: `
|
||||
Searches the project-wide machine-learning index to answer "How have we handled X before?" or to find established local patterns and past decisions.`,
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
[QUERY_KNOWLEDGE_PARAM_QUERY]: {
|
||||
type: 'string',
|
||||
description: 'The search query or question about past learnings.',
|
||||
},
|
||||
[QUERY_KNOWLEDGE_PARAM_LEVEL]: {
|
||||
type: 'string',
|
||||
enum: ['global', 'project', 'micro', 'epic'],
|
||||
description: 'Optional filter for the scope of the search.',
|
||||
},
|
||||
},
|
||||
required: [QUERY_KNOWLEDGE_PARAM_QUERY],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
|
||||
write_todos: {
|
||||
name: WRITE_TODOS_TOOL_NAME,
|
||||
description: `This tool can help you list out the current subtasks that are required to be completed for a given user request. The list of subtasks helps you keep track of the current task, organize complex queries and help ensure that you don't miss any steps. With this list, the user can also see the current progress you are making in executing a given task.
|
||||
|
||||
@@ -59,6 +59,21 @@ import {
|
||||
READ_MANY_PARAM_RECURSIVE,
|
||||
READ_MANY_PARAM_USE_DEFAULT_EXCLUDES,
|
||||
MEMORY_PARAM_FACT,
|
||||
RECORD_LEARNING_TOOL_NAME,
|
||||
RECORD_LEARNING_PARAM_FACT,
|
||||
RECORD_LEARNING_PARAM_LEVEL,
|
||||
RECORD_LEARNING_PARAM_DIRECTORY,
|
||||
RECORD_DECISION_TOOL_NAME,
|
||||
RECORD_DECISION_PARAM_TITLE,
|
||||
RECORD_DECISION_PARAM_CONTEXT,
|
||||
RECORD_DECISION_PARAM_DECISION,
|
||||
RECORD_DECISION_PARAM_CONSEQUENCES,
|
||||
UPDATE_EPIC_STATE_TOOL_NAME,
|
||||
UPDATE_EPIC_STATE_PARAM_TYPE,
|
||||
UPDATE_EPIC_STATE_PARAM_CONTENT,
|
||||
QUERY_KNOWLEDGE_TOOL_NAME,
|
||||
QUERY_KNOWLEDGE_PARAM_QUERY,
|
||||
QUERY_KNOWLEDGE_PARAM_LEVEL,
|
||||
TODOS_PARAM_TODOS,
|
||||
TODOS_ITEM_PARAM_DESCRIPTION,
|
||||
TODOS_ITEM_PARAM_STATUS,
|
||||
@@ -501,6 +516,116 @@ Use this tool when the user's query implies needing the content of several files
|
||||
},
|
||||
},
|
||||
|
||||
record_learning: {
|
||||
name: RECORD_LEARNING_TOOL_NAME,
|
||||
description: `
|
||||
Saves a machine-learning insight to the project or global store. These learnings are used by future agents to avoid past mistakes or follow established local patterns.
|
||||
|
||||
Levels:
|
||||
- 'global': Universal patterns for all projects (~/.gemini/machine-learnings.md).
|
||||
- 'project': Architectural decisions or project-wide gotchas (./machine-learnings.md).
|
||||
- 'micro': Directory-specific optimizations (.//**/machine-learnings.md).`,
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
[RECORD_LEARNING_PARAM_FACT]: {
|
||||
type: 'string',
|
||||
description: 'The machine-learning insight to record.',
|
||||
},
|
||||
[RECORD_LEARNING_PARAM_LEVEL]: {
|
||||
type: 'string',
|
||||
enum: ['global', 'project', 'micro'],
|
||||
description: 'The scope/level of the learning.',
|
||||
},
|
||||
[RECORD_LEARNING_PARAM_DIRECTORY]: {
|
||||
type: 'string',
|
||||
description:
|
||||
'The directory to save the learning in (only used for "micro" level). Defaults to current directory.',
|
||||
},
|
||||
},
|
||||
required: [RECORD_LEARNING_PARAM_FACT, RECORD_LEARNING_PARAM_LEVEL],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
|
||||
record_decision: {
|
||||
name: RECORD_DECISION_TOOL_NAME,
|
||||
description: `
|
||||
Creates an Architecture Decision Record (ADR) in the active Epic context. Use this for significant technical choices made during a workstream.`,
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
[RECORD_DECISION_PARAM_TITLE]: {
|
||||
type: 'string',
|
||||
description: 'Short title of the decision.',
|
||||
},
|
||||
[RECORD_DECISION_PARAM_CONTEXT]: {
|
||||
type: 'string',
|
||||
description: 'The background and motivation for this decision.',
|
||||
},
|
||||
[RECORD_DECISION_PARAM_DECISION]: {
|
||||
type: 'string',
|
||||
description: 'The actual decision made.',
|
||||
},
|
||||
[RECORD_DECISION_PARAM_CONSEQUENCES]: {
|
||||
type: 'string',
|
||||
description: 'The pros, cons, and side effects of this decision.',
|
||||
},
|
||||
},
|
||||
required: [
|
||||
RECORD_DECISION_PARAM_TITLE,
|
||||
RECORD_DECISION_PARAM_CONTEXT,
|
||||
RECORD_DECISION_PARAM_DECISION,
|
||||
RECORD_DECISION_PARAM_CONSEQUENCES,
|
||||
],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
|
||||
update_epic_state: {
|
||||
name: UPDATE_EPIC_STATE_TOOL_NAME,
|
||||
description: `
|
||||
Updates the situational awareness files for the active Epic.`,
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
[UPDATE_EPIC_STATE_PARAM_TYPE]: {
|
||||
type: 'string',
|
||||
enum: ['context', 'task_log', 'notes'],
|
||||
description: 'Which part of the epic state to update.',
|
||||
},
|
||||
[UPDATE_EPIC_STATE_PARAM_CONTENT]: {
|
||||
type: 'string',
|
||||
description: 'The new content or summary to add/update.',
|
||||
},
|
||||
},
|
||||
required: [UPDATE_EPIC_STATE_PARAM_TYPE, UPDATE_EPIC_STATE_PARAM_CONTENT],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
|
||||
query_knowledge: {
|
||||
name: QUERY_KNOWLEDGE_TOOL_NAME,
|
||||
description: `
|
||||
Searches the project-wide machine-learning index to answer "How have we handled X before?" or to find established local patterns and past decisions.`,
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
[QUERY_KNOWLEDGE_PARAM_QUERY]: {
|
||||
type: 'string',
|
||||
description: 'The search query or question about past learnings.',
|
||||
},
|
||||
[QUERY_KNOWLEDGE_PARAM_LEVEL]: {
|
||||
type: 'string',
|
||||
enum: ['global', 'project', 'micro', 'epic'],
|
||||
description: 'Optional filter for the scope of the search.',
|
||||
},
|
||||
},
|
||||
required: [QUERY_KNOWLEDGE_PARAM_QUERY],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
|
||||
write_todos: {
|
||||
name: WRITE_TODOS_TOOL_NAME,
|
||||
description: `This tool can help you list out the current subtasks that are required to be completed for a given user request. The list of subtasks helps you keep track of the current task, organize complex queries and help ensure that you don't miss any steps. With this list, the user can also see the current progress you are making in executing a given task.
|
||||
|
||||
@@ -47,6 +47,10 @@ export interface CoreToolSet {
|
||||
get_internal_docs: FunctionDeclaration;
|
||||
ask_user: FunctionDeclaration;
|
||||
enter_plan_mode: FunctionDeclaration;
|
||||
record_learning: FunctionDeclaration;
|
||||
record_decision: FunctionDeclaration;
|
||||
update_epic_state: FunctionDeclaration;
|
||||
query_knowledge: FunctionDeclaration;
|
||||
exit_plan_mode: (plansDir: string) => FunctionDeclaration;
|
||||
activate_skill: (skillNames: string[]) => FunctionDeclaration;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { MEMORY_DEFINITION } from './definitions/coreTools.js';
|
||||
import { resolveToolDeclaration } from './definitions/resolver.js';
|
||||
|
||||
export const DEFAULT_CONTEXT_FILENAME = 'GEMINI.md';
|
||||
export const MACHINE_LEARNINGS_FILENAME = 'machine-learnings.md';
|
||||
export const MEMORY_SECTION_HEADER = '## Gemini Added Memories';
|
||||
|
||||
// This variable will hold the currently configured filename for GEMINI.md context files.
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
BaseDeclarativeTool,
|
||||
BaseToolInvocation,
|
||||
Kind,
|
||||
type ToolResult,
|
||||
} from './tools.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
import { QUERY_KNOWLEDGE_TOOL_NAME } from './tool-names.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import { QUERY_KNOWLEDGE_DEFINITION } from './definitions/coreTools.js';
|
||||
import { resolveToolDeclaration } from './definitions/resolver.js';
|
||||
import { KnowledgeIndexingService } from '../services/knowledgeIndexingService.js';
|
||||
import type { KnowledgeIndexEntry } from '../services/knowledgeIndexingService.js';
|
||||
|
||||
interface QueryKnowledgeParams {
|
||||
query: string;
|
||||
level?: 'global' | 'project' | 'micro' | 'epic';
|
||||
}
|
||||
|
||||
class QueryKnowledgeInvocation extends BaseToolInvocation<
|
||||
QueryKnowledgeParams,
|
||||
ToolResult
|
||||
> {
|
||||
private readonly config: Config;
|
||||
|
||||
constructor(
|
||||
params: QueryKnowledgeParams,
|
||||
config: Config,
|
||||
messageBus: MessageBus,
|
||||
toolName: string,
|
||||
displayName: string,
|
||||
) {
|
||||
super(params, messageBus, toolName, displayName);
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `Querying knowledge index for: ${this.params.query}`;
|
||||
}
|
||||
|
||||
async execute(_signal: AbortSignal): Promise<ToolResult> {
|
||||
const { query, level } = this.params;
|
||||
const indexer = new KnowledgeIndexingService(this.config);
|
||||
|
||||
try {
|
||||
const index = await indexer.loadIndex();
|
||||
if (!index || index.entries.length === 0) {
|
||||
return {
|
||||
llmContent: JSON.stringify({
|
||||
success: true,
|
||||
results: [],
|
||||
message: 'Knowledge index is empty. No matches found.',
|
||||
}),
|
||||
returnDisplay: 'Knowledge index is empty.',
|
||||
};
|
||||
}
|
||||
|
||||
// Simple keyword match for now
|
||||
const keywords = query.toLowerCase().split(/\s+/);
|
||||
const matches = index.entries
|
||||
.filter((entry) => {
|
||||
if (level && entry.level !== level) return false;
|
||||
const searchable =
|
||||
`${entry.path} ${entry.summary} ${entry.tags.join(' ')}`.toLowerCase();
|
||||
return keywords.some((k) => searchable.includes(k));
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// Rank by number of keyword hits
|
||||
const score = (entry: KnowledgeIndexEntry) => {
|
||||
const searchable =
|
||||
`${entry.path} ${entry.summary} ${entry.tags.join(' ')}`.toLowerCase();
|
||||
return keywords.filter((k) => searchable.includes(k)).length;
|
||||
};
|
||||
return score(b) - score(a);
|
||||
})
|
||||
.slice(0, 5);
|
||||
|
||||
const detailedResults = await Promise.all(
|
||||
matches.map(async (entry) => {
|
||||
const fullPath = path.join(this.config.getProjectRoot(), entry.path);
|
||||
try {
|
||||
const content = await fs.readFile(fullPath, 'utf-8');
|
||||
return {
|
||||
...entry,
|
||||
fullContent: content.substring(0, 2000), // Cap content for context safety
|
||||
};
|
||||
} catch {
|
||||
return { ...entry, fullContent: 'File could not be read.' };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
llmContent: JSON.stringify({
|
||||
success: true,
|
||||
results: detailedResults,
|
||||
}),
|
||||
returnDisplay: `Found ${matches.length} matching insights.`,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
llmContent: JSON.stringify({ success: false, error: errorMessage }),
|
||||
returnDisplay: `Error querying knowledge: ${errorMessage}`,
|
||||
error: {
|
||||
message: errorMessage,
|
||||
type: ToolErrorType.MEMORY_TOOL_EXECUTION_ERROR,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class QueryKnowledgeTool extends BaseDeclarativeTool<
|
||||
QueryKnowledgeParams,
|
||||
ToolResult
|
||||
> {
|
||||
static readonly Name = QUERY_KNOWLEDGE_TOOL_NAME;
|
||||
private readonly config: Config;
|
||||
|
||||
constructor(config: Config, messageBus: MessageBus) {
|
||||
super(
|
||||
QueryKnowledgeTool.Name,
|
||||
'QueryKnowledge',
|
||||
QUERY_KNOWLEDGE_DEFINITION.base.description!,
|
||||
Kind.Think,
|
||||
QUERY_KNOWLEDGE_DEFINITION.base.parametersJsonSchema,
|
||||
messageBus,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
protected createInvocation(
|
||||
params: QueryKnowledgeParams,
|
||||
messageBus: MessageBus,
|
||||
toolName?: string,
|
||||
displayName?: string,
|
||||
) {
|
||||
return new QueryKnowledgeInvocation(
|
||||
params,
|
||||
this.config,
|
||||
messageBus,
|
||||
toolName ?? this.name,
|
||||
displayName ?? this.displayName,
|
||||
);
|
||||
}
|
||||
|
||||
override getSchema(modelId?: string) {
|
||||
return resolveToolDeclaration(QUERY_KNOWLEDGE_DEFINITION, modelId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
BaseDeclarativeTool,
|
||||
BaseToolInvocation,
|
||||
Kind,
|
||||
type ToolResult,
|
||||
} from './tools.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
import { RECORD_DECISION_TOOL_NAME } from './tool-names.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import { RECORD_DECISION_DEFINITION } from './definitions/coreTools.js';
|
||||
import { resolveToolDeclaration } from './definitions/resolver.js';
|
||||
|
||||
interface RecordDecisionParams {
|
||||
title: string;
|
||||
context: string;
|
||||
decision: string;
|
||||
consequences: string;
|
||||
}
|
||||
|
||||
class RecordDecisionInvocation extends BaseToolInvocation<
|
||||
RecordDecisionParams,
|
||||
ToolResult
|
||||
> {
|
||||
private readonly config: Config;
|
||||
|
||||
constructor(
|
||||
params: RecordDecisionParams,
|
||||
config: Config,
|
||||
messageBus: MessageBus,
|
||||
toolName: string,
|
||||
displayName: string,
|
||||
) {
|
||||
super(params, messageBus, toolName, displayName);
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `Recording Architecture Decision: ${this.params.title}`;
|
||||
}
|
||||
|
||||
async execute(_signal: AbortSignal): Promise<ToolResult> {
|
||||
const epicId = this.config.getActiveEpicId();
|
||||
if (!epicId) {
|
||||
return {
|
||||
llmContent: JSON.stringify({
|
||||
success: false,
|
||||
error: 'No active Epic ID found. Cannot record decision.',
|
||||
}),
|
||||
returnDisplay: 'Error: No active Epic found.',
|
||||
error: {
|
||||
message: 'No active Epic ID found.',
|
||||
type: ToolErrorType.MEMORY_TOOL_EXECUTION_ERROR,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { title, context, decision, consequences } = this.params;
|
||||
const adrDir = path.join(
|
||||
process.cwd(),
|
||||
'.gemini',
|
||||
'epics',
|
||||
epicId,
|
||||
'decisions',
|
||||
);
|
||||
|
||||
try {
|
||||
await fs.mkdir(adrDir, { recursive: true });
|
||||
|
||||
const files = await fs.readdir(adrDir);
|
||||
const nextNum = (files.length + 1).toString().padStart(3, '0');
|
||||
const fileName = `ADR-${nextNum}-${title.toLowerCase().replace(/\s+/g, '-')}.md`;
|
||||
const filePath = path.join(adrDir, fileName);
|
||||
|
||||
const content = `# ADR-${nextNum}: ${title}\n\n## Status\nAccepted\n\n## Context\n${context}\n\n## Decision\n${decision}\n\n## Consequences\n${consequences}\n`;
|
||||
|
||||
await fs.writeFile(filePath, content, 'utf-8');
|
||||
|
||||
const message = `Recorded ADR: ${fileName}`;
|
||||
return {
|
||||
llmContent: JSON.stringify({ success: true, message, filePath }),
|
||||
returnDisplay: message,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
llmContent: JSON.stringify({ success: false, error: errorMessage }),
|
||||
returnDisplay: `Error recording decision: ${errorMessage}`,
|
||||
error: {
|
||||
message: errorMessage,
|
||||
type: ToolErrorType.MEMORY_TOOL_EXECUTION_ERROR,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class RecordDecisionTool extends BaseDeclarativeTool<
|
||||
RecordDecisionParams,
|
||||
ToolResult
|
||||
> {
|
||||
static readonly Name = RECORD_DECISION_TOOL_NAME;
|
||||
private readonly config: Config;
|
||||
|
||||
constructor(config: Config, messageBus: MessageBus) {
|
||||
super(
|
||||
RecordDecisionTool.Name,
|
||||
'RecordDecision',
|
||||
RECORD_DECISION_DEFINITION.base.description!,
|
||||
Kind.Think,
|
||||
RECORD_DECISION_DEFINITION.base.parametersJsonSchema,
|
||||
messageBus,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
protected createInvocation(
|
||||
params: RecordDecisionParams,
|
||||
messageBus: MessageBus,
|
||||
toolName?: string,
|
||||
displayName?: string,
|
||||
) {
|
||||
return new RecordDecisionInvocation(
|
||||
params,
|
||||
this.config,
|
||||
messageBus,
|
||||
toolName ?? this.name,
|
||||
displayName ?? this.displayName,
|
||||
);
|
||||
}
|
||||
|
||||
override getSchema(modelId?: string) {
|
||||
return resolveToolDeclaration(RECORD_DECISION_DEFINITION, modelId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
BaseDeclarativeTool,
|
||||
BaseToolInvocation,
|
||||
Kind,
|
||||
type ToolResult,
|
||||
} from './tools.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
import { RECORD_LEARNING_TOOL_NAME } from './tool-names.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import { RECORD_LEARNING_DEFINITION } from './definitions/coreTools.js';
|
||||
import { resolveToolDeclaration } from './definitions/resolver.js';
|
||||
import { MACHINE_LEARNINGS_FILENAME } from './memoryTool.js';
|
||||
|
||||
interface RecordLearningParams {
|
||||
fact: string;
|
||||
level: 'global' | 'project' | 'micro';
|
||||
directory?: string;
|
||||
}
|
||||
|
||||
class RecordLearningInvocation extends BaseToolInvocation<
|
||||
RecordLearningParams,
|
||||
ToolResult
|
||||
> {
|
||||
getDescription(): string {
|
||||
return `Recording machine-learning at ${this.params.level} level`;
|
||||
}
|
||||
|
||||
async execute(_signal: AbortSignal): Promise<ToolResult> {
|
||||
const { fact, level, directory } = this.params;
|
||||
const targetPath = await this.getTargetPath(level, directory);
|
||||
|
||||
try {
|
||||
const currentContent = await this.readCurrentContent(targetPath);
|
||||
const newContent = this.computeNewContent(currentContent, fact);
|
||||
|
||||
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
await fs.writeFile(targetPath, newContent, 'utf-8');
|
||||
|
||||
const message = `Recorded machine-learning at ${level} level: "${fact}" in ${targetPath}`;
|
||||
return {
|
||||
llmContent: JSON.stringify({ success: true, message }),
|
||||
returnDisplay: message,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
llmContent: JSON.stringify({ success: false, error: errorMessage }),
|
||||
returnDisplay: `Error recording learning: ${errorMessage}`,
|
||||
error: {
|
||||
message: errorMessage,
|
||||
type: ToolErrorType.MEMORY_TOOL_EXECUTION_ERROR,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async getTargetPath(
|
||||
level: string,
|
||||
directory?: string,
|
||||
): Promise<string> {
|
||||
switch (level) {
|
||||
case 'global':
|
||||
return path.join(
|
||||
Storage.getGlobalGeminiDir(),
|
||||
MACHINE_LEARNINGS_FILENAME,
|
||||
);
|
||||
case 'project':
|
||||
return path.join(process.cwd(), MACHINE_LEARNINGS_FILENAME);
|
||||
case 'micro':
|
||||
default:
|
||||
return path.join(
|
||||
directory || process.cwd(),
|
||||
MACHINE_LEARNINGS_FILENAME,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async readCurrentContent(filePath: string): Promise<string> {
|
||||
try {
|
||||
return await fs.readFile(filePath, 'utf-8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private computeNewContent(currentContent: string, fact: string): string {
|
||||
const header = '## Agent Machine Learnings';
|
||||
const timestamp = new Date().toISOString().split('T')[0];
|
||||
const newEntry = `- [${timestamp}] ${fact.trim()}`;
|
||||
|
||||
if (!currentContent.includes(header)) {
|
||||
return `${currentContent}\n\n${header}\n${newEntry}\n`.trimStart();
|
||||
}
|
||||
|
||||
return currentContent.replace(header, `${header}\n${newEntry}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class RecordLearningTool extends BaseDeclarativeTool<
|
||||
RecordLearningParams,
|
||||
ToolResult
|
||||
> {
|
||||
static readonly Name = RECORD_LEARNING_TOOL_NAME;
|
||||
|
||||
constructor(messageBus: MessageBus) {
|
||||
super(
|
||||
RecordLearningTool.Name,
|
||||
'RecordLearning',
|
||||
RECORD_LEARNING_DEFINITION.base.description!,
|
||||
Kind.Think,
|
||||
RECORD_LEARNING_DEFINITION.base.parametersJsonSchema,
|
||||
messageBus,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
protected createInvocation(
|
||||
params: RecordLearningParams,
|
||||
messageBus: MessageBus,
|
||||
toolName?: string,
|
||||
displayName?: string,
|
||||
) {
|
||||
return new RecordLearningInvocation(
|
||||
params,
|
||||
messageBus,
|
||||
toolName ?? this.name,
|
||||
displayName ?? this.displayName,
|
||||
);
|
||||
}
|
||||
|
||||
override getSchema(modelId?: string) {
|
||||
return resolveToolDeclaration(RECORD_LEARNING_DEFINITION, modelId);
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,20 @@ import {
|
||||
READ_MANY_PARAM_RECURSIVE,
|
||||
READ_MANY_PARAM_USE_DEFAULT_EXCLUDES,
|
||||
MEMORY_PARAM_FACT,
|
||||
RECORD_LEARNING_TOOL_NAME,
|
||||
RECORD_LEARNING_PARAM_FACT,
|
||||
RECORD_LEARNING_PARAM_LEVEL,
|
||||
RECORD_DECISION_TOOL_NAME,
|
||||
RECORD_DECISION_PARAM_TITLE,
|
||||
RECORD_DECISION_PARAM_CONTEXT,
|
||||
RECORD_DECISION_PARAM_DECISION,
|
||||
RECORD_DECISION_PARAM_CONSEQUENCES,
|
||||
UPDATE_EPIC_STATE_TOOL_NAME,
|
||||
UPDATE_EPIC_STATE_PARAM_TYPE,
|
||||
UPDATE_EPIC_STATE_PARAM_CONTENT,
|
||||
QUERY_KNOWLEDGE_TOOL_NAME,
|
||||
QUERY_KNOWLEDGE_PARAM_QUERY,
|
||||
QUERY_KNOWLEDGE_PARAM_LEVEL,
|
||||
TODOS_PARAM_TODOS,
|
||||
TODOS_ITEM_PARAM_DESCRIPTION,
|
||||
TODOS_ITEM_PARAM_STATUS,
|
||||
@@ -132,6 +146,20 @@ export {
|
||||
READ_MANY_PARAM_RECURSIVE,
|
||||
READ_MANY_PARAM_USE_DEFAULT_EXCLUDES,
|
||||
MEMORY_PARAM_FACT,
|
||||
RECORD_LEARNING_TOOL_NAME,
|
||||
RECORD_LEARNING_PARAM_FACT,
|
||||
RECORD_LEARNING_PARAM_LEVEL,
|
||||
RECORD_DECISION_TOOL_NAME,
|
||||
RECORD_DECISION_PARAM_TITLE,
|
||||
RECORD_DECISION_PARAM_CONTEXT,
|
||||
RECORD_DECISION_PARAM_DECISION,
|
||||
RECORD_DECISION_PARAM_CONSEQUENCES,
|
||||
UPDATE_EPIC_STATE_TOOL_NAME,
|
||||
UPDATE_EPIC_STATE_PARAM_TYPE,
|
||||
UPDATE_EPIC_STATE_PARAM_CONTENT,
|
||||
QUERY_KNOWLEDGE_TOOL_NAME,
|
||||
QUERY_KNOWLEDGE_PARAM_QUERY,
|
||||
QUERY_KNOWLEDGE_PARAM_LEVEL,
|
||||
TODOS_PARAM_TODOS,
|
||||
TODOS_ITEM_PARAM_DESCRIPTION,
|
||||
TODOS_ITEM_PARAM_STATUS,
|
||||
@@ -240,6 +268,10 @@ export const ALL_BUILTIN_TOOL_NAMES = [
|
||||
READ_FILE_TOOL_NAME,
|
||||
LS_TOOL_NAME,
|
||||
MEMORY_TOOL_NAME,
|
||||
RECORD_LEARNING_TOOL_NAME,
|
||||
RECORD_DECISION_TOOL_NAME,
|
||||
UPDATE_EPIC_STATE_TOOL_NAME,
|
||||
QUERY_KNOWLEDGE_TOOL_NAME,
|
||||
ACTIVATE_SKILL_TOOL_NAME,
|
||||
ASK_USER_TOOL_NAME,
|
||||
TRACKER_CREATE_TASK_TOOL_NAME,
|
||||
@@ -264,6 +296,7 @@ export const PLAN_MODE_TOOLS = [
|
||||
READ_FILE_TOOL_NAME,
|
||||
LS_TOOL_NAME,
|
||||
WEB_SEARCH_TOOL_NAME,
|
||||
QUERY_KNOWLEDGE_TOOL_NAME,
|
||||
ASK_USER_TOOL_NAME,
|
||||
ACTIVATE_SKILL_TOOL_NAME,
|
||||
] as const;
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
BaseDeclarativeTool,
|
||||
BaseToolInvocation,
|
||||
Kind,
|
||||
type ToolResult,
|
||||
} from './tools.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
import { UPDATE_EPIC_STATE_TOOL_NAME } from './tool-names.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import { UPDATE_EPIC_STATE_DEFINITION } from './definitions/coreTools.js';
|
||||
import { resolveToolDeclaration } from './definitions/resolver.js';
|
||||
|
||||
interface UpdateEpicStateParams {
|
||||
update_type: 'context' | 'task_log' | 'notes';
|
||||
content: string;
|
||||
}
|
||||
|
||||
class UpdateEpicStateInvocation extends BaseToolInvocation<
|
||||
UpdateEpicStateParams,
|
||||
ToolResult
|
||||
> {
|
||||
private readonly config: Config;
|
||||
|
||||
constructor(
|
||||
params: UpdateEpicStateParams,
|
||||
config: Config,
|
||||
messageBus: MessageBus,
|
||||
toolName: string,
|
||||
displayName: string,
|
||||
) {
|
||||
super(params, messageBus, toolName, displayName);
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `Updating epic ${this.params.update_type}`;
|
||||
}
|
||||
|
||||
async execute(_signal: AbortSignal): Promise<ToolResult> {
|
||||
const epicId = this.config.getActiveEpicId();
|
||||
if (!epicId) {
|
||||
return {
|
||||
llmContent: JSON.stringify({
|
||||
success: false,
|
||||
error: 'No active Epic ID found.',
|
||||
}),
|
||||
returnDisplay: 'Error: No active Epic found.',
|
||||
error: {
|
||||
message: 'No active Epic ID found.',
|
||||
type: ToolErrorType.MEMORY_TOOL_EXECUTION_ERROR,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { update_type, content } = this.params;
|
||||
const fileName = `${update_type}.md`;
|
||||
const filePath = path.join(
|
||||
process.cwd(),
|
||||
'.gemini',
|
||||
'epics',
|
||||
epicId,
|
||||
fileName,
|
||||
);
|
||||
|
||||
try {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
|
||||
let newContent = content;
|
||||
if (update_type === 'task_log' || update_type === 'notes') {
|
||||
const currentContent = await this.readCurrentContent(filePath);
|
||||
const timestamp = new Date().toISOString();
|
||||
newContent =
|
||||
`${currentContent}\n\n### [${timestamp}]\n${content}`.trim();
|
||||
}
|
||||
|
||||
await fs.writeFile(filePath, newContent, 'utf-8');
|
||||
|
||||
const message = `Updated epic ${update_type} for ${epicId}`;
|
||||
return {
|
||||
llmContent: JSON.stringify({ success: true, message, filePath }),
|
||||
returnDisplay: message,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
llmContent: JSON.stringify({ success: false, error: errorMessage }),
|
||||
returnDisplay: `Error updating epic state: ${errorMessage}`,
|
||||
error: {
|
||||
message: errorMessage,
|
||||
type: ToolErrorType.MEMORY_TOOL_EXECUTION_ERROR,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async readCurrentContent(filePath: string): Promise<string> {
|
||||
try {
|
||||
return await fs.readFile(filePath, 'utf-8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdateEpicStateTool extends BaseDeclarativeTool<
|
||||
UpdateEpicStateParams,
|
||||
ToolResult
|
||||
> {
|
||||
static readonly Name = UPDATE_EPIC_STATE_TOOL_NAME;
|
||||
private readonly config: Config;
|
||||
|
||||
constructor(config: Config, messageBus: MessageBus) {
|
||||
super(
|
||||
UpdateEpicStateTool.Name,
|
||||
'UpdateEpicState',
|
||||
UPDATE_EPIC_STATE_DEFINITION.base.description!,
|
||||
Kind.Think,
|
||||
UPDATE_EPIC_STATE_DEFINITION.base.parametersJsonSchema,
|
||||
messageBus,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
protected createInvocation(
|
||||
params: UpdateEpicStateParams,
|
||||
messageBus: MessageBus,
|
||||
toolName?: string,
|
||||
displayName?: string,
|
||||
) {
|
||||
return new UpdateEpicStateInvocation(
|
||||
params,
|
||||
this.config,
|
||||
messageBus,
|
||||
toolName ?? this.name,
|
||||
displayName ?? this.displayName,
|
||||
);
|
||||
}
|
||||
|
||||
override getSchema(modelId?: string) {
|
||||
return resolveToolDeclaration(UPDATE_EPIC_STATE_DEFINITION, modelId);
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { WriteTodosTool, type WriteTodosToolParams } from './write-todos.js';
|
||||
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
|
||||
import { makeFakeConfig } from '../test-utils/config.js';
|
||||
|
||||
describe('WriteTodosTool', () => {
|
||||
const tool = new WriteTodosTool(createMockMessageBus());
|
||||
const tool = new WriteTodosTool(makeFakeConfig(), createMockMessageBus());
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
describe('validation', () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type ToolResult,
|
||||
} from './tools.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { WRITE_TODOS_TOOL_NAME } from './tool-names.js';
|
||||
import { WRITE_TODOS_DEFINITION } from './definitions/coreTools.js';
|
||||
import { resolveToolDeclaration } from './definitions/resolver.js';
|
||||
@@ -35,13 +36,17 @@ class WriteTodosToolInvocation extends BaseToolInvocation<
|
||||
WriteTodosToolParams,
|
||||
ToolResult
|
||||
> {
|
||||
private readonly config: Config;
|
||||
|
||||
constructor(
|
||||
params: WriteTodosToolParams,
|
||||
config: Config,
|
||||
messageBus: MessageBus,
|
||||
_toolName?: string,
|
||||
_toolDisplayName?: string,
|
||||
) {
|
||||
super(params, messageBus, _toolName, _toolDisplayName);
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
@@ -63,11 +68,18 @@ class WriteTodosToolInvocation extends BaseToolInvocation<
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
const llmContent =
|
||||
let llmContent =
|
||||
todos.length > 0
|
||||
? `Successfully updated the todo list. The current list is now:\n${todoListString}`
|
||||
: 'Successfully cleared the todo list.';
|
||||
|
||||
if (this.config.getAklEnabled() && this.config.getActiveEpicId()) {
|
||||
const completedTask = todos.find((t) => t.status === 'completed');
|
||||
if (completedTask) {
|
||||
llmContent += `\n\n**AKL NOTIFICATION**: You have completed a task. You MUST now update the Epic situational awareness using \`update_epic_state\` (type: 'task_log') and record any new architectural decisions or machine-learnings if applicable.`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
llmContent,
|
||||
returnDisplay: { todos },
|
||||
@@ -80,8 +92,9 @@ export class WriteTodosTool extends BaseDeclarativeTool<
|
||||
ToolResult
|
||||
> {
|
||||
static readonly Name = WRITE_TODOS_TOOL_NAME;
|
||||
private readonly config: Config;
|
||||
|
||||
constructor(messageBus: MessageBus) {
|
||||
constructor(config: Config, messageBus: MessageBus) {
|
||||
super(
|
||||
WriteTodosTool.Name,
|
||||
'WriteTodos',
|
||||
@@ -92,6 +105,7 @@ export class WriteTodosTool extends BaseDeclarativeTool<
|
||||
true, // isOutputMarkdown
|
||||
false, // canUpdateOutput
|
||||
);
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
override getSchema(modelId?: string) {
|
||||
@@ -137,6 +151,7 @@ export class WriteTodosTool extends BaseDeclarativeTool<
|
||||
): ToolInvocation<WriteTodosToolParams, ToolResult> {
|
||||
return new WriteTodosToolInvocation(
|
||||
params,
|
||||
this.config,
|
||||
messageBus,
|
||||
_toolName,
|
||||
_displayName,
|
||||
|
||||
@@ -1281,6 +1281,8 @@ included directory memory
|
||||
getImportFormat: vi.fn().mockReturnValue('tree'),
|
||||
getFileFilteringOptions: vi.fn().mockReturnValue(undefined),
|
||||
getDiscoveryMaxDirs: vi.fn().mockReturnValue(200),
|
||||
getAklEnabled: vi.fn().mockReturnValue(false),
|
||||
getProjectRoot: vi.fn().mockReturnValue(projectRoot),
|
||||
setUserMemory: vi.fn(),
|
||||
setGeminiMdFileCount: vi.fn(),
|
||||
setGeminiMdFilePaths: vi.fn(),
|
||||
|
||||
@@ -506,6 +506,44 @@ export async function getEnvironmentMemoryPaths(
|
||||
return Array.from(allPaths).sort();
|
||||
}
|
||||
|
||||
export async function getAklFilePaths(config: Config): Promise<string[]> {
|
||||
if (!config.getAklEnabled()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const aklPaths: string[] = [];
|
||||
const projectRoot = config.getProjectRoot();
|
||||
const epicId = config.getActiveEpicId();
|
||||
|
||||
// 1. .gemini/notes.md at project root
|
||||
const notesPath = normalizePath(
|
||||
path.join(projectRoot, '.gemini', 'notes.md'),
|
||||
);
|
||||
try {
|
||||
await fs.access(notesPath, fsSync.constants.R_OK);
|
||||
aklPaths.push(notesPath);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
// 2. Active Epic context
|
||||
if (epicId) {
|
||||
const epicDir = path.join(projectRoot, '.gemini', 'epics', epicId);
|
||||
const epicFiles = ['context.md', 'notes.md', 'patterns.md', 'task_log.md'];
|
||||
for (const file of epicFiles) {
|
||||
const filePath = normalizePath(path.join(epicDir, file));
|
||||
try {
|
||||
await fs.access(filePath, fsSync.constants.R_OK);
|
||||
aklPaths.push(filePath);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return aklPaths;
|
||||
}
|
||||
|
||||
export function categorizeAndConcatenate(
|
||||
paths: { global: string[]; extension: string[]; project: string[] },
|
||||
contentsMap: Map<string, GeminiFileContent>,
|
||||
@@ -599,6 +637,7 @@ export async function loadServerHierarchicalMemory(
|
||||
importFormat: 'flat' | 'tree' = 'tree',
|
||||
fileFilteringOptions?: FileFilteringOptions,
|
||||
maxDirs: number = 200,
|
||||
config?: Config,
|
||||
): Promise<LoadServerHierarchicalMemoryResponse> {
|
||||
// FIX: Use real, canonical paths for a reliable comparison to handle symlinks.
|
||||
const realCwd = normalizePath(
|
||||
@@ -620,6 +659,7 @@ export async function loadServerHierarchicalMemory(
|
||||
// For the server, homedir() refers to the server process's home.
|
||||
// This is consistent with how MemoryTool already finds the global path.
|
||||
const userHomePath = homedir();
|
||||
const aklPaths = config ? await getAklFilePaths(config) : [];
|
||||
|
||||
// 1. SCATTER: Gather all paths
|
||||
const [discoveryResult, extensionPaths] = await Promise.all([
|
||||
@@ -640,6 +680,7 @@ export async function loadServerHierarchicalMemory(
|
||||
...discoveryResult.global,
|
||||
...discoveryResult.project,
|
||||
...extensionPaths,
|
||||
...aklPaths,
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -710,6 +751,7 @@ export async function refreshServerHierarchicalMemory(config: Config) {
|
||||
config.getImportFormat(),
|
||||
config.getFileFilteringOptions(),
|
||||
config.getDiscoveryMaxDirs(),
|
||||
config,
|
||||
);
|
||||
const mcpInstructions =
|
||||
config.getMcpClientManager()?.getMcpInstructions() || '';
|
||||
|
||||
@@ -2045,6 +2045,13 @@
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"akl": {
|
||||
"title": "Agent Knowledge Layer",
|
||||
"description": "Enable the Agent Knowledge Layer for persistent situational awareness.",
|
||||
"markdownDescription": "Enable the Agent Knowledge Layer for persistent situational awareness.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `false`",
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"modelSteering": {
|
||||
"title": "Model Steering",
|
||||
"description": "Enable model steering (user hints) to guide the model during tool execution.",
|
||||
|
||||
Reference in New Issue
Block a user