@0xmaxma/claude-gateway 1.6.9 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +149 -20
- package/config.template.json +59 -1
- package/dist/agent/dreaming/accept.d.ts +43 -0
- package/dist/agent/dreaming/accept.d.ts.map +1 -0
- package/dist/agent/dreaming/accept.js +230 -0
- package/dist/agent/dreaming/accept.js.map +1 -0
- package/dist/agent/dreaming/applier.d.ts +77 -0
- package/dist/agent/dreaming/applier.d.ts.map +1 -0
- package/dist/agent/dreaming/applier.js +406 -0
- package/dist/agent/dreaming/applier.js.map +1 -0
- package/dist/agent/dreaming/audit.d.ts +27 -0
- package/dist/agent/dreaming/audit.d.ts.map +1 -0
- package/dist/agent/dreaming/audit.js +100 -0
- package/dist/agent/dreaming/audit.js.map +1 -0
- package/dist/agent/dreaming/compactor.d.ts +66 -0
- package/dist/agent/dreaming/compactor.d.ts.map +1 -0
- package/dist/agent/dreaming/compactor.js +306 -0
- package/dist/agent/dreaming/compactor.js.map +1 -0
- package/dist/agent/dreaming/config.d.ts +19 -0
- package/dist/agent/dreaming/config.d.ts.map +1 -0
- package/dist/agent/dreaming/config.js +107 -0
- package/dist/agent/dreaming/config.js.map +1 -0
- package/dist/agent/dreaming/episodic.d.ts +42 -0
- package/dist/agent/dreaming/episodic.d.ts.map +1 -0
- package/dist/agent/dreaming/episodic.js +116 -0
- package/dist/agent/dreaming/episodic.js.map +1 -0
- package/dist/agent/dreaming/gather.d.ts +31 -0
- package/dist/agent/dreaming/gather.d.ts.map +1 -0
- package/dist/agent/dreaming/gather.js +55 -0
- package/dist/agent/dreaming/gather.js.map +1 -0
- package/dist/agent/dreaming/index.d.ts +87 -0
- package/dist/agent/dreaming/index.d.ts.map +1 -0
- package/dist/agent/dreaming/index.js +377 -0
- package/dist/agent/dreaming/index.js.map +1 -0
- package/dist/agent/dreaming/migrate-cli.d.ts +17 -0
- package/dist/agent/dreaming/migrate-cli.d.ts.map +1 -0
- package/dist/agent/dreaming/migrate-cli.js +42 -0
- package/dist/agent/dreaming/migrate-cli.js.map +1 -0
- package/dist/agent/dreaming/migrate.d.ts +72 -0
- package/dist/agent/dreaming/migrate.d.ts.map +1 -0
- package/dist/agent/dreaming/migrate.js +374 -0
- package/dist/agent/dreaming/migrate.js.map +1 -0
- package/dist/agent/dreaming/report.d.ts +39 -0
- package/dist/agent/dreaming/report.d.ts.map +1 -0
- package/dist/agent/dreaming/report.js +121 -0
- package/dist/agent/dreaming/report.js.map +1 -0
- package/dist/agent/dreaming/reviewer.d.ts +43 -0
- package/dist/agent/dreaming/reviewer.d.ts.map +1 -0
- package/dist/agent/dreaming/reviewer.js +249 -0
- package/dist/agent/dreaming/reviewer.js.map +1 -0
- package/dist/agent/dreaming/staleness.d.ts +68 -0
- package/dist/agent/dreaming/staleness.d.ts.map +1 -0
- package/dist/agent/dreaming/staleness.js +281 -0
- package/dist/agent/dreaming/staleness.js.map +1 -0
- package/dist/agent/dreaming/types.d.ts +130 -0
- package/dist/agent/dreaming/types.d.ts.map +1 -0
- package/dist/agent/dreaming/types.js +10 -0
- package/dist/agent/dreaming/types.js.map +1 -0
- package/dist/agent/knowledge/archive-db.d.ts +114 -0
- package/dist/agent/knowledge/archive-db.d.ts.map +1 -0
- package/dist/agent/knowledge/archive-db.js +474 -0
- package/dist/agent/knowledge/archive-db.js.map +1 -0
- package/dist/agent/knowledge/chunk.d.ts +21 -0
- package/dist/agent/knowledge/chunk.d.ts.map +1 -0
- package/dist/agent/knowledge/chunk.js +67 -0
- package/dist/agent/knowledge/chunk.js.map +1 -0
- package/dist/agent/knowledge/config.d.ts +20 -0
- package/dist/agent/knowledge/config.d.ts.map +1 -0
- package/dist/agent/knowledge/config.js +143 -0
- package/dist/agent/knowledge/config.js.map +1 -0
- package/dist/agent/knowledge/graph-demo.d.ts +25 -0
- package/dist/agent/knowledge/graph-demo.d.ts.map +1 -0
- package/dist/agent/knowledge/graph-demo.js +132 -0
- package/dist/agent/knowledge/graph-demo.js.map +1 -0
- package/dist/agent/knowledge/index.d.ts +24 -0
- package/dist/agent/knowledge/index.d.ts.map +1 -0
- package/dist/agent/knowledge/index.js +50 -0
- package/dist/agent/knowledge/index.js.map +1 -0
- package/dist/agent/knowledge/indexer.d.ts +34 -0
- package/dist/agent/knowledge/indexer.d.ts.map +1 -0
- package/dist/agent/knowledge/indexer.js +271 -0
- package/dist/agent/knowledge/indexer.js.map +1 -0
- package/dist/agent/knowledge/lifecycle.d.ts +91 -0
- package/dist/agent/knowledge/lifecycle.d.ts.map +1 -0
- package/dist/agent/knowledge/lifecycle.js +205 -0
- package/dist/agent/knowledge/lifecycle.js.map +1 -0
- package/dist/agent/knowledge/provenance.d.ts +15 -0
- package/dist/agent/knowledge/provenance.d.ts.map +1 -0
- package/dist/agent/knowledge/provenance.js +37 -0
- package/dist/agent/knowledge/provenance.js.map +1 -0
- package/dist/agent/knowledge/reindex-cli.d.ts +13 -0
- package/dist/agent/knowledge/reindex-cli.d.ts.map +1 -0
- package/dist/agent/knowledge/reindex-cli.js +53 -0
- package/dist/agent/knowledge/reindex-cli.js.map +1 -0
- package/dist/agent/knowledge/reindex-spawn.d.ts +30 -0
- package/dist/agent/knowledge/reindex-spawn.d.ts.map +1 -0
- package/dist/agent/knowledge/reindex-spawn.js +97 -0
- package/dist/agent/knowledge/reindex-spawn.js.map +1 -0
- package/dist/agent/knowledge/shared-promote.d.ts +18 -0
- package/dist/agent/knowledge/shared-promote.d.ts.map +1 -0
- package/dist/agent/knowledge/shared-promote.js +29 -0
- package/dist/agent/knowledge/shared-promote.js.map +1 -0
- package/dist/agent/knowledge/shared-writer.d.ts +24 -0
- package/dist/agent/knowledge/shared-writer.d.ts.map +1 -0
- package/dist/agent/knowledge/shared-writer.js +97 -0
- package/dist/agent/knowledge/shared-writer.js.map +1 -0
- package/dist/agent/knowledge/types.d.ts +83 -0
- package/dist/agent/knowledge/types.d.ts.map +1 -0
- package/dist/agent/knowledge/types.js +12 -0
- package/dist/agent/knowledge/types.js.map +1 -0
- package/dist/agent/knowledge/wiki.d.ts +107 -0
- package/dist/agent/knowledge/wiki.d.ts.map +1 -0
- package/dist/agent/knowledge/wiki.js +415 -0
- package/dist/agent/knowledge/wiki.js.map +1 -0
- package/dist/agent/memory-metrics.d.ts +48 -0
- package/dist/agent/memory-metrics.d.ts.map +1 -0
- package/dist/agent/memory-metrics.js +156 -0
- package/dist/agent/memory-metrics.js.map +1 -0
- package/dist/agent/runner.d.ts +24 -1
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +82 -6
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/skill-learning/config.d.ts +18 -0
- package/dist/agent/skill-learning/config.d.ts.map +1 -0
- package/dist/agent/skill-learning/config.js +71 -0
- package/dist/agent/skill-learning/config.js.map +1 -0
- package/dist/agent/skill-learning/curator.d.ts +45 -0
- package/dist/agent/skill-learning/curator.d.ts.map +1 -0
- package/dist/agent/skill-learning/curator.js +171 -0
- package/dist/agent/skill-learning/curator.js.map +1 -0
- package/dist/agent/skill-learning/index.d.ts +79 -0
- package/dist/agent/skill-learning/index.d.ts.map +1 -0
- package/dist/agent/skill-learning/index.js +308 -0
- package/dist/agent/skill-learning/index.js.map +1 -0
- package/dist/agent/skill-learning/metrics.d.ts +13 -0
- package/dist/agent/skill-learning/metrics.d.ts.map +1 -0
- package/dist/agent/skill-learning/metrics.js +109 -0
- package/dist/agent/skill-learning/metrics.js.map +1 -0
- package/dist/agent/skill-learning/notifier.d.ts +111 -0
- package/dist/agent/skill-learning/notifier.d.ts.map +1 -0
- package/dist/agent/skill-learning/notifier.js +398 -0
- package/dist/agent/skill-learning/notifier.js.map +1 -0
- package/dist/agent/skill-learning/reviewer.d.ts +61 -0
- package/dist/agent/skill-learning/reviewer.d.ts.map +1 -0
- package/dist/agent/skill-learning/reviewer.js +271 -0
- package/dist/agent/skill-learning/reviewer.js.map +1 -0
- package/dist/agent/skill-learning/telemetry.d.ts +22 -0
- package/dist/agent/skill-learning/telemetry.d.ts.map +1 -0
- package/dist/agent/skill-learning/telemetry.js +90 -0
- package/dist/agent/skill-learning/telemetry.js.map +1 -0
- package/dist/agent/skill-learning/trigger.d.ts +23 -0
- package/dist/agent/skill-learning/trigger.d.ts.map +1 -0
- package/dist/agent/skill-learning/trigger.js +44 -0
- package/dist/agent/skill-learning/trigger.js.map +1 -0
- package/dist/agent/skill-learning/types.d.ts +165 -0
- package/dist/agent/skill-learning/types.d.ts.map +1 -0
- package/dist/agent/skill-learning/types.js +11 -0
- package/dist/agent/skill-learning/types.js.map +1 -0
- package/dist/agent/skill-learning/writer.d.ts +37 -0
- package/dist/agent/skill-learning/writer.d.ts.map +1 -0
- package/dist/agent/skill-learning/writer.js +223 -0
- package/dist/agent/skill-learning/writer.js.map +1 -0
- package/dist/agent/workspace-loader.d.ts +83 -6
- package/dist/agent/workspace-loader.d.ts.map +1 -1
- package/dist/agent/workspace-loader.js +207 -14
- package/dist/agent/workspace-loader.js.map +1 -1
- package/dist/api/gateway-router.d.ts +2 -0
- package/dist/api/gateway-router.d.ts.map +1 -1
- package/dist/api/gateway-router.js +293 -6
- package/dist/api/gateway-router.js.map +1 -1
- package/dist/api/skills-router.d.ts +2 -2
- package/dist/api/skills-router.d.ts.map +1 -1
- package/dist/api/skills-router.js +61 -1
- package/dist/api/skills-router.js.map +1 -1
- package/dist/apps/agent-manager.d.ts +7 -0
- package/dist/apps/agent-manager.d.ts.map +1 -1
- package/dist/apps/agent-manager.js +13 -1
- package/dist/apps/agent-manager.js.map +1 -1
- package/dist/config/migrator.d.ts +2 -0
- package/dist/config/migrator.d.ts.map +1 -1
- package/dist/config/migrator.js +72 -1
- package/dist/config/migrator.js.map +1 -1
- package/dist/history/cleanup.d.ts +7 -0
- package/dist/history/cleanup.d.ts.map +1 -1
- package/dist/history/cleanup.js +27 -0
- package/dist/history/cleanup.js.map +1 -1
- package/dist/history/db.d.ts +65 -0
- package/dist/history/db.d.ts.map +1 -1
- package/dist/history/db.js +261 -0
- package/dist/history/db.js.map +1 -1
- package/dist/index.js +122 -20
- package/dist/index.js.map +1 -1
- package/dist/session/process.d.ts +10 -0
- package/dist/session/process.d.ts.map +1 -1
- package/dist/session/process.js +86 -1
- package/dist/session/process.js.map +1 -1
- package/dist/skills/loader.d.ts +8 -0
- package/dist/skills/loader.d.ts.map +1 -1
- package/dist/skills/loader.js +29 -1
- package/dist/skills/loader.js.map +1 -1
- package/dist/types.d.ts +116 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/ui/web-ui.d.ts.map +1 -1
- package/dist/ui/web-ui.js +1126 -3
- package/dist/ui/web-ui.js.map +1 -1
- package/mcp/package.json +2 -1
- package/mcp/server.ts +2 -0
- package/mcp/tools/memory/archive-reader.test.ts +212 -0
- package/mcp/tools/memory/archive-reader.ts +253 -0
- package/mcp/tools/memory/module.ts +152 -0
- package/mcp/tools/memory/retrieval-recorder.ts +66 -0
- package/mcp/tools/skills/module.ts +28 -0
- package/package.json +1 -1
- package/resource/claude_gateway.svg +1038 -112
package/dist/ui/web-ui.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"web-ui.js","sourceRoot":"","sources":["../../src/ui/web-ui.ts"],"names":[],"mappings":";;AAIA,
|
|
1
|
+
{"version":3,"file":"web-ui.js","sourceRoot":"","sources":["../../src/ui/web-ui.ts"],"names":[],"mappings":";;AAIA,sDAw+DC;AAQD,8CAiFC;AArkED;;;GAGG;AACH,SAAgB,qBAAqB;IACnC,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;QAs+DD,CAAC;AACT,CAAC;AAED;;;;;GAKG;AACH,SAAgB,iBAAiB,CAAC,cAAc,GAAG,EAAE;IACnD,MAAM,UAAU,GAAG,cAAc;SAC9B,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;IAC9F,+EAA+E;IAC/E,8EAA8E;IAC9E,MAAM,SAAS,GAAG,UAAU;QAC1B,CAAC,CAAC;uBACiB,UAAU,QAAQ;QACrC,CAAC,CAAC;;;;;;;YAOM,CAAC;IACX,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAmCP,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;QA6BH,CAAC;AACT,CAAC"}
|
package/mcp/package.json
CHANGED
package/mcp/server.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { ImageModule } from './tools/image/module';
|
|
|
23
23
|
import { ShareImageModule } from './tools/share-image/module';
|
|
24
24
|
import { AppsModule } from './tools/apps/module';
|
|
25
25
|
import { ApiModule } from './tools/api/module';
|
|
26
|
+
import { MemoryModule } from './tools/memory/module';
|
|
26
27
|
import { buildChannelInstructions } from './instructions';
|
|
27
28
|
import type { ChannelModule, ToolModule, McpToolDefinition } from './types';
|
|
28
29
|
|
|
@@ -46,6 +47,7 @@ const modules: AnyModule[] = [
|
|
|
46
47
|
new ShareImageModule(),
|
|
47
48
|
new AppsModule(),
|
|
48
49
|
new ApiModule(),
|
|
50
|
+
new MemoryModule(),
|
|
49
51
|
];
|
|
50
52
|
|
|
51
53
|
// Build tool-to-module mapping for enabled modules
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bun tests for the knowledge-archive read path (planning-64 K1).
|
|
3
|
+
*
|
|
4
|
+
* Runs under Bun (`bun test`) because the reader uses `bun:sqlite` — the Node
|
|
5
|
+
* jest suite cannot load this module. The fixture DB is built with bun:sqlite
|
|
6
|
+
* using the SAME schema src/ writes under node:sqlite (external-content FTS5 +
|
|
7
|
+
* provenance/recall), so this exercises the real query path end to end.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { test, expect } from 'bun:test';
|
|
11
|
+
import { Database } from 'bun:sqlite';
|
|
12
|
+
import * as fs from 'fs';
|
|
13
|
+
import * as os from 'os';
|
|
14
|
+
import * as path from 'path';
|
|
15
|
+
|
|
16
|
+
import { searchArchive, getExcerpt, isMemoryScopedPath, archiveDbPath, sharedDbPathFromEnv, mergeHits } from './archive-reader';
|
|
17
|
+
|
|
18
|
+
// Build a temp agent dir with a kb.sqlite carrying the given chunks.
|
|
19
|
+
function mkAgentWithArchive(
|
|
20
|
+
chunks: Array<{ id: string; path: string; start: number; end: number; text: string; origin?: string; importance?: number }>,
|
|
21
|
+
): { agentDir: string; workspaceDir: string; dbPath: string } {
|
|
22
|
+
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kb1-'));
|
|
23
|
+
const workspaceDir = path.join(agentDir, 'workspace');
|
|
24
|
+
fs.mkdirSync(workspaceDir, { recursive: true });
|
|
25
|
+
const dbPath = archiveDbPath(workspaceDir); // agentDir/kb.sqlite
|
|
26
|
+
|
|
27
|
+
const db = new Database(dbPath);
|
|
28
|
+
db.exec('PRAGMA foreign_keys=ON');
|
|
29
|
+
db.exec(`
|
|
30
|
+
CREATE TABLE kb_chunks (rowid_id INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT UNIQUE, path TEXT,
|
|
31
|
+
start_line INTEGER, end_line INTEGER, text TEXT, updated_at INTEGER);
|
|
32
|
+
CREATE TABLE kb_chunk_recall (chunk_id TEXT PRIMARY KEY, importance INTEGER, triggers TEXT, project_key TEXT,
|
|
33
|
+
FOREIGN KEY (chunk_id) REFERENCES kb_chunks(id) ON DELETE CASCADE);
|
|
34
|
+
CREATE TABLE kb_chunk_provenance (chunk_id TEXT PRIMARY KEY, origin_class TEXT, session_kind TEXT,
|
|
35
|
+
observed_at INTEGER, supersedes_key TEXT, FOREIGN KEY (chunk_id) REFERENCES kb_chunks(id) ON DELETE CASCADE);
|
|
36
|
+
CREATE VIRTUAL TABLE kb_chunks_fts USING fts5(text, content='kb_chunks', content_rowid='rowid_id');
|
|
37
|
+
CREATE TRIGGER kb_chunks_ai AFTER INSERT ON kb_chunks BEGIN
|
|
38
|
+
INSERT INTO kb_chunks_fts(rowid, text) VALUES (new.rowid_id, new.text);
|
|
39
|
+
END;
|
|
40
|
+
`);
|
|
41
|
+
const ins = db.prepare('INSERT INTO kb_chunks(id,path,start_line,end_line,text,updated_at) VALUES(?,?,?,?,?,?)');
|
|
42
|
+
const insR = db.prepare('INSERT INTO kb_chunk_recall(chunk_id,importance) VALUES(?,?)');
|
|
43
|
+
const insP = db.prepare('INSERT INTO kb_chunk_provenance(chunk_id,origin_class,session_kind,observed_at) VALUES(?,?,?,?)');
|
|
44
|
+
for (const c of chunks) {
|
|
45
|
+
ins.run(c.id, c.path, c.start, c.end, c.text, 1);
|
|
46
|
+
insR.run(c.id, c.importance ?? null);
|
|
47
|
+
insP.run(c.id, c.origin ?? 'agent', 'unknown', 1);
|
|
48
|
+
}
|
|
49
|
+
db.close();
|
|
50
|
+
return { agentDir, workspaceDir, dbPath };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
test('isMemoryScopedPath: accepts memory-scoped, rejects traversal/abs/out-of-scope', () => {
|
|
54
|
+
expect(isMemoryScopedPath('MEMORY.md')).toBe(true);
|
|
55
|
+
expect(isMemoryScopedPath('USER.md')).toBe(true);
|
|
56
|
+
expect(isMemoryScopedPath('memory/foo.md')).toBe(true);
|
|
57
|
+
expect(isMemoryScopedPath('memory/sub/bar.md')).toBe(true);
|
|
58
|
+
expect(isMemoryScopedPath('../secret.md')).toBe(false);
|
|
59
|
+
// The discriminating case: ends in .md AND starts with memory/, so ONLY the
|
|
60
|
+
// ".." guard rejects it — proves that guard is load-bearing.
|
|
61
|
+
expect(isMemoryScopedPath('memory/../secret.md')).toBe(false);
|
|
62
|
+
expect(isMemoryScopedPath('memory/../../etc/passwd')).toBe(false);
|
|
63
|
+
expect(isMemoryScopedPath('/etc/passwd')).toBe(false);
|
|
64
|
+
expect(isMemoryScopedPath('notes/random.md')).toBe(false);
|
|
65
|
+
expect(isMemoryScopedPath('AGENTS.md')).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('searchArchive: FTS match returns ranked hit with provenance + importance', () => {
|
|
69
|
+
const { workspaceDir, agentDir } = mkAgentWithArchive([
|
|
70
|
+
{ id: 'memory/getpod.md#1-1', path: 'memory/getpod.md', start: 1, end: 1, text: 'Getpod ingress returns 401 before the gateway', origin: 'agent', importance: 7 },
|
|
71
|
+
{ id: 'memory/apps.md#1-1', path: 'memory/apps.md', start: 1, end: 1, text: 'apps router mounts under gateway-router', origin: 'agent' },
|
|
72
|
+
]);
|
|
73
|
+
try {
|
|
74
|
+
const hits = searchArchive(archiveDbPath(workspaceDir), 'ingress 401', 6);
|
|
75
|
+
expect(hits.length).toBe(1);
|
|
76
|
+
expect(hits[0].path).toBe('memory/getpod.md');
|
|
77
|
+
expect(hits[0].originClass).toBe('agent');
|
|
78
|
+
expect(hits[0].importance).toBe(7);
|
|
79
|
+
expect(hits[0].startLine).toBe(1);
|
|
80
|
+
expect(typeof hits[0].score).toBe('number');
|
|
81
|
+
} finally {
|
|
82
|
+
fs.rmSync(agentDir, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Regression (#351): a kb.sqlite built before the planning-66 `entry_hash`
|
|
87
|
+
// column (exactly what `mkAgentWithArchive` produces) must not crash the read
|
|
88
|
+
// path with `no such column: entry_hash`. searchArchive heals the column in
|
|
89
|
+
// place and returns hits with entryHash=null. Proven to throw on pre-fix code.
|
|
90
|
+
test('searchArchive: DB missing entry_hash column does not throw and heals it', () => {
|
|
91
|
+
const { workspaceDir, agentDir } = mkAgentWithArchive([
|
|
92
|
+
{ id: 'memory/x.md#1-1', path: 'memory/x.md', start: 1, end: 1, text: 'ingress 401 token mismatch', origin: 'agent' },
|
|
93
|
+
]);
|
|
94
|
+
const dbPath = archiveDbPath(workspaceDir);
|
|
95
|
+
const cols = (dbPath: string): string[] => {
|
|
96
|
+
const d = new Database(dbPath, { readonly: true });
|
|
97
|
+
try {
|
|
98
|
+
return (d.query(`PRAGMA table_info(kb_chunks)`).all() as Array<{ name: string }>).map((c) => c.name);
|
|
99
|
+
} finally {
|
|
100
|
+
d.close();
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
try {
|
|
104
|
+
// Precondition: the pre-#346 fixture genuinely lacks the column.
|
|
105
|
+
expect(cols(dbPath)).not.toContain('entry_hash');
|
|
106
|
+
|
|
107
|
+
let hits: ReturnType<typeof searchArchive> = [];
|
|
108
|
+
expect(() => {
|
|
109
|
+
hits = searchArchive(dbPath, 'ingress 401', 6);
|
|
110
|
+
}).not.toThrow();
|
|
111
|
+
expect(hits.length).toBe(1);
|
|
112
|
+
expect(hits[0].path).toBe('memory/x.md');
|
|
113
|
+
expect(hits[0].entryHash).toBeNull();
|
|
114
|
+
|
|
115
|
+
// The read path healed the DB in place so subsequent writers/readers are safe.
|
|
116
|
+
expect(cols(dbPath)).toContain('entry_hash');
|
|
117
|
+
} finally {
|
|
118
|
+
fs.rmSync(agentDir, { recursive: true, force: true });
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('searchArchive: missing DB → [], empty/punctuation query → []', () => {
|
|
123
|
+
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kb1-'));
|
|
124
|
+
const workspaceDir = path.join(agentDir, 'workspace');
|
|
125
|
+
fs.mkdirSync(workspaceDir, { recursive: true });
|
|
126
|
+
try {
|
|
127
|
+
expect(searchArchive(archiveDbPath(workspaceDir), 'anything', 6)).toEqual([]); // no kb.sqlite
|
|
128
|
+
const { workspaceDir: ws2, agentDir: dir2 } = mkAgentWithArchive([
|
|
129
|
+
{ id: 'm#1-1', path: 'memory/a.md', start: 1, end: 1, text: 'hello world' },
|
|
130
|
+
]);
|
|
131
|
+
expect(searchArchive(archiveDbPath(ws2), ' !!! ', 6)).toEqual([]); // no usable tokens
|
|
132
|
+
fs.rmSync(dir2, { recursive: true, force: true });
|
|
133
|
+
} finally {
|
|
134
|
+
fs.rmSync(agentDir, { recursive: true, force: true });
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('getExcerpt: bounded line range; out-of-scope/missing → null', () => {
|
|
139
|
+
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kb1-'));
|
|
140
|
+
const workspaceDir = path.join(agentDir, 'workspace');
|
|
141
|
+
fs.mkdirSync(path.join(workspaceDir, 'memory'), { recursive: true });
|
|
142
|
+
fs.writeFileSync(path.join(workspaceDir, 'memory/note.md'), 'l1\nl2\nl3\nl4\nl5');
|
|
143
|
+
try {
|
|
144
|
+
const ex = getExcerpt(workspaceDir, 'memory/note.md', 2, 2);
|
|
145
|
+
expect(ex).not.toBeNull();
|
|
146
|
+
expect(ex!.text).toBe('l2\nl3');
|
|
147
|
+
expect(ex!.from).toBe(2);
|
|
148
|
+
expect(ex!.to).toBe(3);
|
|
149
|
+
expect(ex!.totalLines).toBe(5);
|
|
150
|
+
expect(ex!.truncated).toBe(true);
|
|
151
|
+
|
|
152
|
+
// Whole file → not truncated.
|
|
153
|
+
expect(getExcerpt(workspaceDir, 'memory/note.md', 1, 100)!.truncated).toBe(false);
|
|
154
|
+
// Out-of-scope path and missing file → null.
|
|
155
|
+
expect(getExcerpt(workspaceDir, '../secret.md', 1, 5)).toBeNull();
|
|
156
|
+
expect(getExcerpt(workspaceDir, 'memory/missing.md', 1, 5)).toBeNull();
|
|
157
|
+
} finally {
|
|
158
|
+
fs.rmSync(agentDir, { recursive: true, force: true });
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('sharedDbPathFromEnv: null when unset, <dir>/kb.sqlite when set (K3 corpus:shared)', () => {
|
|
163
|
+
const prev = process.env.GATEWAY_SHARED_KB_DIR;
|
|
164
|
+
try {
|
|
165
|
+
delete process.env.GATEWAY_SHARED_KB_DIR;
|
|
166
|
+
expect(sharedDbPathFromEnv()).toBeNull();
|
|
167
|
+
process.env.GATEWAY_SHARED_KB_DIR = ' ';
|
|
168
|
+
expect(sharedDbPathFromEnv()).toBeNull(); // blank → disabled
|
|
169
|
+
process.env.GATEWAY_SHARED_KB_DIR = '/tmp/shared/kb/global';
|
|
170
|
+
expect(sharedDbPathFromEnv()).toBe('/tmp/shared/kb/global/kb.sqlite');
|
|
171
|
+
} finally {
|
|
172
|
+
if (prev === undefined) delete process.env.GATEWAY_SHARED_KB_DIR;
|
|
173
|
+
else process.env.GATEWAY_SHARED_KB_DIR = prev;
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('mergeHits: round-robin interleave (bm25 not comparable across corpora), capped', () => {
|
|
178
|
+
const mk = (path: string, score: number) => ({
|
|
179
|
+
path, startLine: 1, endLine: 1, score, snippet: path, originClass: 'agent', importance: null,
|
|
180
|
+
});
|
|
181
|
+
// Each list is already best-first within its own corpus. A cross-corpus numeric
|
|
182
|
+
// sort would mix incomparable bm25 scales; interleave a,c,b instead (personal
|
|
183
|
+
// first each round), capped at the limit.
|
|
184
|
+
const merged = mergeHits([mk('a', -0.9), mk('b', -0.1)], [mk('c', -0.5), mk('d', -0.05)], 3);
|
|
185
|
+
expect(merged.map((h) => h.path)).toEqual(['a', 'c', 'b']);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('mergeHits: interleave preserves per-corpus order even when scales differ', () => {
|
|
189
|
+
const mk = (path: string, score: number) => ({
|
|
190
|
+
path, startLine: 1, endLine: 1, score, snippet: path, originClass: 'agent', importance: null,
|
|
191
|
+
});
|
|
192
|
+
// Shared scores numerically "better" (more negative) must NOT starve the personal
|
|
193
|
+
// corpus — round-robin gives p1, s1, p2, s2.
|
|
194
|
+
const merged = mergeHits([mk('p1', -0.2), mk('p2', -0.05)], [mk('s1', -9), mk('s2', -8)], 4);
|
|
195
|
+
expect(merged.map((h) => h.path)).toEqual(['p1', 's1', 'p2', 's2']);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('getExcerpt: a symlink inside memory/ escaping the workspace is refused', () => {
|
|
199
|
+
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kb1-'));
|
|
200
|
+
const workspaceDir = path.join(agentDir, 'workspace');
|
|
201
|
+
fs.mkdirSync(path.join(workspaceDir, 'memory'), { recursive: true });
|
|
202
|
+
// A secret target OUTSIDE the workspace, and a memory/ symlink pointing at it.
|
|
203
|
+
const secret = path.join(agentDir, 'outside-secret.md');
|
|
204
|
+
fs.writeFileSync(secret, 'TOPSECRET outside the workspace');
|
|
205
|
+
fs.symlinkSync(secret, path.join(workspaceDir, 'memory', 'evil.md'));
|
|
206
|
+
try {
|
|
207
|
+
// Passes the string scope check (memory/*.md) but realpath escapes the ws → null.
|
|
208
|
+
expect(getExcerpt(workspaceDir, 'memory/evil.md', 1, 5)).toBeNull();
|
|
209
|
+
} finally {
|
|
210
|
+
fs.rmSync(agentDir, { recursive: true, force: true });
|
|
211
|
+
}
|
|
212
|
+
});
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only reader for the per-agent knowledge archive (planning-64 K1).
|
|
3
|
+
*
|
|
4
|
+
* The archive `kb.sqlite` is BUILT by src/ under Node via `node:sqlite`
|
|
5
|
+
* (src/agent/knowledge). These MCP tools run under **Bun**, which has no
|
|
6
|
+
* `node:sqlite`, so the read path here uses **`bun:sqlite`** instead. Same
|
|
7
|
+
* on-disk SQLite/FTS5 file — verified that node:sqlite writes and bun:sqlite
|
|
8
|
+
* reads it back via `bm25()`. This is a deliberate, read-only mirror of the
|
|
9
|
+
* query half of `src/agent/knowledge/archive-db.ts`; keep the two in sync.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Database } from 'bun:sqlite';
|
|
13
|
+
import * as fs from 'fs';
|
|
14
|
+
import * as path from 'path';
|
|
15
|
+
import { recordRetrievalHits } from './retrieval-recorder';
|
|
16
|
+
|
|
17
|
+
export interface SearchHit {
|
|
18
|
+
path: string;
|
|
19
|
+
startLine: number;
|
|
20
|
+
endLine: number;
|
|
21
|
+
score: number; // bm25 (lower = more relevant); surfaced for transparency
|
|
22
|
+
snippet: string;
|
|
23
|
+
originClass: string | null;
|
|
24
|
+
importance: number | null;
|
|
25
|
+
/** Stable archive-entry identity (planning-66); null outside any entry block. */
|
|
26
|
+
entryHash: string | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Archive DB path for an agent: sibling of history.db at agents/<id>/kb.sqlite. */
|
|
30
|
+
export function archiveDbPath(workspaceDir: string): string {
|
|
31
|
+
return path.join(path.dirname(workspaceDir), 'kb.sqlite');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Shared, cross-agent vault DB path from the gateway-provided env
|
|
36
|
+
* (`GATEWAY_SHARED_KB_DIR` = the resolved <root>/<project> dir), or null when
|
|
37
|
+
* shared KB is disabled / not configured (planning-64 K3).
|
|
38
|
+
*/
|
|
39
|
+
export function sharedDbPathFromEnv(): string | null {
|
|
40
|
+
const dir = process.env.GATEWAY_SHARED_KB_DIR;
|
|
41
|
+
if (!dir || !dir.trim()) return null;
|
|
42
|
+
return path.join(dir, 'kb.sqlite');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Merge hit lists (per-agent + shared), keeping the best `limit`. bm25 scores are
|
|
47
|
+
* corpus-relative — the value depends on each index's term/document statistics —
|
|
48
|
+
* so scores from two independent FTS indexes are NOT on a comparable scale and a
|
|
49
|
+
* global sort can spuriously rank one corpus over the other. Each list is already
|
|
50
|
+
* sorted best-first within its own corpus, so interleave them round-robin for a
|
|
51
|
+
* fair blend instead of a cross-corpus numeric sort.
|
|
52
|
+
*/
|
|
53
|
+
export function mergeHits<T extends SearchHit>(a: T[], b: T[], limit: number): T[] {
|
|
54
|
+
const out: T[] = [];
|
|
55
|
+
const max = Math.max(a.length, b.length);
|
|
56
|
+
for (let i = 0; i < max && out.length < limit; i++) {
|
|
57
|
+
if (i < a.length) out.push(a[i]);
|
|
58
|
+
if (out.length < limit && i < b.length) out.push(b[i]);
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Build an FTS5 MATCH string: quote each token so punctuation can't inject syntax. */
|
|
64
|
+
function toFtsMatch(query: string): string {
|
|
65
|
+
return query
|
|
66
|
+
.split(/[^\p{L}\p{N}_]+/u)
|
|
67
|
+
.filter(Boolean)
|
|
68
|
+
.map((t) => `"${t.replace(/"/g, '""')}"`)
|
|
69
|
+
.join(' ');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Keyword search over the archive (FTS5 BM25). Returns [] when the index does
|
|
74
|
+
* not exist yet or the query has no usable tokens. Read-only; never creates a DB.
|
|
75
|
+
*
|
|
76
|
+
* When `recordRetrievals` is set (planning-66), each returned entry's stable
|
|
77
|
+
* `entry_hash` is appended to `kb_retrieval_log` through a SEPARATE, dedicated
|
|
78
|
+
* writable handle AFTER the results are built — append-only and fire-and-forget,
|
|
79
|
+
* so the read path never read-modify-writes chunk rows and never blocks/locks the
|
|
80
|
+
* search response. The Node side folds this log into per-entry recency at GC time.
|
|
81
|
+
*/
|
|
82
|
+
/** True when kb_chunks carries the planning-66 `entry_hash` column. */
|
|
83
|
+
function hasEntryHashColumn(db: Database): boolean {
|
|
84
|
+
const cols = db.query(`PRAGMA table_info(kb_chunks)`).all() as Array<{ name: string }>;
|
|
85
|
+
return cols.some((c) => c.name === 'entry_hash');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Idempotent, best-effort heal for a pre-#346 archive DB. The `entry_hash`
|
|
90
|
+
* column is only added by the writer (`ArchiveDb` constructor migration); a
|
|
91
|
+
* `kb.sqlite` that has not been reindexed since #346 still lacks it, so the
|
|
92
|
+
* read-only search query below crashes with `no such column: entry_hash`. Add
|
|
93
|
+
* the column in place via a short writable handle (mirroring the recall-logging
|
|
94
|
+
* handle further down). A read-only filesystem simply skips — the column-aware
|
|
95
|
+
* query then falls back to a NULL `entryHash` so search still returns hits.
|
|
96
|
+
*/
|
|
97
|
+
function ensureEntryHashColumn(dbPath: string): void {
|
|
98
|
+
try {
|
|
99
|
+
const w = new Database(dbPath);
|
|
100
|
+
try {
|
|
101
|
+
w.exec('PRAGMA busy_timeout=2000');
|
|
102
|
+
if (!hasEntryHashColumn(w)) {
|
|
103
|
+
w.exec(`ALTER TABLE kb_chunks ADD COLUMN entry_hash TEXT`);
|
|
104
|
+
w.exec(`CREATE INDEX IF NOT EXISTS idx_kb_chunks_entry_hash ON kb_chunks(entry_hash)`);
|
|
105
|
+
}
|
|
106
|
+
} finally {
|
|
107
|
+
w.close();
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
/* read-only / locked DB — the column-aware query below degrades gracefully */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function searchArchive(
|
|
115
|
+
dbPath: string,
|
|
116
|
+
query: string,
|
|
117
|
+
maxResults: number,
|
|
118
|
+
opts?: { recordRetrievals?: boolean; now?: number },
|
|
119
|
+
): SearchHit[] {
|
|
120
|
+
if (!fs.existsSync(dbPath)) return [];
|
|
121
|
+
const match = toFtsMatch(query);
|
|
122
|
+
if (!match) return [];
|
|
123
|
+
|
|
124
|
+
// Heal pre-#346 DBs in place so the query never crashes on a missing column.
|
|
125
|
+
ensureEntryHashColumn(dbPath);
|
|
126
|
+
|
|
127
|
+
const db = new Database(dbPath, { readonly: true });
|
|
128
|
+
// If the heal above could not run (read-only FS), select a NULL entry_hash
|
|
129
|
+
// rather than referencing a column that does not exist.
|
|
130
|
+
const entryHashSelect = hasEntryHashColumn(db) ? 'c.entry_hash' : 'NULL';
|
|
131
|
+
let hits: SearchHit[];
|
|
132
|
+
try {
|
|
133
|
+
const rows = db
|
|
134
|
+
.query(
|
|
135
|
+
`SELECT c.id AS id, c.path AS path, c.start_line AS startLine, c.end_line AS endLine,
|
|
136
|
+
c.text AS text, ${entryHashSelect} AS entryHash, bm25(kb_chunks_fts) AS score,
|
|
137
|
+
p.origin_class AS originClass, r.importance AS importance
|
|
138
|
+
FROM kb_chunks_fts f
|
|
139
|
+
JOIN kb_chunks c ON c.rowid_id = f.rowid
|
|
140
|
+
LEFT JOIN kb_chunk_provenance p ON p.chunk_id = c.id
|
|
141
|
+
LEFT JOIN kb_chunk_recall r ON r.chunk_id = c.id
|
|
142
|
+
WHERE kb_chunks_fts MATCH ?
|
|
143
|
+
ORDER BY bm25(kb_chunks_fts)
|
|
144
|
+
LIMIT ?`,
|
|
145
|
+
)
|
|
146
|
+
.all(match, maxResults) as Array<{
|
|
147
|
+
path: string;
|
|
148
|
+
startLine: number;
|
|
149
|
+
endLine: number;
|
|
150
|
+
text: string;
|
|
151
|
+
entryHash: string | null;
|
|
152
|
+
score: number;
|
|
153
|
+
originClass: string | null;
|
|
154
|
+
importance: number | null;
|
|
155
|
+
}>;
|
|
156
|
+
hits = rows.map((r) => ({
|
|
157
|
+
path: r.path,
|
|
158
|
+
startLine: r.startLine,
|
|
159
|
+
endLine: r.endLine,
|
|
160
|
+
score: r.score,
|
|
161
|
+
snippet: r.text.length > 500 ? `${r.text.slice(0, 500)}…` : r.text,
|
|
162
|
+
originClass: r.originClass,
|
|
163
|
+
importance: r.importance,
|
|
164
|
+
entryHash: r.entryHash,
|
|
165
|
+
}));
|
|
166
|
+
} finally {
|
|
167
|
+
db.close();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Recall counter (planning-66): fire-and-forget, append-only, best-effort. A
|
|
171
|
+
// dedicated writable handle (WAL + busy_timeout so it never fails a concurrent
|
|
172
|
+
// reindex) logs the retrieved entries AFTER the read handle is closed. Any error
|
|
173
|
+
// is swallowed inside recordRetrievalHits — telemetry never fails a search.
|
|
174
|
+
if (opts?.recordRetrievals && hits.length > 0) {
|
|
175
|
+
try {
|
|
176
|
+
const w = new Database(dbPath);
|
|
177
|
+
try {
|
|
178
|
+
w.exec('PRAGMA busy_timeout=2000');
|
|
179
|
+
recordRetrievalHits(
|
|
180
|
+
{ run: (sql, params = []) => void w.query(sql).run(...(params as never[])) },
|
|
181
|
+
hits.map((h) => h.entryHash),
|
|
182
|
+
opts.now ?? Date.now(),
|
|
183
|
+
);
|
|
184
|
+
} finally {
|
|
185
|
+
w.close();
|
|
186
|
+
}
|
|
187
|
+
} catch {
|
|
188
|
+
/* best-effort telemetry — never affect the search response */
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return hits;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** A memory-scoped path: MEMORY.md, USER.md, or memory/**.md — no traversal. */
|
|
195
|
+
export function isMemoryScopedPath(relPath: string): boolean {
|
|
196
|
+
const p = relPath.replace(/\\/g, '/');
|
|
197
|
+
if (p.includes('..') || path.isAbsolute(p)) return false;
|
|
198
|
+
if (p === 'MEMORY.md' || p === 'USER.md') return true;
|
|
199
|
+
return p.startsWith('memory/') && p.endsWith('.md');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export interface Excerpt {
|
|
203
|
+
path: string;
|
|
204
|
+
from: number;
|
|
205
|
+
to: number;
|
|
206
|
+
text: string;
|
|
207
|
+
totalLines: number;
|
|
208
|
+
truncated: boolean;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Bounded exact excerpt of a memory file by line range (1-indexed). Reads the
|
|
213
|
+
* file directly (it is the source of truth). Returns null for an out-of-scope
|
|
214
|
+
* path or a missing file.
|
|
215
|
+
*/
|
|
216
|
+
export function getExcerpt(
|
|
217
|
+
workspaceDir: string,
|
|
218
|
+
relPath: string,
|
|
219
|
+
from = 1,
|
|
220
|
+
lines = 200,
|
|
221
|
+
): Excerpt | null {
|
|
222
|
+
// Validate and read the SAME normalized path so the check can't diverge from
|
|
223
|
+
// what is actually opened.
|
|
224
|
+
const rel = relPath.replace(/\\/g, '/');
|
|
225
|
+
if (!isMemoryScopedPath(rel)) return null;
|
|
226
|
+
const abs = path.join(workspaceDir, rel);
|
|
227
|
+
if (!fs.existsSync(abs)) return null;
|
|
228
|
+
// Containment against symlink escape: a memory/x.md symlink pointing outside
|
|
229
|
+
// the workspace passes the string check but must not be readable through the
|
|
230
|
+
// tool. Resolve real paths and require the target stays under the workspace.
|
|
231
|
+
try {
|
|
232
|
+
const realBase = fs.realpathSync(workspaceDir);
|
|
233
|
+
const realTarget = fs.realpathSync(abs);
|
|
234
|
+
if (realTarget !== realBase && !realTarget.startsWith(realBase + path.sep)) return null;
|
|
235
|
+
} catch {
|
|
236
|
+
return null; // realpath failed (broken symlink, race) → refuse
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const all = fs.readFileSync(abs, 'utf8').split('\n');
|
|
240
|
+
const total = all.length;
|
|
241
|
+
const start = Math.max(1, Math.floor(from));
|
|
242
|
+
const count = Math.max(1, Math.floor(lines));
|
|
243
|
+
const end = Math.min(total, start + count - 1);
|
|
244
|
+
const slice = start > total ? [] : all.slice(start - 1, end);
|
|
245
|
+
return {
|
|
246
|
+
path: relPath,
|
|
247
|
+
from: start,
|
|
248
|
+
to: start > total ? start : end,
|
|
249
|
+
text: slice.join('\n'),
|
|
250
|
+
totalLines: total,
|
|
251
|
+
truncated: end < total,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory tool module (planning-64 K1) — implements ToolModule.
|
|
3
|
+
*
|
|
4
|
+
* Read-only, on-demand retrieval over the per-agent knowledge archive
|
|
5
|
+
* (`kb.sqlite`, built by src/ under Node). Two lanes of memory: the small core
|
|
6
|
+
* (MEMORY.md/USER.md) is injected every turn; everything else lives in the
|
|
7
|
+
* searchable archive and is reached here via `memory_search` / `memory_get` —
|
|
8
|
+
* so the bulk never has to sit in the prompt. Runs under Bun; reads via
|
|
9
|
+
* `bun:sqlite` (see archive-reader.ts).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ToolModule, McpToolDefinition, McpToolResult, ToolVisibility } from '../../types';
|
|
13
|
+
import { searchArchive, getExcerpt, archiveDbPath, sharedDbPathFromEnv, mergeHits } from './archive-reader';
|
|
14
|
+
|
|
15
|
+
/** Corpora the tool can serve. */
|
|
16
|
+
const SUPPORTED_CORPORA = new Set(['memory', 'shared', 'all']);
|
|
17
|
+
|
|
18
|
+
export class MemoryModule implements ToolModule {
|
|
19
|
+
id = 'memory';
|
|
20
|
+
toolVisibility: ToolVisibility = 'all-configured';
|
|
21
|
+
|
|
22
|
+
isEnabled(): boolean {
|
|
23
|
+
// Available whenever the agent's workspace dir is known (same gate as skills).
|
|
24
|
+
return Boolean(process.env.GATEWAY_WORKSPACE_DIR);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
getTools(): McpToolDefinition[] {
|
|
28
|
+
return [
|
|
29
|
+
{
|
|
30
|
+
name: 'memory_search',
|
|
31
|
+
description:
|
|
32
|
+
'Search your long-term memory archive (past notes under memory/, plus MEMORY.md/USER.md) by keyword and get the most relevant snippets with their file + line range. Use this to recall details that are NOT in the memory currently loaded into your context — the archive holds far more than fits in the prompt. Read-only.',
|
|
33
|
+
inputSchema: {
|
|
34
|
+
type: 'object',
|
|
35
|
+
properties: {
|
|
36
|
+
query: { type: 'string', description: 'Keywords to search for.' },
|
|
37
|
+
maxResults: {
|
|
38
|
+
type: 'number',
|
|
39
|
+
description: 'Max snippets to return (default 6, max 20).',
|
|
40
|
+
},
|
|
41
|
+
corpus: {
|
|
42
|
+
type: 'string',
|
|
43
|
+
enum: ['memory', 'shared', 'all'],
|
|
44
|
+
description:
|
|
45
|
+
'Which corpus to search: "memory" (this agent, default), "shared" (the cross-agent shared KB), or "all" (both).',
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
required: ['query'],
|
|
49
|
+
additionalProperties: false,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'memory_get',
|
|
54
|
+
description:
|
|
55
|
+
'Read an exact line range from one of your memory files (a path returned by memory_search, e.g. "memory/foo.md", or "MEMORY.md"/"USER.md"). Use after memory_search to pull the full context around a snippet. Read-only.',
|
|
56
|
+
inputSchema: {
|
|
57
|
+
type: 'object',
|
|
58
|
+
properties: {
|
|
59
|
+
path: { type: 'string', description: 'Memory-scoped path (memory/*.md, MEMORY.md, USER.md).' },
|
|
60
|
+
from: { type: 'number', description: '1-indexed start line (default 1).' },
|
|
61
|
+
lines: { type: 'number', description: 'Number of lines to return (default 200).' },
|
|
62
|
+
},
|
|
63
|
+
required: ['path'],
|
|
64
|
+
additionalProperties: false,
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async handleTool(name: string, args: Record<string, unknown>): Promise<McpToolResult> {
|
|
71
|
+
const workspaceDir = process.env.GATEWAY_WORKSPACE_DIR;
|
|
72
|
+
if (!workspaceDir) {
|
|
73
|
+
return this.err('Memory archive unavailable: no workspace directory configured.');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
switch (name) {
|
|
78
|
+
case 'memory_search': {
|
|
79
|
+
const query = typeof args.query === 'string' ? args.query.trim() : '';
|
|
80
|
+
if (!query) return this.err('memory_search requires a non-empty "query".');
|
|
81
|
+
|
|
82
|
+
// Fail closed on an unsupported corpus rather than silently widening scope.
|
|
83
|
+
const corpus = typeof args.corpus === 'string' ? args.corpus : 'memory';
|
|
84
|
+
if (!SUPPORTED_CORPORA.has(corpus)) {
|
|
85
|
+
return this.json({
|
|
86
|
+
results: [],
|
|
87
|
+
unavailable: true,
|
|
88
|
+
warning: `corpus "${corpus}" is not available yet (shared/all arrive in a later phase). Use "memory".`,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const rawMax = Number.isFinite(args.maxResults as number) ? (args.maxResults as number) : 6;
|
|
93
|
+
const maxResults = Math.max(1, Math.min(20, Math.floor(rawMax)));
|
|
94
|
+
|
|
95
|
+
const personalDb = archiveDbPath(workspaceDir);
|
|
96
|
+
const sharedDb = sharedDbPathFromEnv();
|
|
97
|
+
// Recall counter (planning-66): record retrievals ONLY against the
|
|
98
|
+
// per-agent archive (the GC tier). Gated by the gateway via env, mirroring
|
|
99
|
+
// GATEWAY_SHARED_KB_DIR — off unless dreaming.staleness.recordRetrievals.
|
|
100
|
+
const rec = { recordRetrievals: process.env.GATEWAY_RECORD_RETRIEVALS === '1' };
|
|
101
|
+
let results;
|
|
102
|
+
if (corpus === 'memory') {
|
|
103
|
+
results = searchArchive(personalDb, query, maxResults, rec).map((h) => ({ ...h, corpus: 'memory' }));
|
|
104
|
+
} else if (corpus === 'shared') {
|
|
105
|
+
if (!sharedDb) return this.json({ results: [], unavailable: true, warning: 'shared KB is not enabled.' });
|
|
106
|
+
results = searchArchive(sharedDb, query, maxResults).map((h) => ({ ...h, corpus: 'shared' }));
|
|
107
|
+
} else {
|
|
108
|
+
// "all": merge personal + shared, keep the best by bm25.
|
|
109
|
+
type Tagged = ReturnType<typeof searchArchive>[number] & { corpus: string };
|
|
110
|
+
const mine: Tagged[] = searchArchive(personalDb, query, maxResults, rec).map((h) => ({ ...h, corpus: 'memory' }));
|
|
111
|
+
const shared: Tagged[] = sharedDb
|
|
112
|
+
? searchArchive(sharedDb, query, maxResults).map((h) => ({ ...h, corpus: 'shared' }))
|
|
113
|
+
: [];
|
|
114
|
+
results = mergeHits(mine, shared, maxResults);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (results.length === 0) {
|
|
118
|
+
return this.json({
|
|
119
|
+
results: [],
|
|
120
|
+
note: 'No matches (or the archive has not been indexed yet).',
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return this.json({ results, corpus });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
case 'memory_get': {
|
|
127
|
+
const p = typeof args.path === 'string' ? args.path : '';
|
|
128
|
+
const from = Number.isFinite(args.from as number) ? (args.from as number) : 1;
|
|
129
|
+
const lines = Number.isFinite(args.lines as number) ? (args.lines as number) : 200;
|
|
130
|
+
const excerpt = getExcerpt(workspaceDir, p, from, lines);
|
|
131
|
+
if (!excerpt) {
|
|
132
|
+
return this.err(`memory_get: "${p}" is not a readable memory-scoped file (memory/*.md, MEMORY.md, USER.md).`);
|
|
133
|
+
}
|
|
134
|
+
return this.json(excerpt);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
default:
|
|
138
|
+
return this.err(`Unknown memory tool: ${name}`);
|
|
139
|
+
}
|
|
140
|
+
} catch (e) {
|
|
141
|
+
return this.err(`memory tool error: ${e instanceof Error ? e.message : String(e)}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private json(obj: unknown): McpToolResult {
|
|
146
|
+
return { content: [{ type: 'text', text: JSON.stringify(obj, null, 2) }] };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private err(text: string): McpToolResult {
|
|
150
|
+
return { content: [{ type: 'text', text }], isError: true };
|
|
151
|
+
}
|
|
152
|
+
}
|