@0xmaxma/claude-gateway 1.7.9 → 1.8.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 +178 -44
- package/dist/agent/builtin-commands.js +2 -2
- package/dist/agent/builtin-commands.js.map +1 -1
- package/dist/agent/dreaming/accept.d.ts.map +1 -1
- package/dist/agent/dreaming/accept.js +4 -1
- package/dist/agent/dreaming/accept.js.map +1 -1
- package/dist/agent/dreaming/config.d.ts.map +1 -1
- package/dist/agent/dreaming/config.js +2 -0
- package/dist/agent/dreaming/config.js.map +1 -1
- package/dist/agent/dreaming/reviewer.d.ts.map +1 -1
- package/dist/agent/dreaming/reviewer.js +9 -2
- package/dist/agent/dreaming/reviewer.js.map +1 -1
- package/dist/agent/dreaming/staleness.d.ts +7 -1
- package/dist/agent/dreaming/staleness.d.ts.map +1 -1
- package/dist/agent/dreaming/staleness.js +17 -2
- package/dist/agent/dreaming/staleness.js.map +1 -1
- package/dist/agent/dreaming/types.d.ts +17 -1
- package/dist/agent/dreaming/types.d.ts.map +1 -1
- package/dist/agent/knowledge/archive-db.d.ts +43 -0
- package/dist/agent/knowledge/archive-db.d.ts.map +1 -1
- package/dist/agent/knowledge/archive-db.js +130 -0
- package/dist/agent/knowledge/archive-db.js.map +1 -1
- package/dist/agent/knowledge/config.d.ts +3 -1
- package/dist/agent/knowledge/config.d.ts.map +1 -1
- package/dist/agent/knowledge/config.js +32 -10
- package/dist/agent/knowledge/config.js.map +1 -1
- package/dist/agent/knowledge/index.d.ts +11 -4
- package/dist/agent/knowledge/index.d.ts.map +1 -1
- package/dist/agent/knowledge/index.js +22 -1
- package/dist/agent/knowledge/index.js.map +1 -1
- package/dist/agent/knowledge/indexer.d.ts +0 -7
- package/dist/agent/knowledge/indexer.d.ts.map +1 -1
- package/dist/agent/knowledge/indexer.js +34 -7
- package/dist/agent/knowledge/indexer.js.map +1 -1
- package/dist/agent/knowledge/lifecycle.d.ts +20 -4
- package/dist/agent/knowledge/lifecycle.d.ts.map +1 -1
- package/dist/agent/knowledge/lifecycle.js +33 -4
- package/dist/agent/knowledge/lifecycle.js.map +1 -1
- package/dist/agent/knowledge/reflection.d.ts +114 -0
- package/dist/agent/knowledge/reflection.d.ts.map +1 -0
- package/dist/agent/knowledge/reflection.js +447 -0
- package/dist/agent/knowledge/reflection.js.map +1 -0
- package/dist/agent/knowledge/shared-dedup.d.ts +85 -0
- package/dist/agent/knowledge/shared-dedup.d.ts.map +1 -0
- package/dist/agent/knowledge/shared-dedup.js +187 -0
- package/dist/agent/knowledge/shared-dedup.js.map +1 -0
- package/dist/agent/knowledge/shared-promote.d.ts +82 -7
- package/dist/agent/knowledge/shared-promote.d.ts.map +1 -1
- package/dist/agent/knowledge/shared-promote.js +244 -11
- package/dist/agent/knowledge/shared-promote.js.map +1 -1
- package/dist/agent/knowledge/shared-staleness.d.ts +51 -0
- package/dist/agent/knowledge/shared-staleness.d.ts.map +1 -0
- package/dist/agent/knowledge/shared-staleness.js +149 -0
- package/dist/agent/knowledge/shared-staleness.js.map +1 -0
- package/dist/agent/knowledge/shared-writer.d.ts +19 -0
- package/dist/agent/knowledge/shared-writer.d.ts.map +1 -1
- package/dist/agent/knowledge/shared-writer.js +42 -0
- package/dist/agent/knowledge/shared-writer.js.map +1 -1
- package/dist/agent/knowledge/types.d.ts +24 -0
- package/dist/agent/knowledge/types.d.ts.map +1 -1
- package/dist/agent/model-catalog.d.ts +62 -0
- package/dist/agent/model-catalog.d.ts.map +1 -0
- package/dist/agent/model-catalog.js +264 -0
- package/dist/agent/model-catalog.js.map +1 -0
- package/dist/agent/runner.d.ts +83 -0
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +281 -51
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/workspace-loader.d.ts +17 -2
- package/dist/agent/workspace-loader.d.ts.map +1 -1
- package/dist/agent/workspace-loader.js +17 -1
- package/dist/agent/workspace-loader.js.map +1 -1
- package/dist/api/cron-router.d.ts +12 -8
- package/dist/api/cron-router.d.ts.map +1 -1
- package/dist/api/cron-router.js +82 -16
- package/dist/api/cron-router.js.map +1 -1
- package/dist/api/gateway-router.d.ts +4 -0
- package/dist/api/gateway-router.d.ts.map +1 -1
- package/dist/api/gateway-router.js +16 -1
- package/dist/api/gateway-router.js.map +1 -1
- package/dist/api/manifest-sources.d.ts +15 -0
- package/dist/api/manifest-sources.d.ts.map +1 -0
- package/dist/api/manifest-sources.js +27 -0
- package/dist/api/manifest-sources.js.map +1 -0
- package/dist/api/meta-router.d.ts +13 -0
- package/dist/api/meta-router.d.ts.map +1 -0
- package/dist/api/meta-router.js +26 -0
- package/dist/api/meta-router.js.map +1 -0
- package/dist/api/packages.d.ts.map +1 -1
- package/dist/api/packages.js +18 -198
- package/dist/api/packages.js.map +1 -1
- package/dist/api/route-registry.d.ts +81 -0
- package/dist/api/route-registry.d.ts.map +1 -0
- package/dist/api/route-registry.js +44 -0
- package/dist/api/route-registry.js.map +1 -0
- package/dist/api/router.d.ts.map +1 -1
- package/dist/api/router.js +21 -3
- package/dist/api/router.js.map +1 -1
- package/dist/apps/compose-generator.js +7 -4
- package/dist/apps/compose-generator.js.map +1 -1
- package/dist/apps/installer.d.ts +154 -0
- package/dist/apps/installer.d.ts.map +1 -1
- package/dist/apps/installer.js +657 -54
- package/dist/apps/installer.js.map +1 -1
- package/dist/cli/args.d.ts +25 -0
- package/dist/cli/args.d.ts.map +1 -0
- package/dist/cli/args.js +55 -0
- package/dist/cli/args.js.map +1 -0
- package/dist/cli/colors.d.ts +37 -0
- package/dist/cli/colors.d.ts.map +1 -0
- package/dist/cli/colors.js +63 -0
- package/dist/cli/colors.js.map +1 -0
- package/dist/cli/command-names.d.ts +98 -0
- package/dist/cli/command-names.d.ts.map +1 -0
- package/dist/cli/command-names.js +109 -0
- package/dist/cli/command-names.js.map +1 -0
- package/dist/cli/commands/agents.d.ts +28 -0
- package/dist/cli/commands/agents.d.ts.map +1 -0
- package/dist/cli/commands/agents.js +321 -0
- package/dist/cli/commands/agents.js.map +1 -0
- package/dist/cli/commands/channels.d.ts +17 -0
- package/dist/cli/commands/channels.d.ts.map +1 -0
- package/dist/cli/commands/channels.js +101 -0
- package/dist/cli/commands/channels.js.map +1 -0
- package/dist/cli/commands/debug-bundle.d.ts +6 -0
- package/dist/cli/commands/debug-bundle.d.ts.map +1 -0
- package/dist/cli/commands/debug-bundle.js +220 -0
- package/dist/cli/commands/debug-bundle.js.map +1 -0
- package/dist/cli/commands/doctor.d.ts +3 -0
- package/dist/cli/commands/doctor.d.ts.map +1 -0
- package/dist/cli/commands/doctor.js +104 -0
- package/dist/cli/commands/doctor.js.map +1 -0
- package/dist/cli/commands/gateway.d.ts +12 -0
- package/dist/cli/commands/gateway.d.ts.map +1 -0
- package/dist/cli/commands/gateway.js +145 -0
- package/dist/cli/commands/gateway.js.map +1 -0
- package/dist/cli/commands/service.d.ts +33 -0
- package/dist/cli/commands/service.d.ts.map +1 -0
- package/dist/cli/commands/service.js +513 -0
- package/dist/cli/commands/service.js.map +1 -0
- package/dist/cli/commands/update.d.ts +16 -0
- package/dist/cli/commands/update.d.ts.map +1 -0
- package/dist/cli/commands/update.js +149 -0
- package/dist/cli/commands/update.js.map +1 -0
- package/dist/cli/commands.generated.d.ts +4 -0
- package/dist/cli/commands.generated.d.ts.map +1 -0
- package/dist/cli/commands.generated.js +220 -0
- package/dist/cli/commands.generated.js.map +1 -0
- package/dist/cli/health.d.ts +31 -0
- package/dist/cli/health.d.ts.map +1 -0
- package/dist/cli/health.js +49 -0
- package/dist/cli/health.js.map +1 -0
- package/dist/cli/http-client.d.ts +140 -0
- package/dist/cli/http-client.d.ts.map +1 -0
- package/dist/cli/http-client.js +332 -0
- package/dist/cli/http-client.js.map +1 -0
- package/dist/cli/index.d.ts +27 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +372 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/manager.d.ts +64 -0
- package/dist/cli/manager.d.ts.map +1 -0
- package/dist/cli/manager.js +181 -0
- package/dist/cli/manager.js.map +1 -0
- package/dist/cli/output.d.ts +49 -0
- package/dist/cli/output.d.ts.map +1 -0
- package/dist/cli/output.js +101 -0
- package/dist/cli/output.js.map +1 -0
- package/dist/cli/prompt.d.ts +29 -0
- package/dist/cli/prompt.d.ts.map +1 -0
- package/dist/cli/prompt.js +154 -0
- package/dist/cli/prompt.js.map +1 -0
- package/dist/cli/redact.d.ts +3 -0
- package/dist/cli/redact.d.ts.map +1 -0
- package/dist/cli/redact.js +33 -0
- package/dist/cli/redact.js.map +1 -0
- package/dist/cli/types.d.ts +22 -0
- package/dist/cli/types.d.ts.map +1 -0
- package/dist/cli/types.js +4 -0
- package/dist/cli/types.js.map +1 -0
- package/dist/config/bootstrap.d.ts +33 -0
- package/dist/config/bootstrap.d.ts.map +1 -0
- package/dist/config/bootstrap.js +117 -0
- package/dist/config/bootstrap.js.map +1 -0
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +5 -1
- package/dist/config/loader.js.map +1 -1
- package/dist/config/watcher.d.ts.map +1 -1
- package/dist/config/watcher.js +2 -35
- package/dist/config/watcher.js.map +1 -1
- package/dist/discord/receiver.d.ts +6 -1
- package/dist/discord/receiver.d.ts.map +1 -1
- package/dist/discord/receiver.js +9 -1
- package/dist/discord/receiver.js.map +1 -1
- package/dist/entry.d.ts +3 -0
- package/dist/entry.d.ts.map +1 -0
- package/dist/entry.js +83 -0
- package/dist/entry.js.map +1 -0
- package/dist/index.js +197 -47
- package/dist/index.js.map +1 -1
- package/dist/load-dotenv.d.ts +2 -0
- package/dist/load-dotenv.d.ts.map +1 -0
- package/dist/load-dotenv.js +68 -0
- package/dist/load-dotenv.js.map +1 -0
- package/dist/packages/registry.d.ts +50 -0
- package/dist/packages/registry.d.ts.map +1 -0
- package/dist/packages/registry.js +209 -0
- package/dist/packages/registry.js.map +1 -0
- package/dist/session/process.d.ts +20 -3
- package/dist/session/process.d.ts.map +1 -1
- package/dist/session/process.js +120 -5
- package/dist/session/process.js.map +1 -1
- package/dist/shell/claude-pty-shell.js +146 -27
- package/dist/shell/claude-pty-shell.js.map +1 -1
- package/dist/shell/draft-phantom.d.ts +49 -0
- package/dist/shell/draft-phantom.d.ts.map +1 -0
- package/dist/shell/draft-phantom.js +59 -0
- package/dist/shell/draft-phantom.js.map +1 -0
- package/dist/shell/submit-diag.d.ts +67 -0
- package/dist/shell/submit-diag.d.ts.map +1 -0
- package/dist/shell/submit-diag.js +69 -0
- package/dist/shell/submit-diag.js.map +1 -0
- package/dist/shutdown-signals.d.ts +47 -0
- package/dist/shutdown-signals.d.ts.map +1 -0
- package/dist/shutdown-signals.js +62 -0
- package/dist/shutdown-signals.js.map +1 -0
- package/dist/telegram/receiver.d.ts +6 -1
- package/dist/telegram/receiver.d.ts.map +1 -1
- package/dist/telegram/receiver.js +9 -1
- package/dist/telegram/receiver.js.map +1 -1
- package/dist/types.d.ts +25 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/orphan-receivers.d.ts +90 -0
- package/dist/utils/orphan-receivers.d.ts.map +1 -0
- package/dist/utils/orphan-receivers.js +218 -0
- package/dist/utils/orphan-receivers.js.map +1 -0
- package/dist/utils/paths.d.ts +16 -0
- package/dist/utils/paths.d.ts.map +1 -0
- package/dist/utils/paths.js +60 -0
- package/dist/utils/paths.js.map +1 -0
- package/dist/utils/stop-child.d.ts +21 -0
- package/dist/utils/stop-child.d.ts.map +1 -0
- package/dist/utils/stop-child.js +64 -0
- package/dist/utils/stop-child.js.map +1 -0
- package/lib/pairing.ts +1 -1
- package/mcp/tools/agent/handlers.ts +1 -1
- package/mcp/tools/discord/module.ts +23 -15
- package/mcp/tools/discord/receiver-server.ts +4 -0
- package/mcp/tools/memory/archive-reader.test.ts +25 -1
- package/mcp/tools/memory/archive-reader.ts +70 -12
- package/mcp/tools/memory/archive-writer.test.ts +168 -0
- package/mcp/tools/memory/archive-writer.ts +137 -0
- package/mcp/tools/memory/module.test.ts +187 -0
- package/mcp/tools/memory/module.ts +192 -6
- package/mcp/tools/telegram/module.ts +10 -2
- package/mcp/tools/telegram/receiver-server.ts +15 -6
- package/mcp/tools/telegram/typing.ts +178 -34
- package/package.json +6 -4
|
@@ -13,7 +13,7 @@ import * as fs from 'fs';
|
|
|
13
13
|
import * as os from 'os';
|
|
14
14
|
import * as path from 'path';
|
|
15
15
|
|
|
16
|
-
import { searchArchive, getExcerpt, isMemoryScopedPath, archiveDbPath, sharedDbPathFromEnv, mergeHits } from './archive-reader';
|
|
16
|
+
import { searchArchive, findSimilarSharedNotes, getExcerpt, isMemoryScopedPath, archiveDbPath, sharedDbPathFromEnv, mergeHits } from './archive-reader';
|
|
17
17
|
|
|
18
18
|
// Build a temp agent dir with a kb.sqlite carrying the given chunks.
|
|
19
19
|
function mkAgentWithArchive(
|
|
@@ -210,3 +210,27 @@ test('getExcerpt: a symlink inside memory/ escaping the workspace is refused', (
|
|
|
210
210
|
fs.rmSync(agentDir, { recursive: true, force: true });
|
|
211
211
|
}
|
|
212
212
|
});
|
|
213
|
+
|
|
214
|
+
test('findSimilarSharedNotes: OR-matches on partial overlap where an AND search (searchArchive) would miss', () => {
|
|
215
|
+
const { workspaceDir } = mkAgentWithArchive([
|
|
216
|
+
{ id: 'notes/oncall-runbook.md#1-1', path: 'notes/oncall-runbook.md', start: 1, end: 1, text: 'escalate paging incidents to the platform team via pagerduty' },
|
|
217
|
+
{ id: 'notes/unrelated.md#1-1', path: 'notes/unrelated.md', start: 1, end: 1, text: 'deploy checklist for the frontend release pipeline' },
|
|
218
|
+
]);
|
|
219
|
+
const dbPath = archiveDbPath(workspaceDir);
|
|
220
|
+
// Seed text shares only "escalate" + "paging" with the runbook note, not the
|
|
221
|
+
// rest of its wording — an implicit-AND searchArchive() call would need every
|
|
222
|
+
// token to hit and returns nothing, but OR-based findSimilarSharedNotes finds it.
|
|
223
|
+
const seed = 'oncall escalation paging on-call playbook';
|
|
224
|
+
expect(searchArchive(dbPath, seed, 5).length).toBe(0);
|
|
225
|
+
const similar = findSimilarSharedNotes(dbPath, seed, 5);
|
|
226
|
+
expect(similar.length).toBe(1);
|
|
227
|
+
expect(similar[0].path).toBe('notes/oncall-runbook.md');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('findSimilarSharedNotes: no matching terms, or a missing DB, both return []', () => {
|
|
231
|
+
const { workspaceDir } = mkAgentWithArchive([
|
|
232
|
+
{ id: 'notes/oncall-runbook.md#1-1', path: 'notes/oncall-runbook.md', start: 1, end: 1, text: 'escalate paging incidents to the platform team' },
|
|
233
|
+
]);
|
|
234
|
+
expect(findSimilarSharedNotes(archiveDbPath(workspaceDir), 'completely unrelated topic zzz', 5)).toEqual([]);
|
|
235
|
+
expect(findSimilarSharedNotes('/nonexistent/kb.sqlite', 'oncall escalation', 5)).toEqual([]);
|
|
236
|
+
});
|
|
@@ -69,6 +69,22 @@ function toFtsMatch(query: string): string {
|
|
|
69
69
|
.join(' ');
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Same tokenizing/quoting as `toFtsMatch`, but joined with OR and capped to
|
|
74
|
+
* `maxTerms`. Used for "is anything like this already here?" lookups
|
|
75
|
+
* (`findSimilarSharedNotes`), where an implicit-AND match across a whole
|
|
76
|
+
* note's worth of terms would almost never hit — a near-duplicate rarely
|
|
77
|
+
* repeats every single word of the seed text.
|
|
78
|
+
*/
|
|
79
|
+
function toFtsMatchOr(query: string, maxTerms = 12): string {
|
|
80
|
+
return query
|
|
81
|
+
.split(/[^\p{L}\p{N}_]+/u)
|
|
82
|
+
.filter(Boolean)
|
|
83
|
+
.slice(0, maxTerms)
|
|
84
|
+
.map((t) => `"${t.replace(/"/g, '""')}"`)
|
|
85
|
+
.join(' OR ');
|
|
86
|
+
}
|
|
87
|
+
|
|
72
88
|
/**
|
|
73
89
|
* Keyword search over the archive (FTS5 BM25). Returns [] when the index does
|
|
74
90
|
* not exist yet or the query has no usable tokens. Read-only; never creates a DB.
|
|
@@ -111,16 +127,12 @@ function ensureEntryHashColumn(dbPath: string): void {
|
|
|
111
127
|
}
|
|
112
128
|
}
|
|
113
129
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
): SearchHit[] {
|
|
120
|
-
if (!fs.existsSync(dbPath)) return [];
|
|
121
|
-
const match = toFtsMatch(query);
|
|
122
|
-
if (!match) return [];
|
|
123
|
-
|
|
130
|
+
/**
|
|
131
|
+
* Core FTS5 query, shared by `searchArchive` (implicit-AND keyword search)
|
|
132
|
+
* and `findSimilarSharedNotes` (OR-based near-duplicate lookup) — only the
|
|
133
|
+
* MATCH expression differs between the two callers.
|
|
134
|
+
*/
|
|
135
|
+
function runFtsMatchQuery(dbPath: string, match: string, maxResults: number): SearchHit[] {
|
|
124
136
|
// Heal pre-#346 DBs in place so the query never crashes on a missing column.
|
|
125
137
|
ensureEntryHashColumn(dbPath);
|
|
126
138
|
|
|
@@ -128,7 +140,6 @@ export function searchArchive(
|
|
|
128
140
|
// If the heal above could not run (read-only FS), select a NULL entry_hash
|
|
129
141
|
// rather than referencing a column that does not exist.
|
|
130
142
|
const entryHashSelect = hasEntryHashColumn(db) ? 'c.entry_hash' : 'NULL';
|
|
131
|
-
let hits: SearchHit[];
|
|
132
143
|
try {
|
|
133
144
|
const rows = db
|
|
134
145
|
.query(
|
|
@@ -153,7 +164,7 @@ export function searchArchive(
|
|
|
153
164
|
originClass: string | null;
|
|
154
165
|
importance: number | null;
|
|
155
166
|
}>;
|
|
156
|
-
|
|
167
|
+
return rows.map((r) => ({
|
|
157
168
|
path: r.path,
|
|
158
169
|
startLine: r.startLine,
|
|
159
170
|
endLine: r.endLine,
|
|
@@ -166,6 +177,18 @@ export function searchArchive(
|
|
|
166
177
|
} finally {
|
|
167
178
|
db.close();
|
|
168
179
|
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function searchArchive(
|
|
183
|
+
dbPath: string,
|
|
184
|
+
query: string,
|
|
185
|
+
maxResults: number,
|
|
186
|
+
opts?: { recordRetrievals?: boolean; now?: number },
|
|
187
|
+
): SearchHit[] {
|
|
188
|
+
if (!fs.existsSync(dbPath)) return [];
|
|
189
|
+
const match = toFtsMatch(query);
|
|
190
|
+
if (!match) return [];
|
|
191
|
+
const hits = runFtsMatchQuery(dbPath, match, maxResults);
|
|
169
192
|
|
|
170
193
|
// Recall counter (planning-66): fire-and-forget, append-only, best-effort. A
|
|
171
194
|
// dedicated writable handle (WAL + busy_timeout so it never fails a concurrent
|
|
@@ -191,6 +214,41 @@ export function searchArchive(
|
|
|
191
214
|
return hits;
|
|
192
215
|
}
|
|
193
216
|
|
|
217
|
+
export function recordSharedPathRetrieval(dbPath: string, sourcePath: string, now = Date.now()): void {
|
|
218
|
+
if (!fs.existsSync(dbPath)) return;
|
|
219
|
+
try {
|
|
220
|
+
const db = new Database(dbPath);
|
|
221
|
+
try {
|
|
222
|
+
db.exec('PRAGMA busy_timeout=2000');
|
|
223
|
+
const rows = db
|
|
224
|
+
.query('SELECT DISTINCT entry_hash FROM kb_chunks WHERE path = ? AND entry_hash IS NOT NULL')
|
|
225
|
+
.all(sourcePath) as Array<{ entry_hash: string | null }>;
|
|
226
|
+
recordRetrievalHits(
|
|
227
|
+
{ run: (sql, params = []) => void db.query(sql).run(...(params as never[])) },
|
|
228
|
+
rows.map((r) => r.entry_hash),
|
|
229
|
+
now,
|
|
230
|
+
);
|
|
231
|
+
} finally {
|
|
232
|
+
db.close();
|
|
233
|
+
}
|
|
234
|
+
} catch {
|
|
235
|
+
/* best-effort telemetry — never affect a direct read response */
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Find shared notes whose content overlaps `seedText` (typically a new
|
|
241
|
+
* note's name plus the opening of its content) — the basis of
|
|
242
|
+
* `memory_shared_create`'s "does something like this already exist?" nudge.
|
|
243
|
+
* Read-only, same missing-DB/empty-query behavior as `searchArchive`.
|
|
244
|
+
*/
|
|
245
|
+
export function findSimilarSharedNotes(dbPath: string, seedText: string, maxResults = 3): SearchHit[] {
|
|
246
|
+
if (!fs.existsSync(dbPath)) return [];
|
|
247
|
+
const match = toFtsMatchOr(seedText);
|
|
248
|
+
if (!match) return [];
|
|
249
|
+
return runFtsMatchQuery(dbPath, match, maxResults);
|
|
250
|
+
}
|
|
251
|
+
|
|
194
252
|
/** A memory-scoped path: MEMORY.md, USER.md, or memory/**.md — no traversal. */
|
|
195
253
|
export function isMemoryScopedPath(relPath: string): boolean {
|
|
196
254
|
const p = relPath.replace(/\\/g, '/');
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bun tests for the shared-KB write mirror (memory_shared_create/_get/_update/
|
|
3
|
+
* _delete's building blocks). Runs under Bun (`bun test`) alongside archive-reader.test.ts.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { test, expect } from 'bun:test';
|
|
7
|
+
import * as fs from 'fs';
|
|
8
|
+
import * as os from 'os';
|
|
9
|
+
import * as path from 'path';
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
sharedNoteFilename,
|
|
13
|
+
sharedNoteExists,
|
|
14
|
+
readSharedNote,
|
|
15
|
+
writeSharedNoteAtomic,
|
|
16
|
+
deleteSharedNote,
|
|
17
|
+
contentLossPercent,
|
|
18
|
+
triggerSharedReindex,
|
|
19
|
+
} from './archive-writer';
|
|
20
|
+
import { searchArchive } from './archive-reader';
|
|
21
|
+
|
|
22
|
+
function tmpVaultDir(): string {
|
|
23
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'kb-shared-write-'));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test('sharedNoteFilename: slugifies, strips .md, contains to one segment (mirrors Node-side shared-writer)', () => {
|
|
27
|
+
expect(sharedNoteFilename('My Cool Note.md')).toBe('my-cool-note.md');
|
|
28
|
+
expect(sharedNoteFilename('../../etc/passwd')).toBe('etc-passwd.md'); // no separators survive
|
|
29
|
+
expect(sharedNoteFilename('')).toBe('note.md');
|
|
30
|
+
expect(sharedNoteFilename('a/b\\c')).toBe('a-b-c.md');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('writeSharedNoteAtomic: writes atomically into <vaultDir>/notes and cannot escape it', () => {
|
|
34
|
+
const vaultDir = tmpVaultDir();
|
|
35
|
+
try {
|
|
36
|
+
const p = writeSharedNoteAtomic(vaultDir, '../escape attempt', 'shared body');
|
|
37
|
+
const notesDir = path.join(vaultDir, 'notes');
|
|
38
|
+
expect(p.startsWith(notesDir + path.sep)).toBe(true); // contained
|
|
39
|
+
expect(fs.readFileSync(p, 'utf8')).toBe('shared body');
|
|
40
|
+
// No stray temp files left behind.
|
|
41
|
+
expect(fs.readdirSync(notesDir).some((f) => f.startsWith('.tmp-'))).toBe(false);
|
|
42
|
+
} finally {
|
|
43
|
+
fs.rmSync(vaultDir, { recursive: true, force: true });
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('sharedNoteExists: false before write, true after — the basis for memory_shared_create/_update\'s existence gates', () => {
|
|
48
|
+
const vaultDir = tmpVaultDir();
|
|
49
|
+
try {
|
|
50
|
+
expect(sharedNoteExists(vaultDir, 'oncall')).toBe(false);
|
|
51
|
+
writeSharedNoteAtomic(vaultDir, 'oncall', 'escalate paging incidents to platform team');
|
|
52
|
+
expect(sharedNoteExists(vaultDir, 'oncall')).toBe(true);
|
|
53
|
+
} finally {
|
|
54
|
+
fs.rmSync(vaultDir, { recursive: true, force: true });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('readSharedNote: null before write, full content after', () => {
|
|
59
|
+
const vaultDir = tmpVaultDir();
|
|
60
|
+
try {
|
|
61
|
+
expect(readSharedNote(vaultDir, 'oncall')).toBeNull();
|
|
62
|
+
writeSharedNoteAtomic(vaultDir, 'oncall', 'escalate paging incidents to platform team');
|
|
63
|
+
expect(readSharedNote(vaultDir, 'oncall')).toBe('escalate paging incidents to platform team');
|
|
64
|
+
} finally {
|
|
65
|
+
fs.rmSync(vaultDir, { recursive: true, force: true });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('writeSharedNoteAtomic: same name overwrites in place (update semantics, no duplicate file)', () => {
|
|
70
|
+
const vaultDir = tmpVaultDir();
|
|
71
|
+
try {
|
|
72
|
+
const p1 = writeSharedNoteAtomic(vaultDir, 'oncall', 'v1: escalate to platform team');
|
|
73
|
+
const p2 = writeSharedNoteAtomic(vaultDir, 'oncall', 'v2: escalate to SRE team');
|
|
74
|
+
expect(p1).toBe(p2);
|
|
75
|
+
expect(fs.readFileSync(p2, 'utf8')).toBe('v2: escalate to SRE team');
|
|
76
|
+
expect(fs.readdirSync(path.join(vaultDir, 'notes')).length).toBe(1);
|
|
77
|
+
} finally {
|
|
78
|
+
fs.rmSync(vaultDir, { recursive: true, force: true });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('deleteSharedNote: removes an existing note and reports true; false (no-op) if absent', () => {
|
|
83
|
+
const vaultDir = tmpVaultDir();
|
|
84
|
+
try {
|
|
85
|
+
expect(deleteSharedNote(vaultDir, 'oncall')).toBe(false); // nothing written yet
|
|
86
|
+
writeSharedNoteAtomic(vaultDir, 'oncall', 'escalate paging incidents to platform team');
|
|
87
|
+
expect(sharedNoteExists(vaultDir, 'oncall')).toBe(true);
|
|
88
|
+
expect(deleteSharedNote(vaultDir, 'oncall')).toBe(true);
|
|
89
|
+
expect(sharedNoteExists(vaultDir, 'oncall')).toBe(false);
|
|
90
|
+
expect(deleteSharedNote(vaultDir, 'oncall')).toBe(false); // already gone
|
|
91
|
+
} finally {
|
|
92
|
+
fs.rmSync(vaultDir, { recursive: true, force: true });
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('deleteSharedNote: cannot escape the notes dir (name is filename-slugified first)', () => {
|
|
97
|
+
const vaultDir = tmpVaultDir();
|
|
98
|
+
try {
|
|
99
|
+
// Nothing outside notes/ can ever be targeted, so this is always a no-op.
|
|
100
|
+
expect(deleteSharedNote(vaultDir, '../../etc/passwd')).toBe(false);
|
|
101
|
+
} finally {
|
|
102
|
+
fs.rmSync(vaultDir, { recursive: true, force: true });
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('contentLossPercent: 0 when every old line survives, 100 when none do', () => {
|
|
107
|
+
const oldContent = 'line one\nline two\nline three';
|
|
108
|
+
expect(contentLossPercent(oldContent, 'line one\nline two\nline three\nline four')).toBe(0); // pure append, nothing dropped
|
|
109
|
+
expect(contentLossPercent(oldContent, 'totally different content')).toBe(100);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('contentLossPercent: dropping a subset of old lines is proportional loss, not zero', () => {
|
|
113
|
+
const oldContent = 'line one\nline two\nline three';
|
|
114
|
+
expect(contentLossPercent(oldContent, 'line one\nline two')).toBe(33); // 1 of 3 old lines missing
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('contentLossPercent: partial loss rounds to nearest percent', () => {
|
|
118
|
+
// 2 of 4 old lines survive verbatim -> 50% lost.
|
|
119
|
+
const oldContent = 'a\nb\nc\nd';
|
|
120
|
+
expect(contentLossPercent(oldContent, 'a\nb\nsomething else')).toBe(50);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('contentLossPercent: empty old content never divides by zero', () => {
|
|
124
|
+
expect(contentLossPercent('', 'brand new content')).toBe(0);
|
|
125
|
+
expect(contentLossPercent('\n\n', 'brand new content')).toBe(0); // blank-only old content
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('triggerSharedReindex: never throws, even when the compiled CLI is missing', () => {
|
|
129
|
+
const vaultDir = tmpVaultDir();
|
|
130
|
+
try {
|
|
131
|
+
expect(() => triggerSharedReindex(vaultDir)).not.toThrow();
|
|
132
|
+
} finally {
|
|
133
|
+
fs.rmSync(vaultDir, { recursive: true, force: true });
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// End-to-end proof of the acceptance criterion: a written note is discoverable
|
|
138
|
+
// via the same search path memory_search uses, WITHOUT waiting for the nightly
|
|
139
|
+
// dream or a manual reindex step. Uses the real compiled reindex-cli.js (built
|
|
140
|
+
// by `npm run build` into dist/ alongside this package, per package.json
|
|
141
|
+
// `files`) — skips gracefully if that build artifact is missing (e.g. a bare
|
|
142
|
+
// `bun test` run against source with no prior `npm run build`).
|
|
143
|
+
test('memory_shared_create flow: write + triggerSharedReindex makes the note searchable within a few seconds', async () => {
|
|
144
|
+
const cliPath = path.join(__dirname, '..', '..', '..', 'dist', 'agent', 'knowledge', 'reindex-cli.js');
|
|
145
|
+
if (!fs.existsSync(cliPath)) {
|
|
146
|
+
console.warn('[archive-writer.test] dist/agent/knowledge/reindex-cli.js missing — run `npm run build` first; skipping e2e reindex test');
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const vaultDir = tmpVaultDir();
|
|
150
|
+
try {
|
|
151
|
+
const content = 'The staging cluster now runs on kubernetes in ap-southeast-1.';
|
|
152
|
+
writeSharedNoteAtomic(vaultDir, 'infra-note', content);
|
|
153
|
+
triggerSharedReindex(vaultDir);
|
|
154
|
+
|
|
155
|
+
const dbPath = path.join(vaultDir, 'kb.sqlite');
|
|
156
|
+
const deadline = Date.now() + 8000;
|
|
157
|
+
let hits: ReturnType<typeof searchArchive> = [];
|
|
158
|
+
while (Date.now() < deadline) {
|
|
159
|
+
hits = searchArchive(dbPath, 'staging cluster kubernetes', 5);
|
|
160
|
+
if (hits.length > 0) break;
|
|
161
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
162
|
+
}
|
|
163
|
+
expect(hits.length).toBeGreaterThan(0);
|
|
164
|
+
expect(hits[0].snippet).toContain('kubernetes');
|
|
165
|
+
} finally {
|
|
166
|
+
fs.rmSync(vaultDir, { recursive: true, force: true });
|
|
167
|
+
}
|
|
168
|
+
}, 12000);
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write-side glue for the shared KB (Bun/mcp side; planning-64 K3 follow-up).
|
|
3
|
+
*
|
|
4
|
+
* Unlike the read side (archive-reader.ts, which must reimplement its query
|
|
5
|
+
* path because `bun:sqlite`/`node:sqlite` are different bindings over the
|
|
6
|
+
* same file), the atomic-write logic itself (slugify + write-temp-then-rename)
|
|
7
|
+
* has NO `node:sqlite` dependency — it's plain `fs`/`path`. So this file does
|
|
8
|
+
* NOT duplicate it: it imports the real `writeSharedNote` / `sharedNoteFilename`
|
|
9
|
+
* straight from the compiled `dist/agent/knowledge/shared-writer.js`, the same
|
|
10
|
+
* pattern already used elsewhere in `mcp/` for logic it needs but cannot reach
|
|
11
|
+
* in `src/` directly (see `mcp/tools/slack/module.ts`'s `SlackClient` import,
|
|
12
|
+
* `mcp/tools/telegram/receiver-server.ts`'s `turn-trace`/`incident-store`
|
|
13
|
+
* imports). Verified against mcp-no-src-imports.test.ts.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import * as fs from 'fs';
|
|
17
|
+
import * as path from 'path';
|
|
18
|
+
import { spawn } from 'child_process';
|
|
19
|
+
import { writeSharedNote, sharedNoteFilename } from '../../../dist/agent/knowledge/shared-writer.js';
|
|
20
|
+
import type { ResolvedKnowledgeSharedCfg } from '../../../dist/agent/knowledge/types.js';
|
|
21
|
+
|
|
22
|
+
export { sharedNoteFilename };
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Reconstruct the minimal resolved shared-KB config `writeSharedNote` needs
|
|
26
|
+
* (only `root`/`project` are actually read, via `sharedNotesDir`), by
|
|
27
|
+
* splitting `vaultDir` = `<root>/<project>` — the inverse of the Node-side
|
|
28
|
+
* `sharedVaultDir()`. `mode`/`graph`/`enabled` are dead weight for this call
|
|
29
|
+
* (writeSharedNote never reads them) but filled in for type shape.
|
|
30
|
+
*/
|
|
31
|
+
function cfgFromVaultDir(vaultDir: string): ResolvedKnowledgeSharedCfg {
|
|
32
|
+
return {
|
|
33
|
+
enabled: true,
|
|
34
|
+
root: path.dirname(vaultDir),
|
|
35
|
+
project: path.basename(vaultDir),
|
|
36
|
+
mode: 'auto',
|
|
37
|
+
graph: false,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Note identity is the caller's own freeform `name`, slugified to one safe
|
|
43
|
+
* path segment (`sharedNoteFilename`) — deliberately NOT prefixed with an
|
|
44
|
+
* agent id or content-hashed. `memory_shared_create`/`_update`/`_delete` are
|
|
45
|
+
* a shared, cross-agent namespace by design: any agent can name a note
|
|
46
|
+
* anything, and a repeat call with the same name addresses the same file.
|
|
47
|
+
* This is why `memory_shared_create` cross-checks near-duplicates via
|
|
48
|
+
* `findSimilarSharedNotes` before writing — nothing else prevents two agents
|
|
49
|
+
* from independently picking the same topic under different names.
|
|
50
|
+
*
|
|
51
|
+
* This matches the nightly promoter's reason-derived naming (`shared-promote.ts`),
|
|
52
|
+
* so recurring automatic promotions and manual writes resolve through the same
|
|
53
|
+
* freeform shared namespace.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/** Whether a note already exists at this name (post-`sharedNoteFilename` slugification). */
|
|
57
|
+
export function sharedNoteExists(vaultDir: string, name: string): boolean {
|
|
58
|
+
return fs.existsSync(path.join(vaultDir, 'notes', sharedNoteFilename(name)));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Full content of a shared note, or null if it does not exist. */
|
|
62
|
+
export function readSharedNote(vaultDir: string, name: string): string | null {
|
|
63
|
+
const target = path.join(vaultDir, 'notes', sharedNoteFilename(name));
|
|
64
|
+
if (!fs.existsSync(target)) return null;
|
|
65
|
+
return fs.readFileSync(target, 'utf8');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Write a note into the shared vault atomically. Returns the absolute path written. */
|
|
69
|
+
export function writeSharedNoteAtomic(vaultDir: string, name: string, content: string): string {
|
|
70
|
+
return writeSharedNote(cfgFromVaultDir(vaultDir), name, content);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Delete a note by name. Returns false (no-op) if it did not exist. */
|
|
74
|
+
export function deleteSharedNote(vaultDir: string, name: string): boolean {
|
|
75
|
+
const target = path.join(vaultDir, 'notes', sharedNoteFilename(name));
|
|
76
|
+
if (!fs.existsSync(target)) return false;
|
|
77
|
+
fs.rmSync(target);
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Percentage (0-100) of `oldContent`'s non-blank lines that do NOT appear
|
|
83
|
+
* verbatim anywhere in `newContent` — `memory_shared_update`'s guard against
|
|
84
|
+
* an agent blindly clobbering a note instead of editing it. Deliberately a
|
|
85
|
+
* blunt line-membership check (not a real diff/LCS): cheap, deterministic,
|
|
86
|
+
* and good enough to flag "this update discards most of what was here",
|
|
87
|
+
* which is all the guard needs to do. 0 old lines counts as 0% lost (nothing
|
|
88
|
+
* to lose) rather than dividing by zero.
|
|
89
|
+
*/
|
|
90
|
+
export function contentLossPercent(oldContent: string, newContent: string): number {
|
|
91
|
+
const oldLines = oldContent.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
92
|
+
if (oldLines.length === 0) return 0;
|
|
93
|
+
const newLineSet = new Set(newContent.split('\n').map((l) => l.trim()));
|
|
94
|
+
const lost = oldLines.filter((l) => !newLineSet.has(l)).length;
|
|
95
|
+
return Math.round((lost / oldLines.length) * 100);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Fire-and-forget reindex trigger so a promoted note is searchable immediately,
|
|
100
|
+
* instead of waiting for the next natural reindex (session spawn / nightly
|
|
101
|
+
* dream). Spawns the SAME compiled CLI the Node-side `spawnArchiveReindex`
|
|
102
|
+
* uses (`dist/agent/knowledge/reindex-cli.js`) as a SUBPROCESS (never
|
|
103
|
+
* imported — reindex-cli.ts requires `node:sqlite`, which Bun does not have,
|
|
104
|
+
* so it can only be run, not loaded, from this process).
|
|
105
|
+
*
|
|
106
|
+
* This code runs under Bun, whose own `process.execPath` points at the `bun`
|
|
107
|
+
* binary — spawning the CLI with that fails silently (`node:sqlite` is not a
|
|
108
|
+
* Bun built-in) since the child is detached with `stdio:'ignore'`. The gateway
|
|
109
|
+
* forwards its own (real Node) `process.execPath` via `GATEWAY_NODE_EXEC_PATH`
|
|
110
|
+
* (see src/session/process.ts) specifically so this spawn uses the right
|
|
111
|
+
* runtime; fall back to a bare `node` on PATH if that env is somehow unset.
|
|
112
|
+
*
|
|
113
|
+
* The CLI's shared-archive half only needs `{enabled, root, project}` (see
|
|
114
|
+
* `cfgFromVaultDir`); the personal-archive half is explicitly disabled
|
|
115
|
+
* (`{enabled:false}`) since this trigger only concerns the shared vault.
|
|
116
|
+
* Best-effort: swallows every failure so a promote call never fails because
|
|
117
|
+
* reindexing couldn't be kicked off.
|
|
118
|
+
*/
|
|
119
|
+
export function triggerSharedReindex(vaultDir: string): void {
|
|
120
|
+
try {
|
|
121
|
+
const cliPath = path.join(__dirname, '..', '..', '..', 'dist', 'agent', 'knowledge', 'reindex-cli.js');
|
|
122
|
+
if (!fs.existsSync(cliPath)) return;
|
|
123
|
+
const nodeBin = process.env.GATEWAY_NODE_EXEC_PATH || 'node';
|
|
124
|
+
const sharedCfg = cfgFromVaultDir(vaultDir);
|
|
125
|
+
const child = spawn(
|
|
126
|
+
nodeBin,
|
|
127
|
+
[cliPath, vaultDir, JSON.stringify({ enabled: false }), JSON.stringify(sharedCfg)],
|
|
128
|
+
{ detached: true, stdio: 'ignore' },
|
|
129
|
+
);
|
|
130
|
+
child.on('error', () => {
|
|
131
|
+
/* swallowed */
|
|
132
|
+
});
|
|
133
|
+
child.unref();
|
|
134
|
+
} catch {
|
|
135
|
+
/* best-effort: reindex is never on the critical path of a promote call */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bun tests for MemoryModule.handleTool's memory_shared_create / _get / _update /
|
|
3
|
+
* _delete — the exists/missing gates, the near-duplicate nudge on create, and the
|
|
4
|
+
* content-loss confirmation guard on update.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { test, expect, beforeEach, afterEach } from 'bun:test';
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as os from 'os';
|
|
10
|
+
import * as path from 'path';
|
|
11
|
+
|
|
12
|
+
import { MemoryModule } from './module';
|
|
13
|
+
|
|
14
|
+
let workspaceDir: string;
|
|
15
|
+
let vaultDir: string;
|
|
16
|
+
const originalEnv = {
|
|
17
|
+
GATEWAY_WORKSPACE_DIR: process.env.GATEWAY_WORKSPACE_DIR,
|
|
18
|
+
GATEWAY_SHARED_KB_DIR: process.env.GATEWAY_SHARED_KB_DIR,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-module-ws-'));
|
|
23
|
+
vaultDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-module-shared-'));
|
|
24
|
+
process.env.GATEWAY_WORKSPACE_DIR = workspaceDir;
|
|
25
|
+
process.env.GATEWAY_SHARED_KB_DIR = vaultDir;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
fs.rmSync(workspaceDir, { recursive: true, force: true });
|
|
30
|
+
fs.rmSync(vaultDir, { recursive: true, force: true });
|
|
31
|
+
for (const [k, v] of Object.entries(originalEnv)) {
|
|
32
|
+
if (v === undefined) delete process.env[k];
|
|
33
|
+
else process.env[k] = v;
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
function textOf(result: Awaited<ReturnType<MemoryModule['handleTool']>>): string {
|
|
38
|
+
return (result.content[0] as { text: string }).text;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test('memory_shared_create: writes a new note under a freeform name (no agent-id prefix)', async () => {
|
|
42
|
+
const mod = new MemoryModule();
|
|
43
|
+
const result = await mod.handleTool('memory_shared_create', { name: 'oncall-runbook', content: 'escalate to SRE' });
|
|
44
|
+
expect(result.isError).toBeUndefined();
|
|
45
|
+
const body = JSON.parse(textOf(result));
|
|
46
|
+
expect(body.created).toBe(true);
|
|
47
|
+
expect(fs.existsSync(path.join(vaultDir, 'notes', 'oncall-runbook.md'))).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('memory_shared_create: fails with an exact-name collision, does not overwrite', async () => {
|
|
51
|
+
const mod = new MemoryModule();
|
|
52
|
+
await mod.handleTool('memory_shared_create', { name: 'oncall-runbook', content: 'v1: escalate to platform team' });
|
|
53
|
+
const result = await mod.handleTool('memory_shared_create', { name: 'oncall-runbook', content: 'v2 attempt' });
|
|
54
|
+
expect(result.isError).toBe(true);
|
|
55
|
+
expect(textOf(result)).toContain('already exists');
|
|
56
|
+
expect(fs.readFileSync(path.join(vaultDir, 'notes', 'oncall-runbook.md'), 'utf8')).toBe('v1: escalate to platform team');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('memory_shared_create: any agent can create — no GATEWAY_AGENT_ID required', async () => {
|
|
60
|
+
delete process.env.GATEWAY_AGENT_ID;
|
|
61
|
+
const mod = new MemoryModule();
|
|
62
|
+
const result = await mod.handleTool('memory_shared_create', { name: 'oncall-runbook', content: 'escalate to SRE' });
|
|
63
|
+
expect(result.isError).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('memory_shared_get: reads full content; errors on a missing note', async () => {
|
|
67
|
+
const mod = new MemoryModule();
|
|
68
|
+
const missing = await mod.handleTool('memory_shared_get', { name: 'oncall-runbook' });
|
|
69
|
+
expect(missing.isError).toBe(true);
|
|
70
|
+
|
|
71
|
+
await mod.handleTool('memory_shared_create', { name: 'oncall-runbook', content: 'escalate to SRE' });
|
|
72
|
+
const result = await mod.handleTool('memory_shared_get', { name: 'oncall-runbook' });
|
|
73
|
+
expect(result.isError).toBeUndefined();
|
|
74
|
+
const body = JSON.parse(textOf(result));
|
|
75
|
+
expect(body.content).toBe('escalate to SRE');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('memory_shared_update: fails when the note does not exist yet, pointing at memory_shared_create', async () => {
|
|
79
|
+
const mod = new MemoryModule();
|
|
80
|
+
const result = await mod.handleTool('memory_shared_update', { name: 'oncall-runbook', content: 'escalate to SRE' });
|
|
81
|
+
expect(result.isError).toBe(true);
|
|
82
|
+
expect(textOf(result)).toContain('memory_shared_create');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('memory_shared_update: applies a small edit (below the loss threshold) without confirm', async () => {
|
|
86
|
+
const mod = new MemoryModule();
|
|
87
|
+
await mod.handleTool('memory_shared_create', { name: 'oncall-runbook', content: 'line one\nline two\nline three\nline four' });
|
|
88
|
+
// Drop 1 of 4 lines (25% loss) — under the 50% threshold, should apply directly.
|
|
89
|
+
const result = await mod.handleTool('memory_shared_update', { name: 'oncall-runbook', content: 'line one\nline two\nline three' });
|
|
90
|
+
expect(result.isError).toBeUndefined();
|
|
91
|
+
const body = JSON.parse(textOf(result));
|
|
92
|
+
expect(body.updated).toBe(true);
|
|
93
|
+
expect(fs.readFileSync(path.join(vaultDir, 'notes', 'oncall-runbook.md'), 'utf8')).toBe('line one\nline two\nline three');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('memory_shared_update: a large content loss needs confirm:true, and does not write until given', async () => {
|
|
97
|
+
const mod = new MemoryModule();
|
|
98
|
+
await mod.handleTool('memory_shared_create', { name: 'oncall-runbook', content: 'line one\nline two\nline three\nline four' });
|
|
99
|
+
|
|
100
|
+
const warned = await mod.handleTool('memory_shared_update', { name: 'oncall-runbook', content: 'brand new unrelated content' });
|
|
101
|
+
expect(warned.isError).toBeUndefined();
|
|
102
|
+
const warnedBody = JSON.parse(textOf(warned));
|
|
103
|
+
expect(warnedBody.updated).toBe(false);
|
|
104
|
+
expect(warnedBody.needsConfirmation).toBe(true);
|
|
105
|
+
expect(warnedBody.lossPercent).toBe(100);
|
|
106
|
+
// Still the original content — the warning must not have written anything.
|
|
107
|
+
expect(fs.readFileSync(path.join(vaultDir, 'notes', 'oncall-runbook.md'), 'utf8')).toBe('line one\nline two\nline three\nline four');
|
|
108
|
+
|
|
109
|
+
const confirmed = await mod.handleTool('memory_shared_update', { name: 'oncall-runbook', content: 'brand new unrelated content', confirm: true });
|
|
110
|
+
const confirmedBody = JSON.parse(textOf(confirmed));
|
|
111
|
+
expect(confirmedBody.updated).toBe(true);
|
|
112
|
+
expect(fs.readFileSync(path.join(vaultDir, 'notes', 'oncall-runbook.md'), 'utf8')).toBe('brand new unrelated content');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('memory_shared_delete: any agent can delete any note — no ownership scoping', async () => {
|
|
116
|
+
const mod = new MemoryModule();
|
|
117
|
+
await mod.handleTool('memory_shared_create', { name: 'oncall-runbook', content: 'escalate to SRE' });
|
|
118
|
+
delete process.env.GATEWAY_AGENT_ID; // a different/misconfigured agent — still allowed
|
|
119
|
+
const result = await mod.handleTool('memory_shared_delete', { name: 'oncall-runbook' });
|
|
120
|
+
expect(result.isError).toBeUndefined();
|
|
121
|
+
expect(fs.existsSync(path.join(vaultDir, 'notes', 'oncall-runbook.md'))).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('memory_shared_delete: errors (no-op) on a name that does not exist', async () => {
|
|
125
|
+
const mod = new MemoryModule();
|
|
126
|
+
const result = await mod.handleTool('memory_shared_delete', { name: 'never-created' });
|
|
127
|
+
expect(result.isError).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// End-to-end proof that memory_shared_create's near-duplicate nudge actually
|
|
131
|
+
// fires through the real reindex path (not just the pure findSimilarSharedNotes
|
|
132
|
+
// unit tests in archive-reader.test.ts). Skips gracefully if dist/ hasn't been
|
|
133
|
+
// built, same guard as archive-writer.test.ts's e2e reindex test.
|
|
134
|
+
test('memory_shared_create: near-duplicate content is nudged for confirmation once reindexed', async () => {
|
|
135
|
+
const cliPath = path.join(__dirname, '..', '..', '..', 'dist', 'agent', 'knowledge', 'reindex-cli.js');
|
|
136
|
+
if (!fs.existsSync(cliPath)) {
|
|
137
|
+
console.warn('[memory module.test] dist/agent/knowledge/reindex-cli.js missing — run `npm run build` first; skipping e2e near-dup test');
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const mod = new MemoryModule();
|
|
141
|
+
await mod.handleTool('memory_shared_create', {
|
|
142
|
+
name: 'oncall-escalation-runbook',
|
|
143
|
+
content: 'escalate paging incidents to the platform team via pagerduty, then page SRE if unacknowledged in 10 minutes',
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Wait for the fire-and-forget reindex triggered by the create above.
|
|
147
|
+
const dbPath = path.join(vaultDir, 'kb.sqlite');
|
|
148
|
+
const deadline = Date.now() + 8000;
|
|
149
|
+
while (Date.now() < deadline && !fs.existsSync(dbPath)) {
|
|
150
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let result = await mod.handleTool('memory_shared_create', {
|
|
154
|
+
name: 'paging-oncall-playbook',
|
|
155
|
+
content: 'oncall playbook: paging escalation to platform team via pagerduty',
|
|
156
|
+
});
|
|
157
|
+
// Reindex is best-effort/async; retry briefly if it hadn't landed yet.
|
|
158
|
+
const retryDeadline = Date.now() + 8000;
|
|
159
|
+
while (Date.now() < retryDeadline) {
|
|
160
|
+
const body = JSON.parse(textOf(result));
|
|
161
|
+
if (body.needsConfirmation) break;
|
|
162
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
163
|
+
result = await mod.handleTool('memory_shared_create', {
|
|
164
|
+
name: 'paging-oncall-playbook',
|
|
165
|
+
content: 'oncall playbook: paging escalation to platform team via pagerduty',
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
const body = JSON.parse(textOf(result));
|
|
169
|
+
expect(body.created).toBe(false);
|
|
170
|
+
expect(body.needsConfirmation).toBe(true);
|
|
171
|
+
expect(body.similar.some((s: { path: string }) => s.path.includes('oncall-escalation-runbook'))).toBe(true);
|
|
172
|
+
expect(fs.existsSync(path.join(vaultDir, 'notes', 'paging-oncall-playbook.md'))).toBe(false);
|
|
173
|
+
|
|
174
|
+
// confirm:true creates it anyway.
|
|
175
|
+
const confirmed = await mod.handleTool('memory_shared_create', {
|
|
176
|
+
name: 'paging-oncall-playbook',
|
|
177
|
+
content: 'oncall playbook: paging escalation to platform team via pagerduty',
|
|
178
|
+
confirm: true,
|
|
179
|
+
});
|
|
180
|
+
expect(JSON.parse(textOf(confirmed)).created).toBe(true);
|
|
181
|
+
|
|
182
|
+
// #386: confirming past a near-dup nudge should link the related note(s)
|
|
183
|
+
// found, not leave a disconnected duplicate — so /knowledge/graph gets a
|
|
184
|
+
// real edge instead of staying empty.
|
|
185
|
+
const written = fs.readFileSync(path.join(vaultDir, 'notes', 'paging-oncall-playbook.md'), 'utf8');
|
|
186
|
+
expect(written).toContain('[[oncall-escalation-runbook]]');
|
|
187
|
+
}, 20000);
|