@0xmaxma/claude-gateway 1.6.9 → 1.7.1

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 (216) hide show
  1. package/README.md +149 -20
  2. package/config.template.json +59 -1
  3. package/dist/agent/dreaming/accept.d.ts +43 -0
  4. package/dist/agent/dreaming/accept.d.ts.map +1 -0
  5. package/dist/agent/dreaming/accept.js +230 -0
  6. package/dist/agent/dreaming/accept.js.map +1 -0
  7. package/dist/agent/dreaming/applier.d.ts +77 -0
  8. package/dist/agent/dreaming/applier.d.ts.map +1 -0
  9. package/dist/agent/dreaming/applier.js +406 -0
  10. package/dist/agent/dreaming/applier.js.map +1 -0
  11. package/dist/agent/dreaming/audit.d.ts +27 -0
  12. package/dist/agent/dreaming/audit.d.ts.map +1 -0
  13. package/dist/agent/dreaming/audit.js +100 -0
  14. package/dist/agent/dreaming/audit.js.map +1 -0
  15. package/dist/agent/dreaming/compactor.d.ts +66 -0
  16. package/dist/agent/dreaming/compactor.d.ts.map +1 -0
  17. package/dist/agent/dreaming/compactor.js +306 -0
  18. package/dist/agent/dreaming/compactor.js.map +1 -0
  19. package/dist/agent/dreaming/config.d.ts +19 -0
  20. package/dist/agent/dreaming/config.d.ts.map +1 -0
  21. package/dist/agent/dreaming/config.js +107 -0
  22. package/dist/agent/dreaming/config.js.map +1 -0
  23. package/dist/agent/dreaming/episodic.d.ts +42 -0
  24. package/dist/agent/dreaming/episodic.d.ts.map +1 -0
  25. package/dist/agent/dreaming/episodic.js +116 -0
  26. package/dist/agent/dreaming/episodic.js.map +1 -0
  27. package/dist/agent/dreaming/gather.d.ts +31 -0
  28. package/dist/agent/dreaming/gather.d.ts.map +1 -0
  29. package/dist/agent/dreaming/gather.js +55 -0
  30. package/dist/agent/dreaming/gather.js.map +1 -0
  31. package/dist/agent/dreaming/index.d.ts +87 -0
  32. package/dist/agent/dreaming/index.d.ts.map +1 -0
  33. package/dist/agent/dreaming/index.js +377 -0
  34. package/dist/agent/dreaming/index.js.map +1 -0
  35. package/dist/agent/dreaming/migrate-cli.d.ts +17 -0
  36. package/dist/agent/dreaming/migrate-cli.d.ts.map +1 -0
  37. package/dist/agent/dreaming/migrate-cli.js +42 -0
  38. package/dist/agent/dreaming/migrate-cli.js.map +1 -0
  39. package/dist/agent/dreaming/migrate.d.ts +72 -0
  40. package/dist/agent/dreaming/migrate.d.ts.map +1 -0
  41. package/dist/agent/dreaming/migrate.js +374 -0
  42. package/dist/agent/dreaming/migrate.js.map +1 -0
  43. package/dist/agent/dreaming/report.d.ts +39 -0
  44. package/dist/agent/dreaming/report.d.ts.map +1 -0
  45. package/dist/agent/dreaming/report.js +121 -0
  46. package/dist/agent/dreaming/report.js.map +1 -0
  47. package/dist/agent/dreaming/reviewer.d.ts +43 -0
  48. package/dist/agent/dreaming/reviewer.d.ts.map +1 -0
  49. package/dist/agent/dreaming/reviewer.js +249 -0
  50. package/dist/agent/dreaming/reviewer.js.map +1 -0
  51. package/dist/agent/dreaming/staleness.d.ts +68 -0
  52. package/dist/agent/dreaming/staleness.d.ts.map +1 -0
  53. package/dist/agent/dreaming/staleness.js +281 -0
  54. package/dist/agent/dreaming/staleness.js.map +1 -0
  55. package/dist/agent/dreaming/types.d.ts +130 -0
  56. package/dist/agent/dreaming/types.d.ts.map +1 -0
  57. package/dist/agent/dreaming/types.js +10 -0
  58. package/dist/agent/dreaming/types.js.map +1 -0
  59. package/dist/agent/knowledge/archive-db.d.ts +114 -0
  60. package/dist/agent/knowledge/archive-db.d.ts.map +1 -0
  61. package/dist/agent/knowledge/archive-db.js +474 -0
  62. package/dist/agent/knowledge/archive-db.js.map +1 -0
  63. package/dist/agent/knowledge/chunk.d.ts +21 -0
  64. package/dist/agent/knowledge/chunk.d.ts.map +1 -0
  65. package/dist/agent/knowledge/chunk.js +67 -0
  66. package/dist/agent/knowledge/chunk.js.map +1 -0
  67. package/dist/agent/knowledge/config.d.ts +20 -0
  68. package/dist/agent/knowledge/config.d.ts.map +1 -0
  69. package/dist/agent/knowledge/config.js +143 -0
  70. package/dist/agent/knowledge/config.js.map +1 -0
  71. package/dist/agent/knowledge/graph-demo.d.ts +25 -0
  72. package/dist/agent/knowledge/graph-demo.d.ts.map +1 -0
  73. package/dist/agent/knowledge/graph-demo.js +132 -0
  74. package/dist/agent/knowledge/graph-demo.js.map +1 -0
  75. package/dist/agent/knowledge/index.d.ts +24 -0
  76. package/dist/agent/knowledge/index.d.ts.map +1 -0
  77. package/dist/agent/knowledge/index.js +50 -0
  78. package/dist/agent/knowledge/index.js.map +1 -0
  79. package/dist/agent/knowledge/indexer.d.ts +34 -0
  80. package/dist/agent/knowledge/indexer.d.ts.map +1 -0
  81. package/dist/agent/knowledge/indexer.js +271 -0
  82. package/dist/agent/knowledge/indexer.js.map +1 -0
  83. package/dist/agent/knowledge/lifecycle.d.ts +91 -0
  84. package/dist/agent/knowledge/lifecycle.d.ts.map +1 -0
  85. package/dist/agent/knowledge/lifecycle.js +205 -0
  86. package/dist/agent/knowledge/lifecycle.js.map +1 -0
  87. package/dist/agent/knowledge/provenance.d.ts +15 -0
  88. package/dist/agent/knowledge/provenance.d.ts.map +1 -0
  89. package/dist/agent/knowledge/provenance.js +37 -0
  90. package/dist/agent/knowledge/provenance.js.map +1 -0
  91. package/dist/agent/knowledge/reindex-cli.d.ts +13 -0
  92. package/dist/agent/knowledge/reindex-cli.d.ts.map +1 -0
  93. package/dist/agent/knowledge/reindex-cli.js +53 -0
  94. package/dist/agent/knowledge/reindex-cli.js.map +1 -0
  95. package/dist/agent/knowledge/reindex-spawn.d.ts +30 -0
  96. package/dist/agent/knowledge/reindex-spawn.d.ts.map +1 -0
  97. package/dist/agent/knowledge/reindex-spawn.js +97 -0
  98. package/dist/agent/knowledge/reindex-spawn.js.map +1 -0
  99. package/dist/agent/knowledge/shared-promote.d.ts +18 -0
  100. package/dist/agent/knowledge/shared-promote.d.ts.map +1 -0
  101. package/dist/agent/knowledge/shared-promote.js +29 -0
  102. package/dist/agent/knowledge/shared-promote.js.map +1 -0
  103. package/dist/agent/knowledge/shared-writer.d.ts +24 -0
  104. package/dist/agent/knowledge/shared-writer.d.ts.map +1 -0
  105. package/dist/agent/knowledge/shared-writer.js +97 -0
  106. package/dist/agent/knowledge/shared-writer.js.map +1 -0
  107. package/dist/agent/knowledge/types.d.ts +83 -0
  108. package/dist/agent/knowledge/types.d.ts.map +1 -0
  109. package/dist/agent/knowledge/types.js +12 -0
  110. package/dist/agent/knowledge/types.js.map +1 -0
  111. package/dist/agent/knowledge/wiki.d.ts +107 -0
  112. package/dist/agent/knowledge/wiki.d.ts.map +1 -0
  113. package/dist/agent/knowledge/wiki.js +415 -0
  114. package/dist/agent/knowledge/wiki.js.map +1 -0
  115. package/dist/agent/memory-metrics.d.ts +48 -0
  116. package/dist/agent/memory-metrics.d.ts.map +1 -0
  117. package/dist/agent/memory-metrics.js +156 -0
  118. package/dist/agent/memory-metrics.js.map +1 -0
  119. package/dist/agent/runner.d.ts +34 -1
  120. package/dist/agent/runner.d.ts.map +1 -1
  121. package/dist/agent/runner.js +223 -19
  122. package/dist/agent/runner.js.map +1 -1
  123. package/dist/agent/skill-learning/config.d.ts +18 -0
  124. package/dist/agent/skill-learning/config.d.ts.map +1 -0
  125. package/dist/agent/skill-learning/config.js +71 -0
  126. package/dist/agent/skill-learning/config.js.map +1 -0
  127. package/dist/agent/skill-learning/curator.d.ts +45 -0
  128. package/dist/agent/skill-learning/curator.d.ts.map +1 -0
  129. package/dist/agent/skill-learning/curator.js +171 -0
  130. package/dist/agent/skill-learning/curator.js.map +1 -0
  131. package/dist/agent/skill-learning/index.d.ts +79 -0
  132. package/dist/agent/skill-learning/index.d.ts.map +1 -0
  133. package/dist/agent/skill-learning/index.js +308 -0
  134. package/dist/agent/skill-learning/index.js.map +1 -0
  135. package/dist/agent/skill-learning/metrics.d.ts +13 -0
  136. package/dist/agent/skill-learning/metrics.d.ts.map +1 -0
  137. package/dist/agent/skill-learning/metrics.js +109 -0
  138. package/dist/agent/skill-learning/metrics.js.map +1 -0
  139. package/dist/agent/skill-learning/notifier.d.ts +111 -0
  140. package/dist/agent/skill-learning/notifier.d.ts.map +1 -0
  141. package/dist/agent/skill-learning/notifier.js +398 -0
  142. package/dist/agent/skill-learning/notifier.js.map +1 -0
  143. package/dist/agent/skill-learning/reviewer.d.ts +61 -0
  144. package/dist/agent/skill-learning/reviewer.d.ts.map +1 -0
  145. package/dist/agent/skill-learning/reviewer.js +271 -0
  146. package/dist/agent/skill-learning/reviewer.js.map +1 -0
  147. package/dist/agent/skill-learning/telemetry.d.ts +22 -0
  148. package/dist/agent/skill-learning/telemetry.d.ts.map +1 -0
  149. package/dist/agent/skill-learning/telemetry.js +90 -0
  150. package/dist/agent/skill-learning/telemetry.js.map +1 -0
  151. package/dist/agent/skill-learning/trigger.d.ts +23 -0
  152. package/dist/agent/skill-learning/trigger.d.ts.map +1 -0
  153. package/dist/agent/skill-learning/trigger.js +44 -0
  154. package/dist/agent/skill-learning/trigger.js.map +1 -0
  155. package/dist/agent/skill-learning/types.d.ts +165 -0
  156. package/dist/agent/skill-learning/types.d.ts.map +1 -0
  157. package/dist/agent/skill-learning/types.js +11 -0
  158. package/dist/agent/skill-learning/types.js.map +1 -0
  159. package/dist/agent/skill-learning/writer.d.ts +37 -0
  160. package/dist/agent/skill-learning/writer.d.ts.map +1 -0
  161. package/dist/agent/skill-learning/writer.js +223 -0
  162. package/dist/agent/skill-learning/writer.js.map +1 -0
  163. package/dist/agent/workspace-loader.d.ts +83 -6
  164. package/dist/agent/workspace-loader.d.ts.map +1 -1
  165. package/dist/agent/workspace-loader.js +207 -14
  166. package/dist/agent/workspace-loader.js.map +1 -1
  167. package/dist/api/gateway-router.d.ts +2 -0
  168. package/dist/api/gateway-router.d.ts.map +1 -1
  169. package/dist/api/gateway-router.js +293 -6
  170. package/dist/api/gateway-router.js.map +1 -1
  171. package/dist/api/skills-router.d.ts +2 -2
  172. package/dist/api/skills-router.d.ts.map +1 -1
  173. package/dist/api/skills-router.js +61 -1
  174. package/dist/api/skills-router.js.map +1 -1
  175. package/dist/apps/agent-manager.d.ts +7 -0
  176. package/dist/apps/agent-manager.d.ts.map +1 -1
  177. package/dist/apps/agent-manager.js +13 -1
  178. package/dist/apps/agent-manager.js.map +1 -1
  179. package/dist/config/migrator.d.ts +2 -0
  180. package/dist/config/migrator.d.ts.map +1 -1
  181. package/dist/config/migrator.js +72 -1
  182. package/dist/config/migrator.js.map +1 -1
  183. package/dist/history/cleanup.d.ts +7 -0
  184. package/dist/history/cleanup.d.ts.map +1 -1
  185. package/dist/history/cleanup.js +27 -0
  186. package/dist/history/cleanup.js.map +1 -1
  187. package/dist/history/db.d.ts +65 -0
  188. package/dist/history/db.d.ts.map +1 -1
  189. package/dist/history/db.js +261 -0
  190. package/dist/history/db.js.map +1 -1
  191. package/dist/index.js +122 -20
  192. package/dist/index.js.map +1 -1
  193. package/dist/session/process.d.ts +20 -0
  194. package/dist/session/process.d.ts.map +1 -1
  195. package/dist/session/process.js +128 -4
  196. package/dist/session/process.js.map +1 -1
  197. package/dist/skills/loader.d.ts +8 -0
  198. package/dist/skills/loader.d.ts.map +1 -1
  199. package/dist/skills/loader.js +29 -1
  200. package/dist/skills/loader.js.map +1 -1
  201. package/dist/types.d.ts +116 -0
  202. package/dist/types.d.ts.map +1 -1
  203. package/dist/ui/web-ui.d.ts.map +1 -1
  204. package/dist/ui/web-ui.js +1126 -3
  205. package/dist/ui/web-ui.js.map +1 -1
  206. package/mcp/package.json +2 -1
  207. package/mcp/server.ts +8 -2
  208. package/mcp/tools/image/module.ts +58 -7
  209. package/mcp/tools/memory/archive-reader.test.ts +212 -0
  210. package/mcp/tools/memory/archive-reader.ts +253 -0
  211. package/mcp/tools/memory/module.ts +152 -0
  212. package/mcp/tools/memory/retrieval-recorder.ts +66 -0
  213. package/mcp/tools/skills/module.ts +28 -0
  214. package/mcp/types.ts +9 -3
  215. package/package.json +1 -1
  216. package/resource/claude_gateway.svg +1038 -112
@@ -1 +1 @@
1
- {"version":3,"file":"web-ui.js","sourceRoot":"","sources":["../../src/ui/web-ui.ts"],"names":[],"mappings":";;AAIA,sDAq4BC;AAQD,8CAiFC;AAl+BD;;;GAGG;AACH,SAAgB,qBAAqB;IACnC,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;QAm4BD,CAAC;AACT,CAAC;AAED;;;;;GAKG;AACH,SAAgB,iBAAiB,CAAC,cAAc,GAAG,EAAE;IACnD,MAAM,UAAU,GAAG,cAAc;SAC9B,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;IAC9F,+EAA+E;IAC/E,8EAA8E;IAC9E,MAAM,SAAS,GAAG,UAAU;QAC1B,CAAC,CAAC;uBACiB,UAAU,QAAQ;QACrC,CAAC,CAAC;;;;;;;YAOM,CAAC;IACX,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAmCP,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;QA6BH,CAAC;AACT,CAAC"}
1
+ {"version":3,"file":"web-ui.js","sourceRoot":"","sources":["../../src/ui/web-ui.ts"],"names":[],"mappings":"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"}
package/mcp/package.json CHANGED
@@ -4,7 +4,8 @@
4
4
  "type": "module",
5
5
  "bin": "./server.ts",
6
6
  "scripts": {
7
- "start": "bun install --no-summary && bun server.ts"
7
+ "start": "bun install --no-summary && bun server.ts",
8
+ "test": "bun test"
8
9
  },
9
10
  "dependencies": {
10
11
  "@line/bot-sdk": "^11.0.2",
package/mcp/server.ts CHANGED
@@ -23,6 +23,7 @@ import { ImageModule } from './tools/image/module';
23
23
  import { ShareImageModule } from './tools/share-image/module';
24
24
  import { AppsModule } from './tools/apps/module';
25
25
  import { ApiModule } from './tools/api/module';
26
+ import { MemoryModule } from './tools/memory/module';
26
27
  import { buildChannelInstructions } from './instructions';
27
28
  import type { ChannelModule, ToolModule, McpToolDefinition } from './types';
28
29
 
@@ -46,6 +47,7 @@ const modules: AnyModule[] = [
46
47
  new ShareImageModule(),
47
48
  new AppsModule(),
48
49
  new ApiModule(),
50
+ new MemoryModule(),
49
51
  ];
50
52
 
51
53
  // Build tool-to-module mapping for enabled modules
@@ -100,7 +102,7 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
100
102
  tools: visibleTools,
101
103
  }));
102
104
 
103
- mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
105
+ mcp.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
104
106
  const toolName = req.params.name;
105
107
  const args = (req.params.arguments ?? {}) as Record<string, unknown>;
106
108
 
@@ -112,7 +114,11 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
112
114
  };
113
115
  }
114
116
 
115
- return mod.handleTool(toolName, args);
117
+ // extra.signal fires on notifications/cancelled for THIS call (the SDK matches
118
+ // it by requestId) — the CLI sends that when a user Stop/Ctrl-C interrupts an
119
+ // in-flight tool call. Threaded through so a long-running module (image
120
+ // generation's poll loop) can react instead of running to its full timeout.
121
+ return mod.handleTool(toolName, args, extra.signal);
116
122
  });
117
123
 
118
124
  // Connect MCP transport
@@ -109,14 +109,17 @@ export class ImageModule implements ToolModule {
109
109
  return imageToolDefs;
110
110
  }
111
111
 
112
- async handleTool(name: string, args: Record<string, unknown>): Promise<McpToolResult> {
112
+ async handleTool(name: string, args: Record<string, unknown>, signal?: AbortSignal): Promise<McpToolResult> {
113
113
  if (name !== 'generate_image') {
114
114
  return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
115
115
  }
116
116
  const action = typeof args.action === 'string' ? args.action : 'generate';
117
117
  switch (action) {
118
118
  case 'generate':
119
- return this.handleGenerate(args);
119
+ // Only "generate" gets the signal — it's the one action with a multi-second
120
+ // poll loop worth reacting to a Stop mid-flight (#2216 follow-up on the
121
+ // getpod side). status/list/list_refs are single bounded requests already.
122
+ return this.handleGenerate(args, signal);
120
123
  case 'status':
121
124
  return this.handleStatus(args);
122
125
  case 'list':
@@ -257,7 +260,7 @@ export class ImageModule implements ToolModule {
257
260
  };
258
261
  }
259
262
 
260
- private async handleGenerate(args: Record<string, unknown>): Promise<McpToolResult> {
263
+ private async handleGenerate(args: Record<string, unknown>, signal?: AbortSignal): Promise<McpToolResult> {
261
264
  const prompt = typeof args.prompt === 'string' ? args.prompt.trim() : '';
262
265
  const model = typeof args.model === 'string' ? args.model.trim() : '';
263
266
  if (!prompt) {
@@ -369,11 +372,16 @@ export class ImageModule implements ToolModule {
369
372
  return { content: [{ type: 'text', text: 'generate_image: image service did not return a task_id' }], isError: true };
370
373
  }
371
374
 
372
- // Poll (E2) until done/failed or budget exceeded.
375
+ // Poll (E2) until done/failed, budget exceeded, or the caller cancels this call
376
+ // (Stop mid-generation — #2216 follow-up). Checked at the top of every
377
+ // iteration AND inside the sleep itself, so a cancel lands within one wakeup
378
+ // instead of waiting out the full poll interval.
373
379
  const deadline = Date.now() + this.pollTimeoutMs();
374
380
  let last: JobResponse = submit;
375
381
  while (Date.now() < deadline) {
376
- await sleep(DEFAULT_POLL_INTERVAL_MS);
382
+ if (signal?.aborted) return this.cancelledResult(taskId);
383
+ await sleep(DEFAULT_POLL_INTERVAL_MS, signal);
384
+ if (signal?.aborted) return this.cancelledResult(taskId);
377
385
  const polled = await this.fetchJob(taskId);
378
386
  if (polled.__transportError) {
379
387
  // transient transport error — keep polling until deadline
@@ -528,6 +536,36 @@ export class ImageModule implements ToolModule {
528
536
  return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_POLL_TIMEOUT_MS;
529
537
  }
530
538
 
539
+ /**
540
+ * Best-effort E3 cancel — fires and never throws, so a failed cancel call can
541
+ * never break the caller's own (already-cancelled) tool response. The api-side
542
+ * cancel is itself idempotent/no-op-safe on an already-terminal or unknown task,
543
+ * so no need to track whether this actually reached a still-running job.
544
+ */
545
+ private async cancelJob(taskId: string): Promise<void> {
546
+ try {
547
+ await fetch(`${this.baseUrl()}/v1/images/jobs/${encodeURIComponent(taskId)}/cancel`, {
548
+ method: 'POST',
549
+ headers: this.headers(),
550
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
551
+ });
552
+ } catch {
553
+ // non-fatal — the local tool call already reports itself cancelled either way
554
+ }
555
+ }
556
+
557
+ /** Tool result for a Stop-triggered cancellation, firing the E3 cancel first. */
558
+ private cancelledResult(taskId: string): McpToolResult {
559
+ void this.cancelJob(taskId);
560
+ return {
561
+ content: [{
562
+ type: 'text',
563
+ text: `generate_image: cancelled (task_id ${taskId}).`,
564
+ }],
565
+ isError: true,
566
+ };
567
+ }
568
+
531
569
  /** Fetch a job (E2), classifying transport vs HTTP errors so the poller can retry transient ones. */
532
570
  private async fetchJob(taskId: string): Promise<{ job?: JobResponse; httpError?: { status: number; body: string }; __transportError?: unknown }> {
533
571
  let res: Response;
@@ -654,12 +692,14 @@ export class ImageModule implements ToolModule {
654
692
  text: JSON.stringify({
655
693
  status: 'done',
656
694
  task_id: taskId,
695
+ ...(model ? { model } : {}),
657
696
  byok: job.byok ?? false,
658
697
  cost: job.cost ?? 0,
659
698
  files,
660
699
  ...(artifacts ? { artifacts } : {}),
661
700
  ...(droppedImages > 0 ? { dropped_images: droppedImages } : {}),
662
701
  note: 'Image saved. Deliver it to the user with your channel delivery tool — api_reply/reply (files: [...]), or line_image on LINE. Do NOT open/Read the file to inspect it first; attach it and answer briefly.'
702
+ + (model ? ` Mention which model made it (${model}).` : '')
663
703
  + (artifacts ? ' To edit this image later, reference it via its artifact_ref (e.g. image: "artifact:...").' : '')
664
704
  + (droppedImages > 0 ? ` (${droppedImages} extra image(s) beyond the cap were not saved)` : ''),
665
705
  }),
@@ -720,8 +760,19 @@ export class ImageModule implements ToolModule {
720
760
  }
721
761
  }
722
762
 
723
- function sleep(ms: number): Promise<void> {
724
- return new Promise((r) => setTimeout(r, ms));
763
+ // Resolves early (without rejecting) on abort — the poll loop re-checks
764
+ // signal.aborted itself right after, so this only needs to shorten the wait.
765
+ // The abort listener is removed when the timer fires normally: sleep() is called
766
+ // once per poll iteration against the SAME long-lived signal (up to ~75 times for
767
+ // the default 150s/2s budget), so leaving { once:true } listeners around on the
768
+ // non-abort path would pile them onto that one signal and trip Node's
769
+ // MaxListenersExceededWarning.
770
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
771
+ return new Promise((r) => {
772
+ const onAbort = () => { clearTimeout(t); r(); };
773
+ const t = setTimeout(() => { signal?.removeEventListener('abort', onAbort); r(); }, ms);
774
+ signal?.addEventListener('abort', onAbort, { once: true });
775
+ });
725
776
  }
726
777
 
727
778
  function sanitize(s: string): string {
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Bun tests for the knowledge-archive read path (planning-64 K1).
3
+ *
4
+ * Runs under Bun (`bun test`) because the reader uses `bun:sqlite` — the Node
5
+ * jest suite cannot load this module. The fixture DB is built with bun:sqlite
6
+ * using the SAME schema src/ writes under node:sqlite (external-content FTS5 +
7
+ * provenance/recall), so this exercises the real query path end to end.
8
+ */
9
+
10
+ import { test, expect } from 'bun:test';
11
+ import { Database } from 'bun:sqlite';
12
+ import * as fs from 'fs';
13
+ import * as os from 'os';
14
+ import * as path from 'path';
15
+
16
+ import { searchArchive, getExcerpt, isMemoryScopedPath, archiveDbPath, sharedDbPathFromEnv, mergeHits } from './archive-reader';
17
+
18
+ // Build a temp agent dir with a kb.sqlite carrying the given chunks.
19
+ function mkAgentWithArchive(
20
+ chunks: Array<{ id: string; path: string; start: number; end: number; text: string; origin?: string; importance?: number }>,
21
+ ): { agentDir: string; workspaceDir: string; dbPath: string } {
22
+ const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kb1-'));
23
+ const workspaceDir = path.join(agentDir, 'workspace');
24
+ fs.mkdirSync(workspaceDir, { recursive: true });
25
+ const dbPath = archiveDbPath(workspaceDir); // agentDir/kb.sqlite
26
+
27
+ const db = new Database(dbPath);
28
+ db.exec('PRAGMA foreign_keys=ON');
29
+ db.exec(`
30
+ CREATE TABLE kb_chunks (rowid_id INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT UNIQUE, path TEXT,
31
+ start_line INTEGER, end_line INTEGER, text TEXT, updated_at INTEGER);
32
+ CREATE TABLE kb_chunk_recall (chunk_id TEXT PRIMARY KEY, importance INTEGER, triggers TEXT, project_key TEXT,
33
+ FOREIGN KEY (chunk_id) REFERENCES kb_chunks(id) ON DELETE CASCADE);
34
+ CREATE TABLE kb_chunk_provenance (chunk_id TEXT PRIMARY KEY, origin_class TEXT, session_kind TEXT,
35
+ observed_at INTEGER, supersedes_key TEXT, FOREIGN KEY (chunk_id) REFERENCES kb_chunks(id) ON DELETE CASCADE);
36
+ CREATE VIRTUAL TABLE kb_chunks_fts USING fts5(text, content='kb_chunks', content_rowid='rowid_id');
37
+ CREATE TRIGGER kb_chunks_ai AFTER INSERT ON kb_chunks BEGIN
38
+ INSERT INTO kb_chunks_fts(rowid, text) VALUES (new.rowid_id, new.text);
39
+ END;
40
+ `);
41
+ const ins = db.prepare('INSERT INTO kb_chunks(id,path,start_line,end_line,text,updated_at) VALUES(?,?,?,?,?,?)');
42
+ const insR = db.prepare('INSERT INTO kb_chunk_recall(chunk_id,importance) VALUES(?,?)');
43
+ const insP = db.prepare('INSERT INTO kb_chunk_provenance(chunk_id,origin_class,session_kind,observed_at) VALUES(?,?,?,?)');
44
+ for (const c of chunks) {
45
+ ins.run(c.id, c.path, c.start, c.end, c.text, 1);
46
+ insR.run(c.id, c.importance ?? null);
47
+ insP.run(c.id, c.origin ?? 'agent', 'unknown', 1);
48
+ }
49
+ db.close();
50
+ return { agentDir, workspaceDir, dbPath };
51
+ }
52
+
53
+ test('isMemoryScopedPath: accepts memory-scoped, rejects traversal/abs/out-of-scope', () => {
54
+ expect(isMemoryScopedPath('MEMORY.md')).toBe(true);
55
+ expect(isMemoryScopedPath('USER.md')).toBe(true);
56
+ expect(isMemoryScopedPath('memory/foo.md')).toBe(true);
57
+ expect(isMemoryScopedPath('memory/sub/bar.md')).toBe(true);
58
+ expect(isMemoryScopedPath('../secret.md')).toBe(false);
59
+ // The discriminating case: ends in .md AND starts with memory/, so ONLY the
60
+ // ".." guard rejects it — proves that guard is load-bearing.
61
+ expect(isMemoryScopedPath('memory/../secret.md')).toBe(false);
62
+ expect(isMemoryScopedPath('memory/../../etc/passwd')).toBe(false);
63
+ expect(isMemoryScopedPath('/etc/passwd')).toBe(false);
64
+ expect(isMemoryScopedPath('notes/random.md')).toBe(false);
65
+ expect(isMemoryScopedPath('AGENTS.md')).toBe(false);
66
+ });
67
+
68
+ test('searchArchive: FTS match returns ranked hit with provenance + importance', () => {
69
+ const { workspaceDir, agentDir } = mkAgentWithArchive([
70
+ { id: 'memory/getpod.md#1-1', path: 'memory/getpod.md', start: 1, end: 1, text: 'Getpod ingress returns 401 before the gateway', origin: 'agent', importance: 7 },
71
+ { id: 'memory/apps.md#1-1', path: 'memory/apps.md', start: 1, end: 1, text: 'apps router mounts under gateway-router', origin: 'agent' },
72
+ ]);
73
+ try {
74
+ const hits = searchArchive(archiveDbPath(workspaceDir), 'ingress 401', 6);
75
+ expect(hits.length).toBe(1);
76
+ expect(hits[0].path).toBe('memory/getpod.md');
77
+ expect(hits[0].originClass).toBe('agent');
78
+ expect(hits[0].importance).toBe(7);
79
+ expect(hits[0].startLine).toBe(1);
80
+ expect(typeof hits[0].score).toBe('number');
81
+ } finally {
82
+ fs.rmSync(agentDir, { recursive: true, force: true });
83
+ }
84
+ });
85
+
86
+ // Regression (#351): a kb.sqlite built before the planning-66 `entry_hash`
87
+ // column (exactly what `mkAgentWithArchive` produces) must not crash the read
88
+ // path with `no such column: entry_hash`. searchArchive heals the column in
89
+ // place and returns hits with entryHash=null. Proven to throw on pre-fix code.
90
+ test('searchArchive: DB missing entry_hash column does not throw and heals it', () => {
91
+ const { workspaceDir, agentDir } = mkAgentWithArchive([
92
+ { id: 'memory/x.md#1-1', path: 'memory/x.md', start: 1, end: 1, text: 'ingress 401 token mismatch', origin: 'agent' },
93
+ ]);
94
+ const dbPath = archiveDbPath(workspaceDir);
95
+ const cols = (dbPath: string): string[] => {
96
+ const d = new Database(dbPath, { readonly: true });
97
+ try {
98
+ return (d.query(`PRAGMA table_info(kb_chunks)`).all() as Array<{ name: string }>).map((c) => c.name);
99
+ } finally {
100
+ d.close();
101
+ }
102
+ };
103
+ try {
104
+ // Precondition: the pre-#346 fixture genuinely lacks the column.
105
+ expect(cols(dbPath)).not.toContain('entry_hash');
106
+
107
+ let hits: ReturnType<typeof searchArchive> = [];
108
+ expect(() => {
109
+ hits = searchArchive(dbPath, 'ingress 401', 6);
110
+ }).not.toThrow();
111
+ expect(hits.length).toBe(1);
112
+ expect(hits[0].path).toBe('memory/x.md');
113
+ expect(hits[0].entryHash).toBeNull();
114
+
115
+ // The read path healed the DB in place so subsequent writers/readers are safe.
116
+ expect(cols(dbPath)).toContain('entry_hash');
117
+ } finally {
118
+ fs.rmSync(agentDir, { recursive: true, force: true });
119
+ }
120
+ });
121
+
122
+ test('searchArchive: missing DB → [], empty/punctuation query → []', () => {
123
+ const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kb1-'));
124
+ const workspaceDir = path.join(agentDir, 'workspace');
125
+ fs.mkdirSync(workspaceDir, { recursive: true });
126
+ try {
127
+ expect(searchArchive(archiveDbPath(workspaceDir), 'anything', 6)).toEqual([]); // no kb.sqlite
128
+ const { workspaceDir: ws2, agentDir: dir2 } = mkAgentWithArchive([
129
+ { id: 'm#1-1', path: 'memory/a.md', start: 1, end: 1, text: 'hello world' },
130
+ ]);
131
+ expect(searchArchive(archiveDbPath(ws2), ' !!! ', 6)).toEqual([]); // no usable tokens
132
+ fs.rmSync(dir2, { recursive: true, force: true });
133
+ } finally {
134
+ fs.rmSync(agentDir, { recursive: true, force: true });
135
+ }
136
+ });
137
+
138
+ test('getExcerpt: bounded line range; out-of-scope/missing → null', () => {
139
+ const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kb1-'));
140
+ const workspaceDir = path.join(agentDir, 'workspace');
141
+ fs.mkdirSync(path.join(workspaceDir, 'memory'), { recursive: true });
142
+ fs.writeFileSync(path.join(workspaceDir, 'memory/note.md'), 'l1\nl2\nl3\nl4\nl5');
143
+ try {
144
+ const ex = getExcerpt(workspaceDir, 'memory/note.md', 2, 2);
145
+ expect(ex).not.toBeNull();
146
+ expect(ex!.text).toBe('l2\nl3');
147
+ expect(ex!.from).toBe(2);
148
+ expect(ex!.to).toBe(3);
149
+ expect(ex!.totalLines).toBe(5);
150
+ expect(ex!.truncated).toBe(true);
151
+
152
+ // Whole file → not truncated.
153
+ expect(getExcerpt(workspaceDir, 'memory/note.md', 1, 100)!.truncated).toBe(false);
154
+ // Out-of-scope path and missing file → null.
155
+ expect(getExcerpt(workspaceDir, '../secret.md', 1, 5)).toBeNull();
156
+ expect(getExcerpt(workspaceDir, 'memory/missing.md', 1, 5)).toBeNull();
157
+ } finally {
158
+ fs.rmSync(agentDir, { recursive: true, force: true });
159
+ }
160
+ });
161
+
162
+ test('sharedDbPathFromEnv: null when unset, <dir>/kb.sqlite when set (K3 corpus:shared)', () => {
163
+ const prev = process.env.GATEWAY_SHARED_KB_DIR;
164
+ try {
165
+ delete process.env.GATEWAY_SHARED_KB_DIR;
166
+ expect(sharedDbPathFromEnv()).toBeNull();
167
+ process.env.GATEWAY_SHARED_KB_DIR = ' ';
168
+ expect(sharedDbPathFromEnv()).toBeNull(); // blank → disabled
169
+ process.env.GATEWAY_SHARED_KB_DIR = '/tmp/shared/kb/global';
170
+ expect(sharedDbPathFromEnv()).toBe('/tmp/shared/kb/global/kb.sqlite');
171
+ } finally {
172
+ if (prev === undefined) delete process.env.GATEWAY_SHARED_KB_DIR;
173
+ else process.env.GATEWAY_SHARED_KB_DIR = prev;
174
+ }
175
+ });
176
+
177
+ test('mergeHits: round-robin interleave (bm25 not comparable across corpora), capped', () => {
178
+ const mk = (path: string, score: number) => ({
179
+ path, startLine: 1, endLine: 1, score, snippet: path, originClass: 'agent', importance: null,
180
+ });
181
+ // Each list is already best-first within its own corpus. A cross-corpus numeric
182
+ // sort would mix incomparable bm25 scales; interleave a,c,b instead (personal
183
+ // first each round), capped at the limit.
184
+ const merged = mergeHits([mk('a', -0.9), mk('b', -0.1)], [mk('c', -0.5), mk('d', -0.05)], 3);
185
+ expect(merged.map((h) => h.path)).toEqual(['a', 'c', 'b']);
186
+ });
187
+
188
+ test('mergeHits: interleave preserves per-corpus order even when scales differ', () => {
189
+ const mk = (path: string, score: number) => ({
190
+ path, startLine: 1, endLine: 1, score, snippet: path, originClass: 'agent', importance: null,
191
+ });
192
+ // Shared scores numerically "better" (more negative) must NOT starve the personal
193
+ // corpus — round-robin gives p1, s1, p2, s2.
194
+ const merged = mergeHits([mk('p1', -0.2), mk('p2', -0.05)], [mk('s1', -9), mk('s2', -8)], 4);
195
+ expect(merged.map((h) => h.path)).toEqual(['p1', 's1', 'p2', 's2']);
196
+ });
197
+
198
+ test('getExcerpt: a symlink inside memory/ escaping the workspace is refused', () => {
199
+ const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kb1-'));
200
+ const workspaceDir = path.join(agentDir, 'workspace');
201
+ fs.mkdirSync(path.join(workspaceDir, 'memory'), { recursive: true });
202
+ // A secret target OUTSIDE the workspace, and a memory/ symlink pointing at it.
203
+ const secret = path.join(agentDir, 'outside-secret.md');
204
+ fs.writeFileSync(secret, 'TOPSECRET outside the workspace');
205
+ fs.symlinkSync(secret, path.join(workspaceDir, 'memory', 'evil.md'));
206
+ try {
207
+ // Passes the string scope check (memory/*.md) but realpath escapes the ws → null.
208
+ expect(getExcerpt(workspaceDir, 'memory/evil.md', 1, 5)).toBeNull();
209
+ } finally {
210
+ fs.rmSync(agentDir, { recursive: true, force: true });
211
+ }
212
+ });
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Read-only reader for the per-agent knowledge archive (planning-64 K1).
3
+ *
4
+ * The archive `kb.sqlite` is BUILT by src/ under Node via `node:sqlite`
5
+ * (src/agent/knowledge). These MCP tools run under **Bun**, which has no
6
+ * `node:sqlite`, so the read path here uses **`bun:sqlite`** instead. Same
7
+ * on-disk SQLite/FTS5 file — verified that node:sqlite writes and bun:sqlite
8
+ * reads it back via `bm25()`. This is a deliberate, read-only mirror of the
9
+ * query half of `src/agent/knowledge/archive-db.ts`; keep the two in sync.
10
+ */
11
+
12
+ import { Database } from 'bun:sqlite';
13
+ import * as fs from 'fs';
14
+ import * as path from 'path';
15
+ import { recordRetrievalHits } from './retrieval-recorder';
16
+
17
+ export interface SearchHit {
18
+ path: string;
19
+ startLine: number;
20
+ endLine: number;
21
+ score: number; // bm25 (lower = more relevant); surfaced for transparency
22
+ snippet: string;
23
+ originClass: string | null;
24
+ importance: number | null;
25
+ /** Stable archive-entry identity (planning-66); null outside any entry block. */
26
+ entryHash: string | null;
27
+ }
28
+
29
+ /** Archive DB path for an agent: sibling of history.db at agents/<id>/kb.sqlite. */
30
+ export function archiveDbPath(workspaceDir: string): string {
31
+ return path.join(path.dirname(workspaceDir), 'kb.sqlite');
32
+ }
33
+
34
+ /**
35
+ * Shared, cross-agent vault DB path from the gateway-provided env
36
+ * (`GATEWAY_SHARED_KB_DIR` = the resolved <root>/<project> dir), or null when
37
+ * shared KB is disabled / not configured (planning-64 K3).
38
+ */
39
+ export function sharedDbPathFromEnv(): string | null {
40
+ const dir = process.env.GATEWAY_SHARED_KB_DIR;
41
+ if (!dir || !dir.trim()) return null;
42
+ return path.join(dir, 'kb.sqlite');
43
+ }
44
+
45
+ /**
46
+ * Merge hit lists (per-agent + shared), keeping the best `limit`. bm25 scores are
47
+ * corpus-relative — the value depends on each index's term/document statistics —
48
+ * so scores from two independent FTS indexes are NOT on a comparable scale and a
49
+ * global sort can spuriously rank one corpus over the other. Each list is already
50
+ * sorted best-first within its own corpus, so interleave them round-robin for a
51
+ * fair blend instead of a cross-corpus numeric sort.
52
+ */
53
+ export function mergeHits<T extends SearchHit>(a: T[], b: T[], limit: number): T[] {
54
+ const out: T[] = [];
55
+ const max = Math.max(a.length, b.length);
56
+ for (let i = 0; i < max && out.length < limit; i++) {
57
+ if (i < a.length) out.push(a[i]);
58
+ if (out.length < limit && i < b.length) out.push(b[i]);
59
+ }
60
+ return out;
61
+ }
62
+
63
+ /** Build an FTS5 MATCH string: quote each token so punctuation can't inject syntax. */
64
+ function toFtsMatch(query: string): string {
65
+ return query
66
+ .split(/[^\p{L}\p{N}_]+/u)
67
+ .filter(Boolean)
68
+ .map((t) => `"${t.replace(/"/g, '""')}"`)
69
+ .join(' ');
70
+ }
71
+
72
+ /**
73
+ * Keyword search over the archive (FTS5 BM25). Returns [] when the index does
74
+ * not exist yet or the query has no usable tokens. Read-only; never creates a DB.
75
+ *
76
+ * When `recordRetrievals` is set (planning-66), each returned entry's stable
77
+ * `entry_hash` is appended to `kb_retrieval_log` through a SEPARATE, dedicated
78
+ * writable handle AFTER the results are built — append-only and fire-and-forget,
79
+ * so the read path never read-modify-writes chunk rows and never blocks/locks the
80
+ * search response. The Node side folds this log into per-entry recency at GC time.
81
+ */
82
+ /** True when kb_chunks carries the planning-66 `entry_hash` column. */
83
+ function hasEntryHashColumn(db: Database): boolean {
84
+ const cols = db.query(`PRAGMA table_info(kb_chunks)`).all() as Array<{ name: string }>;
85
+ return cols.some((c) => c.name === 'entry_hash');
86
+ }
87
+
88
+ /**
89
+ * Idempotent, best-effort heal for a pre-#346 archive DB. The `entry_hash`
90
+ * column is only added by the writer (`ArchiveDb` constructor migration); a
91
+ * `kb.sqlite` that has not been reindexed since #346 still lacks it, so the
92
+ * read-only search query below crashes with `no such column: entry_hash`. Add
93
+ * the column in place via a short writable handle (mirroring the recall-logging
94
+ * handle further down). A read-only filesystem simply skips — the column-aware
95
+ * query then falls back to a NULL `entryHash` so search still returns hits.
96
+ */
97
+ function ensureEntryHashColumn(dbPath: string): void {
98
+ try {
99
+ const w = new Database(dbPath);
100
+ try {
101
+ w.exec('PRAGMA busy_timeout=2000');
102
+ if (!hasEntryHashColumn(w)) {
103
+ w.exec(`ALTER TABLE kb_chunks ADD COLUMN entry_hash TEXT`);
104
+ w.exec(`CREATE INDEX IF NOT EXISTS idx_kb_chunks_entry_hash ON kb_chunks(entry_hash)`);
105
+ }
106
+ } finally {
107
+ w.close();
108
+ }
109
+ } catch {
110
+ /* read-only / locked DB — the column-aware query below degrades gracefully */
111
+ }
112
+ }
113
+
114
+ export function searchArchive(
115
+ dbPath: string,
116
+ query: string,
117
+ maxResults: number,
118
+ opts?: { recordRetrievals?: boolean; now?: number },
119
+ ): SearchHit[] {
120
+ if (!fs.existsSync(dbPath)) return [];
121
+ const match = toFtsMatch(query);
122
+ if (!match) return [];
123
+
124
+ // Heal pre-#346 DBs in place so the query never crashes on a missing column.
125
+ ensureEntryHashColumn(dbPath);
126
+
127
+ const db = new Database(dbPath, { readonly: true });
128
+ // If the heal above could not run (read-only FS), select a NULL entry_hash
129
+ // rather than referencing a column that does not exist.
130
+ const entryHashSelect = hasEntryHashColumn(db) ? 'c.entry_hash' : 'NULL';
131
+ let hits: SearchHit[];
132
+ try {
133
+ const rows = db
134
+ .query(
135
+ `SELECT c.id AS id, c.path AS path, c.start_line AS startLine, c.end_line AS endLine,
136
+ c.text AS text, ${entryHashSelect} AS entryHash, bm25(kb_chunks_fts) AS score,
137
+ p.origin_class AS originClass, r.importance AS importance
138
+ FROM kb_chunks_fts f
139
+ JOIN kb_chunks c ON c.rowid_id = f.rowid
140
+ LEFT JOIN kb_chunk_provenance p ON p.chunk_id = c.id
141
+ LEFT JOIN kb_chunk_recall r ON r.chunk_id = c.id
142
+ WHERE kb_chunks_fts MATCH ?
143
+ ORDER BY bm25(kb_chunks_fts)
144
+ LIMIT ?`,
145
+ )
146
+ .all(match, maxResults) as Array<{
147
+ path: string;
148
+ startLine: number;
149
+ endLine: number;
150
+ text: string;
151
+ entryHash: string | null;
152
+ score: number;
153
+ originClass: string | null;
154
+ importance: number | null;
155
+ }>;
156
+ hits = rows.map((r) => ({
157
+ path: r.path,
158
+ startLine: r.startLine,
159
+ endLine: r.endLine,
160
+ score: r.score,
161
+ snippet: r.text.length > 500 ? `${r.text.slice(0, 500)}…` : r.text,
162
+ originClass: r.originClass,
163
+ importance: r.importance,
164
+ entryHash: r.entryHash,
165
+ }));
166
+ } finally {
167
+ db.close();
168
+ }
169
+
170
+ // Recall counter (planning-66): fire-and-forget, append-only, best-effort. A
171
+ // dedicated writable handle (WAL + busy_timeout so it never fails a concurrent
172
+ // reindex) logs the retrieved entries AFTER the read handle is closed. Any error
173
+ // is swallowed inside recordRetrievalHits — telemetry never fails a search.
174
+ if (opts?.recordRetrievals && hits.length > 0) {
175
+ try {
176
+ const w = new Database(dbPath);
177
+ try {
178
+ w.exec('PRAGMA busy_timeout=2000');
179
+ recordRetrievalHits(
180
+ { run: (sql, params = []) => void w.query(sql).run(...(params as never[])) },
181
+ hits.map((h) => h.entryHash),
182
+ opts.now ?? Date.now(),
183
+ );
184
+ } finally {
185
+ w.close();
186
+ }
187
+ } catch {
188
+ /* best-effort telemetry — never affect the search response */
189
+ }
190
+ }
191
+ return hits;
192
+ }
193
+
194
+ /** A memory-scoped path: MEMORY.md, USER.md, or memory/**.md — no traversal. */
195
+ export function isMemoryScopedPath(relPath: string): boolean {
196
+ const p = relPath.replace(/\\/g, '/');
197
+ if (p.includes('..') || path.isAbsolute(p)) return false;
198
+ if (p === 'MEMORY.md' || p === 'USER.md') return true;
199
+ return p.startsWith('memory/') && p.endsWith('.md');
200
+ }
201
+
202
+ export interface Excerpt {
203
+ path: string;
204
+ from: number;
205
+ to: number;
206
+ text: string;
207
+ totalLines: number;
208
+ truncated: boolean;
209
+ }
210
+
211
+ /**
212
+ * Bounded exact excerpt of a memory file by line range (1-indexed). Reads the
213
+ * file directly (it is the source of truth). Returns null for an out-of-scope
214
+ * path or a missing file.
215
+ */
216
+ export function getExcerpt(
217
+ workspaceDir: string,
218
+ relPath: string,
219
+ from = 1,
220
+ lines = 200,
221
+ ): Excerpt | null {
222
+ // Validate and read the SAME normalized path so the check can't diverge from
223
+ // what is actually opened.
224
+ const rel = relPath.replace(/\\/g, '/');
225
+ if (!isMemoryScopedPath(rel)) return null;
226
+ const abs = path.join(workspaceDir, rel);
227
+ if (!fs.existsSync(abs)) return null;
228
+ // Containment against symlink escape: a memory/x.md symlink pointing outside
229
+ // the workspace passes the string check but must not be readable through the
230
+ // tool. Resolve real paths and require the target stays under the workspace.
231
+ try {
232
+ const realBase = fs.realpathSync(workspaceDir);
233
+ const realTarget = fs.realpathSync(abs);
234
+ if (realTarget !== realBase && !realTarget.startsWith(realBase + path.sep)) return null;
235
+ } catch {
236
+ return null; // realpath failed (broken symlink, race) → refuse
237
+ }
238
+
239
+ const all = fs.readFileSync(abs, 'utf8').split('\n');
240
+ const total = all.length;
241
+ const start = Math.max(1, Math.floor(from));
242
+ const count = Math.max(1, Math.floor(lines));
243
+ const end = Math.min(total, start + count - 1);
244
+ const slice = start > total ? [] : all.slice(start - 1, end);
245
+ return {
246
+ path: relPath,
247
+ from: start,
248
+ to: start > total ? start : end,
249
+ text: slice.join('\n'),
250
+ totalLines: total,
251
+ truncated: end < total,
252
+ };
253
+ }