@vellumai/assistant 0.8.11 → 0.8.12-staging.2

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 (244) hide show
  1. package/ARCHITECTURE.md +15 -17
  2. package/README.md +0 -6
  3. package/bun.lock +6 -122
  4. package/node_modules/@vellumai/gateway-client/bun.lock +1 -0
  5. package/node_modules/@vellumai/gateway-client/package.json +3 -1
  6. package/node_modules/@vellumai/gateway-client/src/__tests__/gateway-client.test.ts +1 -1
  7. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +87 -0
  8. package/node_modules/@vellumai/gateway-client/src/index.ts +3 -5
  9. package/openapi.yaml +633 -4
  10. package/package.json +1 -3
  11. package/src/__tests__/adaptive-thinking-repair.test.ts +185 -0
  12. package/src/__tests__/agent-loop-compaction-events.test.ts +7 -6
  13. package/src/__tests__/anthropic-provider.test.ts +129 -0
  14. package/src/__tests__/background-workers-disk-pressure.test.ts +4 -1
  15. package/src/__tests__/btw-routes.test.ts +7 -34
  16. package/src/__tests__/checker.test.ts +6 -12
  17. package/src/__tests__/config-loader-backfill.test.ts +4 -2
  18. package/src/__tests__/config-loader-quarantine-notice.test.ts +167 -0
  19. package/src/__tests__/config-watcher.test.ts +2 -2
  20. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -1
  21. package/src/__tests__/conversation-error.test.ts +2 -6
  22. package/src/__tests__/conversation-history-web-search.test.ts +8 -0
  23. package/src/__tests__/conversation-title-service.test.ts +2 -1
  24. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  25. package/src/__tests__/disk-pressure-tools.test.ts +1 -1
  26. package/src/__tests__/exploration-drift-hook.test.ts +692 -0
  27. package/src/__tests__/filing-service.test.ts +8 -3
  28. package/src/__tests__/guardian-action-store.test.ts +0 -167
  29. package/src/__tests__/handlers-skills-memory-v2-reseed.test.ts +1 -1
  30. package/src/__tests__/heartbeat-disk-pressure.test.ts +4 -1
  31. package/src/__tests__/heartbeat-service.test.ts +5 -2
  32. package/src/__tests__/identity-intro-cache.test.ts +12 -5
  33. package/src/__tests__/identity-routes.test.ts +16 -57
  34. package/src/__tests__/injector-chain.test.ts +8 -3
  35. package/src/__tests__/injector-config-quarantine-notice.test.ts +115 -0
  36. package/src/__tests__/llm-catalog-parity.test.ts +16 -0
  37. package/src/__tests__/llm-usage-store.test.ts +11 -0
  38. package/src/__tests__/log-export-workspace.test.ts +468 -3
  39. package/src/__tests__/memory-v2-static-injector.test.ts +22 -0
  40. package/src/__tests__/model-intents.test.ts +1 -1
  41. package/src/__tests__/oauth-cli.test.ts +19 -8
  42. package/src/__tests__/openai-provider.test.ts +34 -0
  43. package/src/__tests__/prechat-onboarding-contract.test.ts +0 -1
  44. package/src/__tests__/recurrence-engine.test.ts +45 -0
  45. package/src/__tests__/schedule-routes.test.ts +34 -0
  46. package/src/__tests__/scheduler-disk-pressure.test.ts +1 -1
  47. package/src/__tests__/script-proxy-conversation-manager.test.ts +10 -5
  48. package/src/__tests__/secret-fixtures.ts +20 -0
  49. package/src/__tests__/skill-tool-factory.test.ts +49 -0
  50. package/src/__tests__/subagent-role-registry.test.ts +24 -1
  51. package/src/__tests__/subagent-tools.test.ts +1 -0
  52. package/src/__tests__/system-prompt.test.ts +109 -11
  53. package/src/__tests__/tool-approval-handler.test.ts +85 -0
  54. package/src/__tests__/tool-audit-listener.test.ts +86 -0
  55. package/src/__tests__/tool-error-hook.test.ts +1 -0
  56. package/src/__tests__/tool-result-spool.test.ts +337 -0
  57. package/src/__tests__/tool-result-truncate-hook.test.ts +1 -0
  58. package/src/__tests__/validate-input.test.ts +95 -1
  59. package/src/__tests__/workspace-migration-098-remove-stale-updates-bulletin-file.test.ts +65 -0
  60. package/src/__tests__/workspace-migration-099-disable-cache-one-shot-callsites.test.ts +139 -0
  61. package/src/__tests__/workspace-migration-100-upgrade-quality-profile-to-fable-5.test.ts +174 -0
  62. package/src/__tests__/workspace-migration-101-upgrade-balanced-economy-to-minimax-m3.test.ts +162 -0
  63. package/src/__tests__/workspace-release-notes-feature-flag-guard.test.ts +45 -95
  64. package/src/acp/__tests__/agent-process.test.ts +315 -2
  65. package/src/acp/__tests__/prepare-agent-env.test.ts +79 -5
  66. package/src/acp/agent-process.ts +163 -34
  67. package/src/acp/prepare-agent-env.ts +55 -15
  68. package/src/agent/loop.ts +81 -24
  69. package/src/api/events/usage-progress.ts +28 -0
  70. package/src/api/index.ts +6 -0
  71. package/src/background-wake/wake-intent-hooks.test.ts +2 -0
  72. package/src/bundler/app-bundler.ts +25 -42
  73. package/src/bundler/app-compiler.ts +8 -0
  74. package/src/calls/call-controller.ts +1 -1
  75. package/src/cli/commands/plugins.ts +248 -15
  76. package/src/cli/lib/__tests__/inspect-plugin.test.ts +318 -0
  77. package/src/cli/lib/__tests__/install-from-github.test.ts +16 -9
  78. package/src/cli/lib/__tests__/plugin-artifact.test.ts +183 -0
  79. package/src/cli/lib/__tests__/plugin-details.test.ts +158 -0
  80. package/src/cli/lib/__tests__/plugin-fingerprint.test.ts +245 -0
  81. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +307 -0
  82. package/src/cli/lib/inspect-plugin.ts +252 -0
  83. package/src/cli/lib/install-from-github.ts +214 -21
  84. package/src/cli/lib/list-installed-plugins.ts +17 -6
  85. package/src/cli/lib/plugin-artifact.ts +103 -0
  86. package/src/cli/lib/plugin-details.ts +18 -1
  87. package/src/cli/lib/plugin-fingerprint.ts +197 -0
  88. package/src/cli/lib/upgrade-plugin.ts +225 -0
  89. package/src/config/bundled-skills/subagent/SKILL.md +2 -0
  90. package/src/config/bundled-skills/subagent/TOOLS.json +8 -2
  91. package/src/config/call-site-defaults.ts +13 -2
  92. package/src/config/feature-flag-registry.json +8 -16
  93. package/src/config/loader.ts +52 -59
  94. package/src/config/schema.ts +0 -2
  95. package/src/config/schemas/__tests__/memory-v2.test.ts +1 -0
  96. package/src/config/schemas/__tests__/memory-v3.test.ts +10 -0
  97. package/src/config/schemas/llm.ts +10 -0
  98. package/src/config/schemas/memory-v2.ts +13 -0
  99. package/src/config/schemas/memory-v3.ts +92 -0
  100. package/src/config/seed-inference-profiles.ts +4 -8
  101. package/src/context/post-turn-tool-result-truncation.ts +32 -18
  102. package/src/context/tool-result-spool.ts +104 -0
  103. package/src/credential-execution/feature-gates.ts +0 -1
  104. package/src/daemon/conversation-agent-loop-handlers.ts +41 -16
  105. package/src/daemon/conversation-error.ts +6 -15
  106. package/src/daemon/conversation.ts +9 -0
  107. package/src/daemon/disk-pressure-policy.ts +0 -1
  108. package/src/daemon/lifecycle.ts +1 -20
  109. package/src/daemon/message-types/conversations.ts +2 -15
  110. package/src/daemon/trust-context.ts +1 -1
  111. package/src/events/tool-audit-listener.ts +40 -9
  112. package/src/heartbeat/__tests__/heartbeat-service.test.ts +1 -1
  113. package/src/home/__tests__/home-content-refresh.test.ts +114 -0
  114. package/src/home/__tests__/suggested-prompts.test.ts +86 -5
  115. package/src/home/home-content-refresh.ts +43 -31
  116. package/src/home/home-greeting-cache.ts +8 -1
  117. package/src/home/home-greeting.ts +13 -9
  118. package/src/home/suggested-prompts.ts +77 -24
  119. package/src/ipc/routes/trust-rules.test.ts +66 -72
  120. package/src/media/image-credentials.ts +2 -2
  121. package/src/memory/__tests__/compaction-log-store-clickhouse.test.ts +432 -0
  122. package/src/memory/{compaction-log-writer-clickhouse.ts → compaction-log-store-clickhouse.ts} +264 -55
  123. package/src/memory/conversation-attention-store.ts +1 -0
  124. package/src/memory/conversation-bootstrap.ts +18 -9
  125. package/src/memory/conversation-crud.ts +12 -2
  126. package/src/memory/conversation-title-service.ts +53 -9
  127. package/src/memory/delivery-channels.ts +0 -69
  128. package/src/memory/graph/extraction-job.ts +0 -15
  129. package/src/memory/guardian-action-store.ts +1 -376
  130. package/src/memory/llm-usage-store.ts +5 -1
  131. package/src/memory/migrations/181-rename-thread-starters-checkpoints.ts +2 -2
  132. package/src/memory/v2/__tests__/consolidation-job.test.ts +183 -2
  133. package/src/memory/v2/__tests__/injection.test.ts +70 -0
  134. package/src/memory/v2/__tests__/static-context.test.ts +12 -0
  135. package/src/memory/v2/consolidation-job.ts +93 -9
  136. package/src/memory/v2/injection.ts +53 -0
  137. package/src/memory/v2/prompts/consolidation.ts +1 -0
  138. package/src/memory/v2/static-context.ts +13 -1
  139. package/src/memory/v2/sweep-job.ts +1 -1
  140. package/src/memory/v2/types.ts +5 -0
  141. package/src/plugin-api/types.ts +7 -0
  142. package/src/plugins/defaults/exploration-drift/hooks/post-tool-use.ts +300 -0
  143. package/src/plugins/defaults/exploration-drift/package.json +15 -0
  144. package/src/plugins/defaults/index.ts +25 -0
  145. package/src/plugins/defaults/memory-retrieval/injectors.ts +132 -4
  146. package/src/plugins/defaults/memory-v3-shadow/__tests__/card.test.ts +92 -0
  147. package/src/plugins/defaults/memory-v3-shadow/__tests__/carry-integration.test.ts +2 -1
  148. package/src/plugins/defaults/memory-v3-shadow/__tests__/fresh-set.test.ts +52 -0
  149. package/src/plugins/defaults/memory-v3-shadow/__tests__/injection.test.ts +1 -0
  150. package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +2 -1
  151. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +136 -5
  152. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +17 -0
  153. package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +6 -0
  154. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +5 -1
  155. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +68 -4
  156. package/src/plugins/defaults/memory-v3-shadow/card.ts +49 -5
  157. package/src/plugins/defaults/memory-v3-shadow/fresh-set.ts +59 -0
  158. package/src/plugins/defaults/memory-v3-shadow/injector.ts +4 -2
  159. package/src/plugins/defaults/memory-v3-shadow/learned-edges.test.ts +169 -0
  160. package/src/plugins/defaults/memory-v3-shadow/learned-edges.ts +178 -0
  161. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +115 -26
  162. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +13 -9
  163. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +144 -22
  164. package/src/plugins/defaults/memory-v3-shadow/types.ts +24 -6
  165. package/src/plugins/defaults/title-generate/hooks/stop.ts +13 -0
  166. package/src/plugins/defaults/title-generate/hooks/user-prompt-submit.ts +16 -0
  167. package/src/prompts/cache-boundary.ts +17 -0
  168. package/src/prompts/sections.ts +50 -17
  169. package/src/prompts/system-prompt.ts +12 -4
  170. package/src/prompts/templates/system-sections.ts +22 -0
  171. package/src/providers/__tests__/unparseable-tool-args.test.ts +53 -0
  172. package/src/providers/anthropic/client.ts +74 -28
  173. package/src/providers/gemini/client.ts +5 -1
  174. package/src/providers/minimax/client.ts +9 -0
  175. package/src/providers/model-catalog.ts +28 -0
  176. package/src/providers/model-intents.ts +3 -3
  177. package/src/providers/openai/chat-completions-provider.ts +4 -2
  178. package/src/providers/openai/responses-provider.ts +7 -2
  179. package/src/providers/retry.ts +8 -0
  180. package/src/providers/types.ts +11 -0
  181. package/src/providers/unparseable-tool-args.ts +56 -0
  182. package/src/runtime/AGENTS.md +6 -0
  183. package/src/runtime/__tests__/agent-wake.test.ts +2 -2
  184. package/src/runtime/agent-wake.ts +5 -5
  185. package/src/runtime/background-job-runner.ts +2 -2
  186. package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +150 -3
  187. package/src/runtime/migrations/vbundle-import-analyzer.ts +29 -6
  188. package/src/runtime/migrations/vbundle-import-policy.ts +23 -0
  189. package/src/runtime/migrations/vbundle-importer.ts +9 -4
  190. package/src/runtime/migrations/vbundle-streaming-importer.ts +8 -3
  191. package/src/runtime/pre-first-message-gate.ts +1 -1
  192. package/src/runtime/routes/__tests__/conversation-compaction-routes.test.ts +241 -0
  193. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +132 -0
  194. package/src/runtime/routes/__tests__/gateway-log-routes.test.ts +97 -185
  195. package/src/runtime/routes/__tests__/home-feed-routes.test.ts +17 -0
  196. package/src/runtime/routes/__tests__/plugins-routes.test.ts +348 -0
  197. package/src/runtime/routes/__tests__/task-routes.test.ts +3 -3
  198. package/src/runtime/routes/btw-routes.ts +0 -14
  199. package/src/runtime/routes/conversation-compaction-routes.ts +86 -19
  200. package/src/runtime/routes/conversation-list-routes.ts +77 -5
  201. package/src/runtime/routes/conversation-management-routes.ts +54 -0
  202. package/src/runtime/routes/conversation-query-routes.ts +79 -4
  203. package/src/runtime/routes/gateway-log-routes.ts +14 -64
  204. package/src/runtime/routes/home-feed-routes.ts +10 -0
  205. package/src/runtime/routes/identity-intro-cache.ts +1 -1
  206. package/src/runtime/routes/identity-routes.ts +76 -20
  207. package/src/runtime/routes/inbound-message-handler.ts +0 -36
  208. package/src/runtime/routes/log-export-routes.ts +143 -96
  209. package/src/runtime/routes/plugins-routes.ts +380 -0
  210. package/src/runtime/routes/redact-staged-export.ts +259 -0
  211. package/src/runtime/routes/schedule-routes.ts +19 -2
  212. package/src/runtime/routes/trust-rules-routes.ts +14 -67
  213. package/src/schedule/recurrence-engine.ts +34 -0
  214. package/src/schedule/scheduler.ts +1 -0
  215. package/src/security/redact-json.ts +61 -0
  216. package/src/skills/validate-input.ts +41 -1
  217. package/src/subagent/types.ts +26 -1
  218. package/src/telemetry/types.ts +15 -1
  219. package/src/telemetry/usage-telemetry-reporter.test.ts +6 -1
  220. package/src/telemetry/usage-telemetry-reporter.ts +1 -0
  221. package/src/tools/apps/executors.ts +1 -1
  222. package/src/tools/skills/skill-tool-factory.ts +19 -8
  223. package/src/tools/tool-approval-handler.ts +31 -0
  224. package/src/usage/types.ts +8 -1
  225. package/src/util/platform.ts +16 -0
  226. package/src/watcher/engine.ts +1 -0
  227. package/src/workspace/adaptive-thinking-repair.ts +113 -0
  228. package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +70 -67
  229. package/src/workspace/migrations/098-remove-stale-updates-bulletin-file.ts +31 -0
  230. package/src/workspace/migrations/099-disable-cache-one-shot-callsites.ts +81 -0
  231. package/src/workspace/migrations/100-upgrade-quality-profile-to-fable-5.ts +86 -0
  232. package/src/workspace/migrations/101-upgrade-balanced-economy-to-minimax-m3.ts +70 -0
  233. package/src/workspace/migrations/registry.ts +8 -0
  234. package/src/__tests__/config-loader-quarantine-bulletin.test.ts +0 -202
  235. package/src/__tests__/conversation-starters-cadence.test.ts +0 -161
  236. package/src/__tests__/guardian-action-followup-executor.test.ts +0 -322
  237. package/src/__tests__/guardian-action-followup-store.test.ts +0 -373
  238. package/src/__tests__/guardian-action-late-reply.test.ts +0 -1083
  239. package/src/__tests__/update-bulletin-job.test.ts +0 -292
  240. package/src/config/schemas/updates.ts +0 -14
  241. package/src/memory/__tests__/compaction-log-writer-clickhouse.test.ts +0 -227
  242. package/src/memory/conversation-starters-cadence.ts +0 -78
  243. package/src/prompts/update-bulletin-job.ts +0 -180
  244. package/src/runtime/guardian-action-followup-executor.ts +0 -306
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Tests for the learned-edge lane (`learned-edges.ts`).
3
+ *
4
+ * `computeLearnedEdgeGraph` takes the db handle directly (no module mocks):
5
+ * each test seeds an in-memory SQLite db with `memory_v3_selections` rows
6
+ * (via the real migration) and asserts the NPMI association graph. Rows
7
+ * sharing a `(conversation_id, created_at)` form one selector call.
8
+ */
9
+
10
+ import { Database } from "bun:sqlite";
11
+ import { beforeEach, describe, expect, test } from "bun:test";
12
+
13
+ import { drizzle } from "drizzle-orm/bun-sqlite";
14
+
15
+ import { migrateAddMemoryV3Selections } from "../../../memory/migrations/268-add-memory-v3-selections.js";
16
+ import * as schema from "../../../memory/schema.js";
17
+ import {
18
+ computeLearnedEdgeGraph,
19
+ type LearnedEdgesOptions,
20
+ } from "./learned-edges.js";
21
+
22
+ const HALF_LIFE_MS = 1_000_000;
23
+ const NOW = 100_000;
24
+
25
+ let sqlite: Database;
26
+ let db: ReturnType<typeof drizzle<typeof schema>>;
27
+
28
+ beforeEach(() => {
29
+ sqlite = new Database(":memory:");
30
+ db = drizzle(sqlite, { schema });
31
+ migrateAddMemoryV3Selections(db);
32
+ });
33
+
34
+ let nextTurn = 0;
35
+ /** Insert one selector call: every slug shares the same (conv, created_at). */
36
+ function seedCall(slugs: string[], createdAt = NOW, conv = "conv-1"): void {
37
+ const stmt = sqlite.query(
38
+ `INSERT INTO memory_v3_selections (conversation_id, turn, slug, source, pinned, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
39
+ );
40
+ const turn = nextTurn++;
41
+ for (const slug of slugs) {
42
+ stmt.run(conv, turn, slug, "needle", 0, createdAt);
43
+ }
44
+ }
45
+
46
+ function graphOf(overrides: Partial<LearnedEdgesOptions> = {}) {
47
+ return computeLearnedEdgeGraph(
48
+ { db },
49
+ {
50
+ halfLifeMs: HALF_LIFE_MS,
51
+ minCount: 2,
52
+ npmiFloor: 0.2,
53
+ maxPerPage: 6,
54
+ now: NOW,
55
+ windowMs: NOW,
56
+ knownSlugs: new Set([
57
+ "page-a",
58
+ "page-b",
59
+ "page-c",
60
+ "page-d",
61
+ "skills/widget",
62
+ ]),
63
+ ...overrides,
64
+ },
65
+ );
66
+ }
67
+
68
+ const peersOf = (
69
+ graph: ReturnType<typeof computeLearnedEdgeGraph>,
70
+ slug: string,
71
+ ): string[] => [...(graph.adjacency.get(slug)?.keys() ?? [])];
72
+
73
+ describe("computeLearnedEdgeGraph", () => {
74
+ test("pages selected together (and rarely apart) form a symmetric edge", () => {
75
+ // a+b co-select twice; c selects alone twice (so a/b are not ubiquitous).
76
+ seedCall(["page-a", "page-b"], NOW, "conv-1");
77
+ seedCall(["page-a", "page-b"], NOW, "conv-2");
78
+ seedCall(["page-c"], NOW, "conv-3");
79
+ seedCall(["page-c"], NOW, "conv-4");
80
+
81
+ const graph = graphOf();
82
+ expect(peersOf(graph, "page-a")).toEqual(["page-b"]);
83
+ expect(peersOf(graph, "page-b")).toEqual(["page-a"]);
84
+ expect(graph.hubs.size).toBe(0);
85
+ });
86
+
87
+ test("a page selected in every call forms no edges (NPMI → 0)", () => {
88
+ // page-a rides every call; page-b/page-c each appear half the time.
89
+ seedCall(["page-a", "page-b"], NOW, "conv-1");
90
+ seedCall(["page-a", "page-b"], NOW, "conv-2");
91
+ seedCall(["page-a", "page-c"], NOW, "conv-3");
92
+ seedCall(["page-a", "page-c"], NOW, "conv-4");
93
+
94
+ const graph = graphOf();
95
+ // p(a) = 1 ⇒ p(a,b) = p(b) ⇒ npmi = 0, floored out — for every pairing.
96
+ expect(peersOf(graph, "page-a")).toEqual([]);
97
+ expect(peersOf(graph, "page-b")).toEqual([]);
98
+ });
99
+
100
+ test("pairs below the co-occurrence mass floor form no edge", () => {
101
+ seedCall(["page-a", "page-b"], NOW, "conv-1"); // mass ≈ 1 < minCount 2
102
+ seedCall(["page-c"], NOW, "conv-2");
103
+ seedCall(["page-d"], NOW, "conv-3");
104
+
105
+ const graph = graphOf();
106
+ expect(peersOf(graph, "page-a")).toEqual([]);
107
+ });
108
+
109
+ test("decay pushes old co-selections below the mass floor", () => {
110
+ // Two co-selections ten half-lives ago: decayed mass ≈ 0.002 < 2.
111
+ seedCall(["page-a", "page-b"], NOW - 10 * HALF_LIFE_MS, "conv-1");
112
+ seedCall(["page-a", "page-b"], NOW - 10 * HALF_LIFE_MS, "conv-2");
113
+ seedCall(["page-c"], NOW, "conv-3");
114
+
115
+ const graph = graphOf({ windowMs: 20 * HALF_LIFE_MS });
116
+ expect(peersOf(graph, "page-a")).toEqual([]);
117
+ });
118
+
119
+ test("maxPerPage keeps the strongest associations first", () => {
120
+ // page-a co-selects with b twice and with c once alongside others, making
121
+ // a↔b the stronger association; cap page-a to one edge.
122
+ seedCall(["page-a", "page-b"], NOW, "conv-1");
123
+ seedCall(["page-a", "page-b"], NOW, "conv-2");
124
+ seedCall(["page-a", "page-c", "page-d"], NOW, "conv-3");
125
+ seedCall(["page-a", "page-c", "page-d"], NOW, "conv-4");
126
+ seedCall(["page-d"], NOW, "conv-5");
127
+ seedCall(["page-d"], NOW, "conv-6");
128
+
129
+ const graph = graphOf({ maxPerPage: 1 });
130
+ expect(peersOf(graph, "page-a")).toEqual(["page-b"]);
131
+ });
132
+
133
+ test("capability slugs participate like any page", () => {
134
+ seedCall(["skills/widget", "page-a"], NOW, "conv-1");
135
+ seedCall(["skills/widget", "page-a"], NOW, "conv-2");
136
+ seedCall(["page-b"], NOW, "conv-3");
137
+ seedCall(["page-b"], NOW, "conv-4");
138
+
139
+ const graph = graphOf();
140
+ expect(peersOf(graph, "skills/widget")).toEqual(["page-a"]);
141
+ expect(peersOf(graph, "page-a")).toEqual(["skills/widget"]);
142
+ });
143
+
144
+ test("slugs outside knownSlugs never form edges", () => {
145
+ seedCall(["page-a", "deleted-page"], NOW, "conv-1");
146
+ seedCall(["page-a", "deleted-page"], NOW, "conv-2");
147
+ seedCall(["page-b"], NOW, "conv-3");
148
+
149
+ const graph = graphOf();
150
+ expect(peersOf(graph, "page-a")).toEqual([]);
151
+ expect(graph.adjacency.has("deleted-page")).toBe(false);
152
+ });
153
+
154
+ test("maxPerPage = 0 disables the lane", () => {
155
+ seedCall(["page-a", "page-b"], NOW, "conv-1");
156
+ seedCall(["page-a", "page-b"], NOW, "conv-2");
157
+
158
+ const graph = graphOf({ maxPerPage: 0 });
159
+ expect(graph.adjacency.size).toBe(0);
160
+ });
161
+
162
+ test("rows outside the scan window are ignored", () => {
163
+ seedCall(["page-a", "page-b"], NOW - 50_000, "conv-1");
164
+ seedCall(["page-a", "page-b"], NOW - 60_000, "conv-2");
165
+
166
+ const graph = graphOf({ windowMs: 10_000 });
167
+ expect(graph.adjacency.size).toBe(0);
168
+ });
169
+ });
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Learned-edge lane: a co-selection association graph over
3
+ * `memory_v3_selections`, scored by NPMI (normalized pointwise mutual
4
+ * information).
5
+ *
6
+ * Two pages that keep getting SELECTED together are associated in how the
7
+ * corpus is actually used, whether or not any authored `[[link]]` connects
8
+ * them. That behavioral signal is exactly what the static edge lane cannot
9
+ * see, and it reaches the candidate class no other lane can: pages relevant
10
+ * by association, with no lexical/semantic overlap with the query and no
11
+ * curated link from any seed. Capability slugs (skills / CLI commands)
12
+ * participate like any page — a skill co-selected with a topic page is a
13
+ * real association worth following.
14
+ *
15
+ * Scoring: selections are grouped into selector calls
16
+ * (`conversation_id` + `created_at`); each call contributes a
17
+ * recency-decayed weight `2^(−age / halfLifeMs)` to every slug (unigram) and
18
+ * unordered slug-pair (co-occurrence) it selected. NPMI per pair:
19
+ *
20
+ * npmi(a,b) = ln(p(a,b) / (p(a)·p(b))) / −ln(p(a,b))
21
+ *
22
+ * NPMI is what makes the lane safe around the stable-prefix pages: a page
23
+ * selected in (nearly) every call has p(a) ≈ 1, so p(a,b) ≈ p(b) and the
24
+ * numerator collapses to ~0 — ubiquitous pages form no edges, mathematically,
25
+ * rather than by special-casing. The `minCount` mass floor suppresses
26
+ * rare-pair noise (NPMI explodes on coincidences), and `maxPerPage` bounds
27
+ * each page's out-degree, which is the lane's real noise control.
28
+ *
29
+ * The result is emitted as an {@link EdgeGraph} so expansion REUSES
30
+ * `edgeExpand` unchanged. Neighbors are inserted in NPMI-descending order —
31
+ * `edgeExpand` takes neighbors in insertion order, so its `perSeed` budget
32
+ * consumes the strongest associations first. The graph carries no hubs
33
+ * (the per-page cap is the degree control) and no curated descriptions
34
+ * (the orchestrator falls back to a section descriptor).
35
+ *
36
+ * Like the other computed lanes, the graph is built at lane init and rebuilt
37
+ * on lane invalidation (the consolidation cadence). The selections table is
38
+ * the persistence; nothing else is stored.
39
+ */
40
+
41
+ import {
42
+ type DrizzleDb,
43
+ getSqliteFrom,
44
+ } from "../../../memory/db-connection.js";
45
+ import type { EdgeGraph } from "./edge.js";
46
+ import type { Slug } from "./types.js";
47
+
48
+ export interface LearnedEdgesDeps {
49
+ /** Handle to the database containing `memory_v3_selections`. */
50
+ db: DrizzleDb;
51
+ }
52
+
53
+ export interface LearnedEdgesOptions {
54
+ /** Decay half-life in milliseconds: a selector call this old contributes
55
+ * half the weight of one made now. */
56
+ halfLifeMs: number;
57
+ /** Minimum decayed co-occurrence mass for a pair to form an edge — the
58
+ * rare-pair noise floor (NPMI explodes on coincidences). */
59
+ minCount: number;
60
+ /** Minimum NPMI for a pair to form an edge. */
61
+ npmiFloor: number;
62
+ /** Maximum out-edges kept per page (strongest-NPMI first); `0` disables
63
+ * the lane (empty graph). */
64
+ maxPerPage: number;
65
+ /** Reference timestamp (ms epoch) ages are measured against. */
66
+ now: number;
67
+ /** Only selections younger than this window are read. At a 30-day
68
+ * half-life, rows beyond ~90 days carry negligible weight anyway — the
69
+ * window bounds the scan, not the math. */
70
+ windowMs: number;
71
+ /** Slugs that may form edges — the live section-index membership, so
72
+ * selections of since-deleted pages can never surface a dangling
73
+ * candidate. */
74
+ knownSlugs: Set<string>;
75
+ }
76
+
77
+ interface SelectionRow {
78
+ conversation_id: string;
79
+ slug: string;
80
+ created_at: number;
81
+ }
82
+
83
+ /**
84
+ * Build the learned-edge {@link EdgeGraph} from the selection log.
85
+ *
86
+ * Deterministic for fixed inputs: per-page neighbors order by NPMI desc, then
87
+ * peer slug asc. Symmetric by construction — an edge contributes to BOTH
88
+ * endpoints' neighbor lists (each independently subject to `maxPerPage`).
89
+ */
90
+ export function computeLearnedEdgeGraph(
91
+ deps: LearnedEdgesDeps,
92
+ opts: LearnedEdgesOptions,
93
+ ): EdgeGraph {
94
+ const { halfLifeMs, minCount, npmiFloor, maxPerPage, now, windowMs } = opts;
95
+ const empty: EdgeGraph = {
96
+ adjacency: new Map(),
97
+ hubs: new Set(),
98
+ slugs: opts.knownSlugs,
99
+ };
100
+ if (maxPerPage <= 0) return empty;
101
+
102
+ const rows = getSqliteFrom(deps.db)
103
+ .query(
104
+ /*sql*/ `
105
+ SELECT conversation_id, slug, created_at FROM memory_v3_selections
106
+ WHERE created_at >= ?
107
+ `,
108
+ )
109
+ .all(now - windowMs) as SelectionRow[];
110
+ if (rows.length === 0) return empty;
111
+
112
+ // Group into selector calls; one decayed weight per call.
113
+ const calls = new Map<string, { weight: number; slugs: Set<Slug> }>();
114
+ for (const row of rows) {
115
+ if (!opts.knownSlugs.has(row.slug)) continue;
116
+ const key = `${row.conversation_id}|${row.created_at}`;
117
+ let call = calls.get(key);
118
+ if (!call) {
119
+ const age = Math.max(0, now - row.created_at);
120
+ call = { weight: 2 ** (-age / halfLifeMs), slugs: new Set() };
121
+ calls.set(key, call);
122
+ }
123
+ call.slugs.add(row.slug);
124
+ }
125
+
126
+ // Decayed unigram and pair masses. Pair keys order endpoints
127
+ // lexicographically so (a,b) and (b,a) accumulate together.
128
+ const uni = new Map<Slug, number>();
129
+ const pair = new Map<string, number>();
130
+ let total = 0;
131
+ for (const { weight, slugs } of calls.values()) {
132
+ total += weight;
133
+ const arr = [...slugs];
134
+ for (const slug of arr) uni.set(slug, (uni.get(slug) ?? 0) + weight);
135
+ for (let i = 0; i < arr.length; i++) {
136
+ for (let j = i + 1; j < arr.length; j++) {
137
+ const key =
138
+ arr[i]! < arr[j]! ? `${arr[i]}\t${arr[j]}` : `${arr[j]}\t${arr[i]}`;
139
+ pair.set(key, (pair.get(key) ?? 0) + weight);
140
+ }
141
+ }
142
+ }
143
+ if (total <= 0) return empty;
144
+
145
+ // Score pairs and collect each endpoint's qualifying neighbors.
146
+ const neighbors = new Map<Slug, Array<{ peer: Slug; npmi: number }>>();
147
+ const addNeighbor = (from: Slug, peer: Slug, npmi: number): void => {
148
+ let list = neighbors.get(from);
149
+ if (!list) {
150
+ list = [];
151
+ neighbors.set(from, list);
152
+ }
153
+ list.push({ peer, npmi });
154
+ };
155
+ for (const [key, mass] of pair) {
156
+ if (mass < minCount) continue;
157
+ const [a, b] = key.split("\t") as [Slug, Slug];
158
+ const pab = mass / total;
159
+ const pa = uni.get(a)! / total;
160
+ const pb = uni.get(b)! / total;
161
+ const npmi = Math.log(pab / (pa * pb)) / -Math.log(pab);
162
+ if (npmi <= npmiFloor) continue;
163
+ addNeighbor(a, b, npmi);
164
+ addNeighbor(b, a, npmi);
165
+ }
166
+
167
+ // Emit adjacency with neighbors in NPMI-desc order (edgeExpand consumes
168
+ // insertion order), capped per page.
169
+ const adjacency = new Map<Slug, Map<Slug, string | undefined>>();
170
+ for (const [slug, list] of neighbors) {
171
+ list.sort((x, y) => y.npmi - x.npmi || (x.peer < y.peer ? -1 : 1));
172
+ const out = new Map<Slug, string | undefined>();
173
+ for (const { peer } of list.slice(0, maxPerPage)) out.set(peer, undefined);
174
+ adjacency.set(slug, out);
175
+ }
176
+
177
+ return { adjacency, hubs: new Set(), slugs: opts.knownSlugs };
178
+ }
@@ -2,21 +2,29 @@
2
2
  * Memory v3 — orchestrator composing the candidate lanes into one turn.
3
3
  *
4
4
  * Each turn runs:
5
- * 1. Candidate generation over three deterministic finder lanes:
5
+ * 1. Candidate generation over the deterministic finder lanes:
6
6
  * - the section-grain BM25 needle (`SectionNeedle.query`, KB articles),
7
- * - the dense lane (`denseLane`, KD articles), and
8
- * - link-graph edge expansion (`edgeExpand`) over the top needle+dense
9
- * article seeds.
7
+ * - the dense lane (`denseLane`, KD articles),
8
+ * - the reply-query pass — needle + dense re-run over the assistant's
9
+ * PREVIOUS message as separate queries at a smaller budget
10
+ * (`replyQueryK` per lane), surfacing the threads the assistant is
11
+ * actively developing that the user's message references without
12
+ * naming — and
13
+ * - link-graph edge expansion (`edgeExpand`) over the top
14
+ * user-message needle+dense article seeds, and
15
+ * - learned-edge expansion (`edgeExpand` over the co-selection NPMI
16
+ * graph from `learned-edges.ts`) over the same seeds — behavioral
17
+ * associations the authored link graph does not record.
10
18
  * Each lane only ever ADDS candidates, so the pool is recall-safe by
11
19
  * construction.
12
20
  * 2. Build the candidate pool in CACHE ORDER: the stable prefix —
13
- * `[...core (file order), ...hot (score order)]`, both computed at lane
14
- * init — followed by the finder candidates (needle → dense → edge
15
- * surfacing order). The stable prefix is identical across consecutive
16
- * turns while the lanes are unchanged (lane invalidation at consolidation
17
- * is the recompute cadence), so the selector input's leading segment
18
- * rides the provider KV cache (the cache breakpoint itself lives in
19
- * `pool-select.ts`).
21
+ * `[...core (file order), ...hot (score order), ...fresh (recency
22
+ * order)]`, all computed at lane init — followed by the finder
23
+ * candidates (needle → dense → edge surfacing order). The stable prefix
24
+ * is identical across consecutive turns while the lanes are unchanged
25
+ * (lane invalidation at consolidation is the recompute cadence), so the
26
+ * selector input's leading segment rides the provider KV cache (the
27
+ * cache breakpoint itself lives in `pool-select.ts`).
20
28
  *
21
29
  * Stable-prefix candidates render as FULL CARDS (`renderCard` — head
22
30
  * section + TOC), pre-rendered at lane init (`prefixCards`) so the
@@ -71,16 +79,23 @@ export interface OrchestrateDeps {
71
79
  /** The frecency hot set in score order (computed at lane init with the core
72
80
  * set excluded). Follows core in the stable prefix. */
73
81
  hotSlugs: Slug[];
82
+ /** The modification-recency fresh set in recency order (computed at lane
83
+ * init with core and hot excluded). Follows hot in the stable prefix. */
84
+ freshSlugs: Slug[];
74
85
  /** Pre-rendered FULL cards for the stable-prefix slugs, keyed by slug.
75
86
  * Rendered ONCE at lane init so the selector's stable prefix is
76
- * byte-identical across turns (the cache contract). Every core/hot slug
77
- * MUST have an entry — a missing card is a lane-init bug and throws
87
+ * byte-identical across turns (the cache contract). Every core/hot/fresh
88
+ * slug MUST have an entry — a missing card is a lane-init bug and throws
78
89
  * (silently degrading would violate the byte-stable-prefix contract). */
79
90
  prefixCards: ReadonlyMap<Slug, string>;
80
91
  /** Number of BM25 needle articles. Defaults to {@link DEFAULT_NEEDLE_K}. */
81
92
  needleK?: number;
82
93
  /** Number of dense-lane articles. Defaults to {@link DEFAULT_DENSE_K}. */
83
94
  denseK?: number;
95
+ /** Per-lane article budget for the reply-query pass (needle + dense re-run
96
+ * over `turn.previousAssistantMessage` as separate queries). `0` or
97
+ * omitted disables the pass (canonical value: `memory.v3.replyQueryK`). */
98
+ replyQueryK?: number;
84
99
  /** Number of top needle+dense seeds expanded. When omitted, the edge lane's
85
100
  * own default applies (canonical value: `memory.v3.edge.seedCount`). */
86
101
  edgeSeeds?: number;
@@ -90,6 +105,15 @@ export interface OrchestrateDeps {
90
105
  /** Hard cap on total edge-lane surfaced articles. When omitted, the edge
91
106
  * lane's own default applies (canonical value: `memory.v3.edge.cap`). */
92
107
  edgeCap?: number;
108
+ /** The learned-edge graph (co-selection NPMI associations, built at lane
109
+ * init). Omitted or empty → no learned pass. */
110
+ learnedGraph?: EdgeGraph;
111
+ /** Learned neighbours surfaced per expanded seed (canonical value:
112
+ * `memory.v3.learnedEdges.perSeed`). */
113
+ learnedPerSeed?: number;
114
+ /** Hard cap on total learned-lane surfaced articles; `0` disables the pass
115
+ * (canonical value: `memory.v3.learnedEdges.cap`). */
116
+ learnedCap?: number;
93
117
  }
94
118
 
95
119
  /** A finder-lane candidate: the slug, the descriptor that justified it, and
@@ -101,16 +125,19 @@ export interface FinderCandidate {
101
125
  }
102
126
 
103
127
  /**
104
- * The three candidate lanes in cache order. `core` and `hot` are the stable
105
- * prefix (byte-identical across turns while lanes are unchanged); `finder` is
106
- * the dynamic tail and MAY repeat a stable-prefix slug (a finder hit on a
107
- * core/hot page is kept so its current relevance stays visible downstream).
128
+ * The candidate lanes in cache order. `core`, `hot`, and `fresh` are the
129
+ * stable prefix (byte-identical across turns while lanes are unchanged);
130
+ * `finder` is the dynamic tail and MAY repeat a stable-prefix slug (a finder
131
+ * hit on a stable-prefix page is kept so its current relevance stays visible
132
+ * downstream).
108
133
  */
109
134
  export interface OrchestrateLanes {
110
135
  /** Curated core set, file order. */
111
136
  core: Slug[];
112
137
  /** Frecency hot set, score order (never overlaps core). */
113
138
  hot: Slug[];
139
+ /** Modification-recency fresh set, recency order (never overlaps core/hot). */
140
+ fresh: Slug[];
114
141
  /** Finder candidates in surfacing order, deduped among themselves only. */
115
142
  finder: FinderCandidate[];
116
143
  }
@@ -144,19 +171,34 @@ export async function orchestrate(
144
171
  const denseK = deps.denseK ?? DEFAULT_DENSE_K;
145
172
  const { sections } = deps.sectionIndex;
146
173
 
147
- // The stable prefix: core (file order) then hot (score order). Hot is
148
- // computed with core excluded at lane init; the filter here is a cheap
149
- // defensive dedup so a misbehaving lane can never double-list a slug.
174
+ // The stable prefix: core (file order), hot (score order), fresh (recency
175
+ // order). Hot is computed with core excluded at lane init, fresh with both
176
+ // excluded; the filters here are a cheap defensive dedup so a misbehaving
177
+ // lane can never double-list a slug.
150
178
  const core = deps.coreSlugs;
151
179
  const hot = deps.hotSlugs.filter((slug) => !core.includes(slug));
152
- const stablePrefix = new Set<Slug>([...core, ...hot]);
180
+ const coreHot = new Set<Slug>([...core, ...hot]);
181
+ const fresh = deps.freshSlugs.filter((slug) => !coreHot.has(slug));
182
+ const stablePrefix = new Set<Slug>([...coreHot, ...fresh]);
153
183
 
154
184
  // Step 1: needle (sync BM25) and dense (async embed + Qdrant) lanes run in
155
185
  // parallel. Both return distinct articles each tagged with their best-scoring
156
- // section index/ordinal.
157
- const [needled, densed] = await Promise.all([
186
+ // section index/ordinal. The reply-query pass re-runs both lanes over the
187
+ // assistant's previous message as SEPARATE queries (concatenating the two
188
+ // speakers would average their retrieval intents into a vector that matches
189
+ // neither) at its own, smaller budget; it runs in the same parallel batch.
190
+ const replyK = deps.replyQueryK ?? 0;
191
+ const replyQuery =
192
+ replyK > 0 ? (turn.previousAssistantMessage ?? "").trim() : "";
193
+ const [needled, densed, replyNeedled, replyDensed] = await Promise.all([
158
194
  Promise.resolve(deps.needle.query(turn.currentMessage, needleK)),
159
195
  denseLane(deps.denseConfig, turn.currentMessage, denseK),
196
+ Promise.resolve(
197
+ replyQuery.length > 0 ? deps.needle.query(replyQuery, replyK) : [],
198
+ ),
199
+ replyQuery.length > 0
200
+ ? denseLane(deps.denseConfig, replyQuery, replyK)
201
+ : Promise.resolve([]),
160
202
  ]);
161
203
 
162
204
  // `matchedSections` records the matched `Section` (when one is known) for
@@ -211,6 +253,24 @@ export async function orchestrate(
211
253
  );
212
254
  }
213
255
 
256
+ // Step 1b': reply-query hits — candidates the user-message lanes already
257
+ // surfaced keep their primary attribution (`addFinder`'s first-lane-wins
258
+ // dedup); only genuinely reply-surfaced articles tag `"reply"`. Matched
259
+ // sections are recorded the same way as the primary lanes', so injection
260
+ // and the spotlight render the reply-matched section.
261
+ for (const hit of replyNeedled) {
262
+ addFinder(hit.article, sections[hit.section], undefined, "reply");
263
+ }
264
+ for (const hit of replyDensed) {
265
+ if (!deps.sectionIndex.byArticle.has(hit.article)) continue;
266
+ addFinder(
267
+ hit.article,
268
+ sectionByOrdinal(deps.sectionIndex, hit.article, hit.section),
269
+ undefined,
270
+ "reply",
271
+ );
272
+ }
273
+
214
274
  // Step 1c: edge expansion over the top needle+dense article seeds. `alive`
215
275
  // skips slugs already in the pool — finder-surfaced AND stable-prefix slugs
216
276
  // (an edge hit carries no matched section, so re-surfacing a core/hot page
@@ -245,8 +305,36 @@ export async function orchestrate(
245
305
  );
246
306
  }
247
307
 
308
+ // Step 1d: learned-edge expansion over the SAME seeds, through the
309
+ // co-selection NPMI graph. Mirrors the static edge pass — no matched
310
+ // section (association, not lexical relevance, surfaced the page; injection
311
+ // falls back to the full page), `bestSection` text as the descriptor
312
+ // fallback — but tags `"learned"` so the lane's selection rate is
313
+ // measurable. Runs after the static lane: an association that duplicates an
314
+ // authored link keeps its `"edge"` attribution.
315
+ if (deps.learnedGraph && (deps.learnedCap ?? 0) > 0) {
316
+ const learned = edgeExpand(deps.learnedGraph, seeds, {
317
+ seedCount: deps.edgeSeeds,
318
+ perSeed: deps.learnedPerSeed,
319
+ cap: deps.learnedCap,
320
+ alive: (slug) => !finderSeen.has(slug) && !stablePrefix.has(slug),
321
+ });
322
+ for (const neighbor of learned) {
323
+ const best = deps.needle.bestSection(
324
+ neighbor.article,
325
+ turn.currentMessage,
326
+ );
327
+ addFinder(
328
+ neighbor.article,
329
+ undefined,
330
+ best >= 0 ? sections[best]?.text : undefined,
331
+ "learned",
332
+ );
333
+ }
334
+ }
335
+
248
336
  // Step 2: assemble the selector pool in cache order — the stable prefix
249
- // (core then hot) as FULL CARDS, then the finder tail. Cards are
337
+ // (core, hot, fresh) as FULL CARDS, then the finder tail. Cards are
250
338
  // pre-rendered at lane init (`prefixCards`): query- AND
251
339
  // conversation-state-INDEPENDENT by design, so the rendered prefix is
252
340
  // byte-identical across turns while the lanes are unchanged. The tail is
@@ -254,7 +342,7 @@ export async function orchestrate(
254
342
  // its own snippet line so its CURRENT relevance stays visible; `selectPool`
255
343
  // dedupes selections by slug. Finder candidates with no match text fall
256
344
  // back to the page's lead-section snippet.
257
- const stable: StableCandidate[] = [...core, ...hot].map((slug) => {
345
+ const stable: StableCandidate[] = [...core, ...hot, ...fresh].map((slug) => {
258
346
  const card = deps.prefixCards.get(slug);
259
347
  if (card === undefined) {
260
348
  // Lane init renders a card for every core/hot slug; a hole here means
@@ -269,6 +357,7 @@ export async function orchestrate(
269
357
  });
270
358
  const finderTail: PoolCandidate[] = finder.map((c) => ({
271
359
  slug: c.slug,
360
+ lane: c.lane,
272
361
  descriptor:
273
362
  c.descriptor.trim().length > 0
274
363
  ? c.descriptor
@@ -283,7 +372,7 @@ export async function orchestrate(
283
372
  return {
284
373
  selections,
285
374
  matchedSections,
286
- lanes: { core, hot, finder },
375
+ lanes: { core, hot, fresh, finder },
287
376
  };
288
377
  }
289
378
 
@@ -62,10 +62,12 @@ const log = getLogger("memory-v3-pool-select");
62
62
 
63
63
  /** A dynamic-tail (finder) candidate: the slug plus the descriptor that
64
64
  * justifies it — a matched section for a needle/dense hit, or a curated link
65
- * description for an edge page. Rendered as a one-line snippet. */
65
+ * description for an edge page. Rendered as a one-line snippet, prefixed
66
+ * with the surfacing lane when one is supplied. */
66
67
  export interface PoolCandidate {
67
68
  slug: Slug;
68
69
  descriptor: string;
70
+ lane?: string;
69
71
  }
70
72
 
71
73
  /** A stable-prefix candidate: the slug plus its pre-rendered FULL card
@@ -127,13 +129,13 @@ const SELECT_PAGES_TOOL: ToolDefinition = {
127
129
  },
128
130
  };
129
131
 
130
- const SYSTEM_PROMPT = `You are given the candidate memory pages for an assistant's next reply, in two segments that share one numbering: full page cards first (the curated core and recently-recurring pages, shown every turn), then this turn's search hits as one-line snippets.
132
+ const SYSTEM_PROMPT = `You are given the candidate memory pages for an assistant's next reply, in two segments that share one numbering: full page cards first (the curated core, recently-recurring, and recently-modified pages, shown every turn), then this turn's search hits as one-line snippets. Cards carry a \`[lane: …]\` annotation — core is curated, hot recurs by selection frequency, fresh was recently modified (with its last-update time) — and search hits are tagged with the lane that surfaced them.
131
133
 
132
- Select EVERY candidate whose content the upcoming reply would draw on. That includes facts the reply needs, but equally register, established framing, calibration rules, and relationship/person/project texture — pages that shape HOW to reply, not only what to say. There is no limit on how many you may select; recall matters more than precision, so when a candidate could plausibly inform the reply, keep it. For a list or an "all of X" request, keep EVERY candidate that belongs to X rather than guessing a representative subset.
134
+ Select EVERY candidate whose content the upcoming reply would draw on. That includes facts the reply needs, current task and event state (open items, deadlines, schedules, recent activity), and equally register, established framing, calibration rules, and relationship/person/project texture — pages that shape HOW to reply, not only what to say. There is no limit on how many you may select; recall matters more than precision, so when a candidate could plausibly inform the reply, keep it. For a list or an "all of X" request, keep EVERY candidate that belongs to X rather than guessing a representative subset.
133
135
 
134
136
  Pages you select persist in the conversation automatically, and re-selecting a page that is already in context is harmless (duplicates are removed downstream) — so never withhold a candidate because it might have been selected before. Judge each candidate only by whether the upcoming reply would draw on it.
135
137
 
136
- A page can be relevant because of the current situation — the date or the live scratchpad — not only the message: keep a page the situation makes pertinent (e.g. a person whose anniversary is today).
138
+ A page can be relevant because of the current situation — the date or the live scratchpad — not only the message: keep a page the situation makes pertinent (e.g. a person whose anniversary is today). When the message asks about status, plans, schedule, or what's pending, treat pages carrying current task/event state — especially recently-updated (fresh) ones — as first-class candidates.
137
139
 
138
140
  If the conversation is centrally ABOUT a page (rather than only peripherally relevant to it), mark that page as pinned. Call \`select_pages\` with the chosen IDs. Omit \`ids\` only as a recall-safe fallback when you cannot judge the pool (keeps every candidate); return \`[]\` when candidates are present but none are relevant.`;
139
141
 
@@ -153,17 +155,19 @@ function renderCardSegment(stable: StableCandidate[]): string {
153
155
  }
154
156
 
155
157
  /**
156
- * Render the finder tail: one `[m+i] slug — snippet` line per candidate,
157
- * numbered continuing after the `offset` stable-prefix cards. A candidate
158
- * with an empty descriptor renders without the dash.
158
+ * Render the finder tail: one `[m+i] (lane) slug — snippet` line per
159
+ * candidate, numbered continuing after the `offset` stable-prefix cards. The
160
+ * lane tag is omitted for a candidate without one; a candidate with an empty
161
+ * descriptor renders without the dash.
159
162
  */
160
163
  function renderFinderSegment(finder: PoolCandidate[], offset: number): string {
161
164
  const lines = finder.map((c, i) => {
162
165
  const snippet = renderSnippet(c.descriptor);
163
166
  const id = offset + i + 1;
167
+ const lane = c.lane !== undefined ? `(${c.lane}) ` : "";
164
168
  return snippet.length > 0
165
- ? `[${id}] ${c.slug} — ${snippet}`
166
- : `[${id}] ${c.slug}`;
169
+ ? `[${id}] ${lane}${c.slug} — ${snippet}`
170
+ : `[${id}] ${lane}${c.slug}`;
167
171
  });
168
172
  return `<candidates>\n${lines.join("\n")}\n</candidates>`;
169
173
  }