@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.
Files changed (258) hide show
  1. package/README.md +178 -44
  2. package/dist/agent/builtin-commands.js +2 -2
  3. package/dist/agent/builtin-commands.js.map +1 -1
  4. package/dist/agent/dreaming/accept.d.ts.map +1 -1
  5. package/dist/agent/dreaming/accept.js +4 -1
  6. package/dist/agent/dreaming/accept.js.map +1 -1
  7. package/dist/agent/dreaming/config.d.ts.map +1 -1
  8. package/dist/agent/dreaming/config.js +2 -0
  9. package/dist/agent/dreaming/config.js.map +1 -1
  10. package/dist/agent/dreaming/reviewer.d.ts.map +1 -1
  11. package/dist/agent/dreaming/reviewer.js +9 -2
  12. package/dist/agent/dreaming/reviewer.js.map +1 -1
  13. package/dist/agent/dreaming/staleness.d.ts +7 -1
  14. package/dist/agent/dreaming/staleness.d.ts.map +1 -1
  15. package/dist/agent/dreaming/staleness.js +17 -2
  16. package/dist/agent/dreaming/staleness.js.map +1 -1
  17. package/dist/agent/dreaming/types.d.ts +17 -1
  18. package/dist/agent/dreaming/types.d.ts.map +1 -1
  19. package/dist/agent/knowledge/archive-db.d.ts +43 -0
  20. package/dist/agent/knowledge/archive-db.d.ts.map +1 -1
  21. package/dist/agent/knowledge/archive-db.js +130 -0
  22. package/dist/agent/knowledge/archive-db.js.map +1 -1
  23. package/dist/agent/knowledge/config.d.ts +3 -1
  24. package/dist/agent/knowledge/config.d.ts.map +1 -1
  25. package/dist/agent/knowledge/config.js +32 -10
  26. package/dist/agent/knowledge/config.js.map +1 -1
  27. package/dist/agent/knowledge/index.d.ts +11 -4
  28. package/dist/agent/knowledge/index.d.ts.map +1 -1
  29. package/dist/agent/knowledge/index.js +22 -1
  30. package/dist/agent/knowledge/index.js.map +1 -1
  31. package/dist/agent/knowledge/indexer.d.ts +0 -7
  32. package/dist/agent/knowledge/indexer.d.ts.map +1 -1
  33. package/dist/agent/knowledge/indexer.js +34 -7
  34. package/dist/agent/knowledge/indexer.js.map +1 -1
  35. package/dist/agent/knowledge/lifecycle.d.ts +20 -4
  36. package/dist/agent/knowledge/lifecycle.d.ts.map +1 -1
  37. package/dist/agent/knowledge/lifecycle.js +33 -4
  38. package/dist/agent/knowledge/lifecycle.js.map +1 -1
  39. package/dist/agent/knowledge/reflection.d.ts +114 -0
  40. package/dist/agent/knowledge/reflection.d.ts.map +1 -0
  41. package/dist/agent/knowledge/reflection.js +447 -0
  42. package/dist/agent/knowledge/reflection.js.map +1 -0
  43. package/dist/agent/knowledge/shared-dedup.d.ts +85 -0
  44. package/dist/agent/knowledge/shared-dedup.d.ts.map +1 -0
  45. package/dist/agent/knowledge/shared-dedup.js +187 -0
  46. package/dist/agent/knowledge/shared-dedup.js.map +1 -0
  47. package/dist/agent/knowledge/shared-promote.d.ts +82 -7
  48. package/dist/agent/knowledge/shared-promote.d.ts.map +1 -1
  49. package/dist/agent/knowledge/shared-promote.js +244 -11
  50. package/dist/agent/knowledge/shared-promote.js.map +1 -1
  51. package/dist/agent/knowledge/shared-staleness.d.ts +51 -0
  52. package/dist/agent/knowledge/shared-staleness.d.ts.map +1 -0
  53. package/dist/agent/knowledge/shared-staleness.js +149 -0
  54. package/dist/agent/knowledge/shared-staleness.js.map +1 -0
  55. package/dist/agent/knowledge/shared-writer.d.ts +19 -0
  56. package/dist/agent/knowledge/shared-writer.d.ts.map +1 -1
  57. package/dist/agent/knowledge/shared-writer.js +42 -0
  58. package/dist/agent/knowledge/shared-writer.js.map +1 -1
  59. package/dist/agent/knowledge/types.d.ts +24 -0
  60. package/dist/agent/knowledge/types.d.ts.map +1 -1
  61. package/dist/agent/model-catalog.d.ts +62 -0
  62. package/dist/agent/model-catalog.d.ts.map +1 -0
  63. package/dist/agent/model-catalog.js +264 -0
  64. package/dist/agent/model-catalog.js.map +1 -0
  65. package/dist/agent/runner.d.ts +83 -0
  66. package/dist/agent/runner.d.ts.map +1 -1
  67. package/dist/agent/runner.js +281 -51
  68. package/dist/agent/runner.js.map +1 -1
  69. package/dist/agent/workspace-loader.d.ts +17 -2
  70. package/dist/agent/workspace-loader.d.ts.map +1 -1
  71. package/dist/agent/workspace-loader.js +17 -1
  72. package/dist/agent/workspace-loader.js.map +1 -1
  73. package/dist/api/cron-router.d.ts +12 -8
  74. package/dist/api/cron-router.d.ts.map +1 -1
  75. package/dist/api/cron-router.js +82 -16
  76. package/dist/api/cron-router.js.map +1 -1
  77. package/dist/api/gateway-router.d.ts +4 -0
  78. package/dist/api/gateway-router.d.ts.map +1 -1
  79. package/dist/api/gateway-router.js +16 -1
  80. package/dist/api/gateway-router.js.map +1 -1
  81. package/dist/api/manifest-sources.d.ts +15 -0
  82. package/dist/api/manifest-sources.d.ts.map +1 -0
  83. package/dist/api/manifest-sources.js +27 -0
  84. package/dist/api/manifest-sources.js.map +1 -0
  85. package/dist/api/meta-router.d.ts +13 -0
  86. package/dist/api/meta-router.d.ts.map +1 -0
  87. package/dist/api/meta-router.js +26 -0
  88. package/dist/api/meta-router.js.map +1 -0
  89. package/dist/api/packages.d.ts.map +1 -1
  90. package/dist/api/packages.js +18 -198
  91. package/dist/api/packages.js.map +1 -1
  92. package/dist/api/route-registry.d.ts +81 -0
  93. package/dist/api/route-registry.d.ts.map +1 -0
  94. package/dist/api/route-registry.js +44 -0
  95. package/dist/api/route-registry.js.map +1 -0
  96. package/dist/api/router.d.ts.map +1 -1
  97. package/dist/api/router.js +21 -3
  98. package/dist/api/router.js.map +1 -1
  99. package/dist/apps/compose-generator.js +7 -4
  100. package/dist/apps/compose-generator.js.map +1 -1
  101. package/dist/apps/installer.d.ts +154 -0
  102. package/dist/apps/installer.d.ts.map +1 -1
  103. package/dist/apps/installer.js +657 -54
  104. package/dist/apps/installer.js.map +1 -1
  105. package/dist/cli/args.d.ts +25 -0
  106. package/dist/cli/args.d.ts.map +1 -0
  107. package/dist/cli/args.js +55 -0
  108. package/dist/cli/args.js.map +1 -0
  109. package/dist/cli/colors.d.ts +37 -0
  110. package/dist/cli/colors.d.ts.map +1 -0
  111. package/dist/cli/colors.js +63 -0
  112. package/dist/cli/colors.js.map +1 -0
  113. package/dist/cli/command-names.d.ts +98 -0
  114. package/dist/cli/command-names.d.ts.map +1 -0
  115. package/dist/cli/command-names.js +109 -0
  116. package/dist/cli/command-names.js.map +1 -0
  117. package/dist/cli/commands/agents.d.ts +28 -0
  118. package/dist/cli/commands/agents.d.ts.map +1 -0
  119. package/dist/cli/commands/agents.js +321 -0
  120. package/dist/cli/commands/agents.js.map +1 -0
  121. package/dist/cli/commands/channels.d.ts +17 -0
  122. package/dist/cli/commands/channels.d.ts.map +1 -0
  123. package/dist/cli/commands/channels.js +101 -0
  124. package/dist/cli/commands/channels.js.map +1 -0
  125. package/dist/cli/commands/debug-bundle.d.ts +6 -0
  126. package/dist/cli/commands/debug-bundle.d.ts.map +1 -0
  127. package/dist/cli/commands/debug-bundle.js +220 -0
  128. package/dist/cli/commands/debug-bundle.js.map +1 -0
  129. package/dist/cli/commands/doctor.d.ts +3 -0
  130. package/dist/cli/commands/doctor.d.ts.map +1 -0
  131. package/dist/cli/commands/doctor.js +104 -0
  132. package/dist/cli/commands/doctor.js.map +1 -0
  133. package/dist/cli/commands/gateway.d.ts +12 -0
  134. package/dist/cli/commands/gateway.d.ts.map +1 -0
  135. package/dist/cli/commands/gateway.js +145 -0
  136. package/dist/cli/commands/gateway.js.map +1 -0
  137. package/dist/cli/commands/service.d.ts +33 -0
  138. package/dist/cli/commands/service.d.ts.map +1 -0
  139. package/dist/cli/commands/service.js +513 -0
  140. package/dist/cli/commands/service.js.map +1 -0
  141. package/dist/cli/commands/update.d.ts +16 -0
  142. package/dist/cli/commands/update.d.ts.map +1 -0
  143. package/dist/cli/commands/update.js +149 -0
  144. package/dist/cli/commands/update.js.map +1 -0
  145. package/dist/cli/commands.generated.d.ts +4 -0
  146. package/dist/cli/commands.generated.d.ts.map +1 -0
  147. package/dist/cli/commands.generated.js +220 -0
  148. package/dist/cli/commands.generated.js.map +1 -0
  149. package/dist/cli/health.d.ts +31 -0
  150. package/dist/cli/health.d.ts.map +1 -0
  151. package/dist/cli/health.js +49 -0
  152. package/dist/cli/health.js.map +1 -0
  153. package/dist/cli/http-client.d.ts +140 -0
  154. package/dist/cli/http-client.d.ts.map +1 -0
  155. package/dist/cli/http-client.js +332 -0
  156. package/dist/cli/http-client.js.map +1 -0
  157. package/dist/cli/index.d.ts +27 -0
  158. package/dist/cli/index.d.ts.map +1 -0
  159. package/dist/cli/index.js +372 -0
  160. package/dist/cli/index.js.map +1 -0
  161. package/dist/cli/manager.d.ts +64 -0
  162. package/dist/cli/manager.d.ts.map +1 -0
  163. package/dist/cli/manager.js +181 -0
  164. package/dist/cli/manager.js.map +1 -0
  165. package/dist/cli/output.d.ts +49 -0
  166. package/dist/cli/output.d.ts.map +1 -0
  167. package/dist/cli/output.js +101 -0
  168. package/dist/cli/output.js.map +1 -0
  169. package/dist/cli/prompt.d.ts +29 -0
  170. package/dist/cli/prompt.d.ts.map +1 -0
  171. package/dist/cli/prompt.js +154 -0
  172. package/dist/cli/prompt.js.map +1 -0
  173. package/dist/cli/redact.d.ts +3 -0
  174. package/dist/cli/redact.d.ts.map +1 -0
  175. package/dist/cli/redact.js +33 -0
  176. package/dist/cli/redact.js.map +1 -0
  177. package/dist/cli/types.d.ts +22 -0
  178. package/dist/cli/types.d.ts.map +1 -0
  179. package/dist/cli/types.js +4 -0
  180. package/dist/cli/types.js.map +1 -0
  181. package/dist/config/bootstrap.d.ts +33 -0
  182. package/dist/config/bootstrap.d.ts.map +1 -0
  183. package/dist/config/bootstrap.js +117 -0
  184. package/dist/config/bootstrap.js.map +1 -0
  185. package/dist/config/loader.d.ts.map +1 -1
  186. package/dist/config/loader.js +5 -1
  187. package/dist/config/loader.js.map +1 -1
  188. package/dist/config/watcher.d.ts.map +1 -1
  189. package/dist/config/watcher.js +2 -35
  190. package/dist/config/watcher.js.map +1 -1
  191. package/dist/discord/receiver.d.ts +6 -1
  192. package/dist/discord/receiver.d.ts.map +1 -1
  193. package/dist/discord/receiver.js +9 -1
  194. package/dist/discord/receiver.js.map +1 -1
  195. package/dist/entry.d.ts +3 -0
  196. package/dist/entry.d.ts.map +1 -0
  197. package/dist/entry.js +83 -0
  198. package/dist/entry.js.map +1 -0
  199. package/dist/index.js +197 -47
  200. package/dist/index.js.map +1 -1
  201. package/dist/load-dotenv.d.ts +2 -0
  202. package/dist/load-dotenv.d.ts.map +1 -0
  203. package/dist/load-dotenv.js +68 -0
  204. package/dist/load-dotenv.js.map +1 -0
  205. package/dist/packages/registry.d.ts +50 -0
  206. package/dist/packages/registry.d.ts.map +1 -0
  207. package/dist/packages/registry.js +209 -0
  208. package/dist/packages/registry.js.map +1 -0
  209. package/dist/session/process.d.ts +20 -3
  210. package/dist/session/process.d.ts.map +1 -1
  211. package/dist/session/process.js +120 -5
  212. package/dist/session/process.js.map +1 -1
  213. package/dist/shell/claude-pty-shell.js +146 -27
  214. package/dist/shell/claude-pty-shell.js.map +1 -1
  215. package/dist/shell/draft-phantom.d.ts +49 -0
  216. package/dist/shell/draft-phantom.d.ts.map +1 -0
  217. package/dist/shell/draft-phantom.js +59 -0
  218. package/dist/shell/draft-phantom.js.map +1 -0
  219. package/dist/shell/submit-diag.d.ts +67 -0
  220. package/dist/shell/submit-diag.d.ts.map +1 -0
  221. package/dist/shell/submit-diag.js +69 -0
  222. package/dist/shell/submit-diag.js.map +1 -0
  223. package/dist/shutdown-signals.d.ts +47 -0
  224. package/dist/shutdown-signals.d.ts.map +1 -0
  225. package/dist/shutdown-signals.js +62 -0
  226. package/dist/shutdown-signals.js.map +1 -0
  227. package/dist/telegram/receiver.d.ts +6 -1
  228. package/dist/telegram/receiver.d.ts.map +1 -1
  229. package/dist/telegram/receiver.js +9 -1
  230. package/dist/telegram/receiver.js.map +1 -1
  231. package/dist/types.d.ts +25 -0
  232. package/dist/types.d.ts.map +1 -1
  233. package/dist/utils/orphan-receivers.d.ts +90 -0
  234. package/dist/utils/orphan-receivers.d.ts.map +1 -0
  235. package/dist/utils/orphan-receivers.js +218 -0
  236. package/dist/utils/orphan-receivers.js.map +1 -0
  237. package/dist/utils/paths.d.ts +16 -0
  238. package/dist/utils/paths.d.ts.map +1 -0
  239. package/dist/utils/paths.js +60 -0
  240. package/dist/utils/paths.js.map +1 -0
  241. package/dist/utils/stop-child.d.ts +21 -0
  242. package/dist/utils/stop-child.d.ts.map +1 -0
  243. package/dist/utils/stop-child.js +64 -0
  244. package/dist/utils/stop-child.js.map +1 -0
  245. package/lib/pairing.ts +1 -1
  246. package/mcp/tools/agent/handlers.ts +1 -1
  247. package/mcp/tools/discord/module.ts +23 -15
  248. package/mcp/tools/discord/receiver-server.ts +4 -0
  249. package/mcp/tools/memory/archive-reader.test.ts +25 -1
  250. package/mcp/tools/memory/archive-reader.ts +70 -12
  251. package/mcp/tools/memory/archive-writer.test.ts +168 -0
  252. package/mcp/tools/memory/archive-writer.ts +137 -0
  253. package/mcp/tools/memory/module.test.ts +187 -0
  254. package/mcp/tools/memory/module.ts +192 -6
  255. package/mcp/tools/telegram/module.ts +10 -2
  256. package/mcp/tools/telegram/receiver-server.ts +15 -6
  257. package/mcp/tools/telegram/typing.ts +178 -34
  258. 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
- 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
-
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
- hits = rows.map((r) => ({
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);