@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,52 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { computeFreshSet } from "../fresh-set.js";
4
+
5
+ const entry = (slug: string, modifiedAt: number) => ({ slug, modifiedAt });
6
+
7
+ describe("computeFreshSet", () => {
8
+ test("ranks by modification time, newest first", () => {
9
+ const slugs = computeFreshSet(
10
+ [entry("old", 1000), entry("newest", 3000), entry("mid", 2000)],
11
+ { k: 3, excludeSlugs: new Set() },
12
+ );
13
+ expect(slugs).toEqual(["newest", "mid", "old"]);
14
+ });
15
+
16
+ test("cuts to k after exclusions", () => {
17
+ const slugs = computeFreshSet(
18
+ [entry("a", 4000), entry("b", 3000), entry("c", 2000), entry("d", 1000)],
19
+ { k: 2, excludeSlugs: new Set(["a"]) },
20
+ );
21
+ // "a" is excluded BEFORE the cut, so the two slots go to the next-newest.
22
+ expect(slugs).toEqual(["b", "c"]);
23
+ });
24
+
25
+ test("skips synthetic entries (modifiedAt 0)", () => {
26
+ const slugs = computeFreshSet(
27
+ [entry("skills/oura", 0), entry("real-page", 500)],
28
+ { k: 5, excludeSlugs: new Set() },
29
+ );
30
+ expect(slugs).toEqual(["real-page"]);
31
+ });
32
+
33
+ test("k = 0 disables the lane", () => {
34
+ expect(
35
+ computeFreshSet([entry("a", 1000)], { k: 0, excludeSlugs: new Set() }),
36
+ ).toEqual([]);
37
+ });
38
+
39
+ test("breaks modification-time ties by slug, ascending", () => {
40
+ const slugs = computeFreshSet(
41
+ [entry("zebra", 1000), entry("apple", 1000), entry("mango", 1000)],
42
+ { k: 3, excludeSlugs: new Set() },
43
+ );
44
+ expect(slugs).toEqual(["apple", "mango", "zebra"]);
45
+ });
46
+
47
+ test("does not mutate the input array", () => {
48
+ const entries = [entry("b", 1000), entry("a", 2000)];
49
+ computeFreshSet(entries, { k: 2, excludeSlugs: new Set() });
50
+ expect(entries.map((e) => e.slug)).toEqual(["b", "a"]);
51
+ });
52
+ });
@@ -215,6 +215,7 @@ function result(
215
215
  lanes: {
216
216
  core: [],
217
217
  hot: [],
218
+ fresh: [],
218
219
  finder: matched.map(([slug]) => ({
219
220
  slug,
220
221
  descriptor: "",
@@ -189,7 +189,7 @@ function candidateSlugs(messages: Message[]): Slug[] {
189
189
  );
190
190
  if (finder) {
191
191
  for (const line of finder[1].split("\n")) {
192
- const m = /^\[(\d+)\] (\S+)(?: — |$)/.exec(line);
192
+ const m = /^\[(\d+)\] (?:\([^)]*\) )?(\S+)(?: — |$)/.exec(line);
193
193
  if (m) entries.push({ id: Number(m[1]), slug: m[2]! });
194
194
  }
195
195
  }
@@ -235,6 +235,7 @@ async function runTurn(
235
235
  edgeGraph: deps.lanes.edgeGraph,
236
236
  coreSlugs: [],
237
237
  hotSlugs: [],
238
+ freshSlugs: [],
238
239
  prefixCards: new Map(),
239
240
  });
240
241
  const active = getActiveSlugs(conversationId);
@@ -127,8 +127,9 @@ function depsOf(
127
127
  ): OrchestrateDeps {
128
128
  const coreSlugs = overrides.coreSlugs ?? [];
129
129
  const hotSlugs = overrides.hotSlugs ?? [];
130
+ const freshSlugs = overrides.freshSlugs ?? [];
130
131
  const prefixCards = new Map<Slug, string>(
131
- [...coreSlugs, ...hotSlugs].map((slug) => [
132
+ [...coreSlugs, ...hotSlugs, ...freshSlugs].map((slug) => [
132
133
  slug,
133
134
  renderCard(slug, RAW[slug] ?? ""),
134
135
  ]),
@@ -140,6 +141,7 @@ function depsOf(
140
141
  edgeGraph: lanes.edgeGraph,
141
142
  coreSlugs,
142
143
  hotSlugs,
144
+ freshSlugs,
143
145
  prefixCards,
144
146
  ...overrides,
145
147
  };
@@ -148,12 +150,14 @@ function depsOf(
148
150
  function makeTurn(
149
151
  turnNumber: number,
150
152
  currentMessage: string,
153
+ previousAssistantMessage?: string,
151
154
  ): MemoryRoutingTurn {
152
155
  return {
153
156
  conversationId: "conv-xyz",
154
157
  turnNumber,
155
158
  currentMessage,
156
159
  recentContext: "prior context",
160
+ previousAssistantMessage,
157
161
  };
158
162
  }
159
163
 
@@ -199,7 +203,7 @@ function parsePool(messages: Message[]): {
199
203
  );
200
204
  if (finder) {
201
205
  for (const line of finder[1].split("\n")) {
202
- const m = /^\[(\d+)\] (\S+)(?: — |$)/.exec(line);
206
+ const m = /^\[(\d+)\] (?:\([^)]*\) )?(\S+)(?: — |$)/.exec(line);
203
207
  if (m) entries.push({ id: Number(m[1]), slug: m[2]!, line });
204
208
  }
205
209
  }
@@ -371,6 +375,133 @@ describe("orchestrate — cache-ordered pool (core + hot + finders)", () => {
371
375
  ]);
372
376
  });
373
377
 
378
+ test("fresh follows hot in the pool and dedups against core/hot", async () => {
379
+ const lanes = await buildLanes();
380
+ denseHits = [];
381
+ providerStub = selectProvider([]);
382
+
383
+ const result = await orchestrate(
384
+ makeTurn(1, "apple"),
385
+ depsOf(lanes, {
386
+ coreSlugs: ["topic-c"],
387
+ hotSlugs: ["topic-d"],
388
+ // topic-c (core) and topic-d (hot) are defensively dropped; only
389
+ // topic-b earns a fresh slot.
390
+ freshSlugs: ["topic-c", "topic-d", "topic-b"],
391
+ }),
392
+ );
393
+
394
+ expect(lastPool).toEqual(["topic-c", "topic-d", "topic-b", "topic-a"]);
395
+ expect(result.lanes.fresh).toEqual(["topic-b"]);
396
+ });
397
+
398
+ test('reply-query hits join the finder tail tagged "reply", after primary lanes and before edge', async () => {
399
+ const lanes = await buildLanes();
400
+ denseHits = [];
401
+ providerStub = selectProvider([]);
402
+
403
+ // Primary query matches topic-a; the previous reply matches topic-b. The
404
+ // edge lane expands topic-a's curated link to topic-d.
405
+ const result = await orchestrate(
406
+ makeTurn(1, "apple", "cherry date"),
407
+ depsOf(lanes, { replyQueryK: 5 }),
408
+ );
409
+
410
+ expect(result.lanes.finder.map((c) => [c.slug, c.lane])).toEqual([
411
+ ["topic-a", "needle"],
412
+ ["topic-b", "reply"],
413
+ ["topic-d", "edge"],
414
+ ]);
415
+ // The reply-matched section is recorded for injection/spotlight.
416
+ expect(result.matchedSections.has("topic-b")).toBe(true);
417
+ });
418
+
419
+ test("a slug both queries surface keeps its primary-lane attribution", async () => {
420
+ const lanes = await buildLanes();
421
+ denseHits = [];
422
+ providerStub = selectProvider([]);
423
+
424
+ const result = await orchestrate(
425
+ makeTurn(1, "apple", "apple banana"),
426
+ depsOf(lanes, { replyQueryK: 5 }),
427
+ );
428
+
429
+ const topicA = result.lanes.finder.filter((c) => c.slug === "topic-a");
430
+ expect(topicA).toHaveLength(1);
431
+ expect(topicA[0]!.lane).toBe("needle");
432
+ });
433
+
434
+ test("no previous assistant message → no reply-lane candidates", async () => {
435
+ const lanes = await buildLanes();
436
+ denseHits = [];
437
+ providerStub = selectProvider([]);
438
+
439
+ const result = await orchestrate(
440
+ makeTurn(1, "apple"),
441
+ depsOf(lanes, { replyQueryK: 5 }),
442
+ );
443
+
444
+ expect(result.lanes.finder.some((c) => c.lane === "reply")).toBe(false);
445
+ });
446
+
447
+ test("replyQueryK = 0 disables the pass even with a previous reply", async () => {
448
+ const lanes = await buildLanes();
449
+ denseHits = [];
450
+ providerStub = selectProvider([]);
451
+
452
+ const result = await orchestrate(
453
+ makeTurn(1, "apple", "cherry date"),
454
+ depsOf(lanes, { replyQueryK: 0 }),
455
+ );
456
+
457
+ expect(result.lanes.finder.some((c) => c.lane === "reply")).toBe(false);
458
+ });
459
+
460
+ test('learned-edge expansion surfaces association neighbours tagged "learned", after the static edge lane', async () => {
461
+ const lanes = await buildLanes();
462
+ denseHits = [];
463
+ providerStub = selectProvider([]);
464
+
465
+ // Needle("apple") surfaces topic-a; static links expand a → d; the
466
+ // learned graph associates a → c (no authored link exists).
467
+ const learnedGraph = {
468
+ adjacency: new Map([["topic-a", new Map([["topic-c", undefined]])]]),
469
+ hubs: new Set<Slug>(),
470
+ slugs: new Set(SLUGS),
471
+ };
472
+ const result = await orchestrate(
473
+ makeTurn(1, "apple"),
474
+ depsOf(lanes, { learnedGraph, learnedPerSeed: 3, learnedCap: 20 }),
475
+ );
476
+
477
+ expect(result.lanes.finder.map((c) => [c.slug, c.lane])).toEqual([
478
+ ["topic-a", "needle"],
479
+ ["topic-d", "edge"],
480
+ ["topic-c", "learned"],
481
+ ]);
482
+ // Association, not lexical relevance, surfaced topic-c — no matched
483
+ // section is recorded (injection falls back to the full page).
484
+ expect(result.matchedSections.has("topic-c")).toBe(false);
485
+ });
486
+
487
+ test("learnedCap = 0 disables the learned pass", async () => {
488
+ const lanes = await buildLanes();
489
+ denseHits = [];
490
+ providerStub = selectProvider([]);
491
+
492
+ const learnedGraph = {
493
+ adjacency: new Map([["topic-a", new Map([["topic-c", undefined]])]]),
494
+ hubs: new Set<Slug>(),
495
+ slugs: new Set(SLUGS),
496
+ };
497
+ const result = await orchestrate(
498
+ makeTurn(1, "apple"),
499
+ depsOf(lanes, { learnedGraph, learnedCap: 0 }),
500
+ );
501
+
502
+ expect(result.lanes.finder.some((c) => c.lane === "learned")).toBe(false);
503
+ });
504
+
374
505
  test("the rendered stable prefix is byte-identical across turns with different queries", async () => {
375
506
  const lanes = await buildLanes();
376
507
  const deps = depsOf(lanes, {
@@ -456,9 +587,9 @@ describe("orchestrate — cache-ordered pool (core + hot + finders)", () => {
456
587
  expect(lastPool.filter((s) => s === "topic-a")).toHaveLength(2);
457
588
  expect(lastPool[0]).toBe("topic-a");
458
589
  // The finder line shows the matched-section snippet.
459
- expect(lastPoolLines.find((l) => /^\[2\] topic-a — /.test(l))).toContain(
460
- "apple",
461
- );
590
+ expect(
591
+ lastPoolLines.find((l) => /^\[2\] (?:\([^)]*\) )?topic-a — /.test(l)),
592
+ ).toContain("apple");
462
593
  // Selecting both ids still yields ONE selection (slug dedup), the finder
463
594
  // lane records the hit, and the matched section survives downstream.
464
595
  expect(result.selections).toEqual([{ slug: "topic-a", pinned: false }]);
@@ -316,6 +316,23 @@ describe("selectPool — request shape", () => {
316
316
  expect(tail.text).not.toContain("<candidate_cards>");
317
317
  });
318
318
 
319
+ test("finder lines render the surfacing lane tag when one is supplied", async () => {
320
+ providerStub = makeProvider(toolUseResponse({ ids: [] }));
321
+ const pool = makePool();
322
+ pool.finder = [
323
+ { slug: "topic-x", descriptor: "section: about topic x", lane: "needle" },
324
+ { slug: "page-a", descriptor: "", lane: "learned" },
325
+ ];
326
+ await selectPool(pool, makeTurn("rollout?"));
327
+
328
+ const [, tail] = sentBlocks();
329
+ expect(tail.text).toContain(
330
+ "[3] (needle) topic-x — section: about topic x",
331
+ );
332
+ // Empty descriptor: lane tag still renders, dash omitted.
333
+ expect(tail.text).toContain("[4] (learned) page-a");
334
+ });
335
+
319
336
  test("the rendered prefix is byte-identical across turns; only the tail varies", async () => {
320
337
  providerStub = makeProvider(toolUseResponse({ ids: [1] }));
321
338
  await selectPool(makePool(), makeTurn("first question"));
@@ -202,9 +202,12 @@ describe("summarizeSelections", () => {
202
202
  expect(summary.bySource).toEqual({
203
203
  core: 0,
204
204
  hot: 0,
205
+ fresh: 0,
205
206
  needle: 2,
206
207
  dense: 0,
207
208
  edge: 2,
209
+ reply: 0,
210
+ learned: 0,
208
211
  });
209
212
  expect(summary.turns).toBe(2);
210
213
  // page-1 and page-2 — distinct across the two turns.
@@ -216,9 +219,12 @@ describe("summarizeSelections", () => {
216
219
  bySource: {
217
220
  core: 0,
218
221
  hot: 0,
222
+ fresh: 0,
219
223
  needle: 0,
220
224
  dense: 0,
221
225
  edge: 0,
226
+ reply: 0,
227
+ learned: 0,
222
228
  },
223
229
  turns: 0,
224
230
  distinctSlugs: 0,
@@ -237,7 +237,7 @@ function candidateSlugs(messages: Message[]): Slug[] {
237
237
  );
238
238
  if (finder) {
239
239
  for (const line of finder[1].split("\n")) {
240
- const m = /^\[(\d+)\] (\S+)(?: — |$)/.exec(line);
240
+ const m = /^\[(\d+)\] (?:\([^)]*\) )?(\S+)(?: — |$)/.exec(line);
241
241
  if (m) entries.push({ id: Number(m[1]), slug: m[2]! });
242
242
  }
243
243
  }
@@ -310,6 +310,7 @@ async function runTurn(
310
310
  edgeGraph: deps.lanes.edgeGraph,
311
311
  coreSlugs: deps.core ?? [],
312
312
  hotSlugs: deps.hot ?? [],
313
+ freshSlugs: [],
313
314
  // Mirrors lane init: every stable-prefix slug gets a pre-rendered card.
314
315
  prefixCards: new Map(
315
316
  stableSlugs.map((slug) => [slug, renderCard(slug, RAW[slug] ?? "")]),
@@ -476,9 +477,12 @@ describe("memory-v3 shadow integration — selection-log readout", () => {
476
477
  bySource: {
477
478
  core: 0,
478
479
  hot: 0,
480
+ fresh: 0,
479
481
  needle: 0,
480
482
  dense: 0,
481
483
  edge: 0,
484
+ reply: 0,
485
+ learned: 0,
482
486
  },
483
487
  turns: 0,
484
488
  distinctSlugs: 0,
@@ -34,6 +34,7 @@ import type { HotSetEntry, HotSetOptions } from "../hot-set.js";
34
34
  import type { OrchestrateResult } from "../orchestrate.js";
35
35
  import {
36
36
  MEMORY_V3_COMMIT_META_KEY,
37
+ type MemoryRoutingTurn,
37
38
  type SectionIndex,
38
39
  type SelectionSource,
39
40
  } from "../types.js";
@@ -88,8 +89,9 @@ const CAPABILITY_SLUG = "skills/example";
88
89
  const CAPABILITY_CONTENT = "use the kumquat skill to do the thing";
89
90
 
90
91
  // The orchestrate result the spy returns. `lanes` records where each pooled
91
- // slug lived: page-core in the core lane, page-hot in the hot lane, and the
92
- // finder entries page-1 → "needle", page-2 → "dense", page-3 → "edge";
92
+ // slug lived: page-core in the core lane, page-hot in the hot lane, page-fresh
93
+ // in the fresh lane, and the finder entries page-1 → "needle", page-2 → "dense",
94
+ // page-3 → "edge";
93
95
  // `attributeSelections` reads it directly. `matchedSections` carries the
94
96
  // matched section for the slugs that had one (page-1/page-2) — consumed by the
95
97
  // live injector's progressive disclosure, independent of source attribution.
@@ -98,9 +100,12 @@ const orchestrateSpy = mock(
98
100
  selections: [
99
101
  { slug: "page-core", pinned: false },
100
102
  { slug: "page-hot", pinned: false },
103
+ { slug: "page-fresh", pinned: false },
101
104
  { slug: "page-1", pinned: true },
102
105
  { slug: "page-2", pinned: false },
103
106
  { slug: "page-3", pinned: false },
107
+ { slug: "page-4", pinned: false },
108
+ { slug: "page-5", pinned: false },
104
109
  ],
105
110
  matchedSections: new Map([
106
111
  ["page-1", { article: "page-1", title: "", text: "x", ordinal: 0 }],
@@ -109,10 +114,13 @@ const orchestrateSpy = mock(
109
114
  lanes: {
110
115
  core: ["page-core"],
111
116
  hot: ["page-hot"],
117
+ fresh: ["page-fresh"],
112
118
  finder: [
113
119
  { slug: "page-1", descriptor: "", lane: "needle" },
114
120
  { slug: "page-2", descriptor: "", lane: "dense" },
115
121
  { slug: "page-3", descriptor: "", lane: "edge" },
122
+ { slug: "page-4", descriptor: "", lane: "reply" },
123
+ { slug: "page-5", descriptor: "", lane: "learned" },
116
124
  ],
117
125
  },
118
126
  }),
@@ -180,9 +188,19 @@ mock.module("../../../../config/loader.js", () => ({
180
188
  enabled: memoryEnabled,
181
189
  v3: {
182
190
  hotSet: { k: 40, halfLifeDays: 14 },
191
+ freshSet: { k: 50 },
183
192
  spotlight: { n: 6, windowTurns: 2 },
184
193
  needleK: 100,
185
194
  denseK: 100,
195
+ replyQueryK: 12,
196
+ learnedEdges: {
197
+ halfLifeDays: 30,
198
+ minCount: 3,
199
+ npmiFloor: 0.2,
200
+ maxPerPage: 6,
201
+ perSeed: 3,
202
+ cap: 20,
203
+ },
186
204
  edge: { hubDegree: 30, seedCount: 18, perSeed: 6, cap: 45 },
187
205
  },
188
206
  qdrant: { vectorSize: 8, onDisk: false },
@@ -463,12 +481,57 @@ describe("memory-v3 shadow plugin", () => {
463
481
  { slug: "page-2", source: "dense", pinned: 0 },
464
482
  // page-3 was surfaced by the edge lane → "edge".
465
483
  { slug: "page-3", source: "edge", pinned: 0 },
466
- // page-core / page-hot sit in the stable prefix → "core" / "hot".
484
+ // page-4 was first surfaced by the reply-query pass → "reply".
485
+ { slug: "page-4", source: "reply", pinned: 0 },
486
+ // page-5 was first surfaced by the learned-edge pass → "learned".
487
+ { slug: "page-5", source: "learned", pinned: 0 },
488
+ // page-core / page-hot / page-fresh sit in the stable prefix →
489
+ // "core" / "hot" / "fresh".
467
490
  { slug: "page-core", source: "core", pinned: 0 },
491
+ { slug: "page-fresh", source: "fresh", pinned: 0 },
468
492
  { slug: "page-hot", source: "hot", pinned: 0 },
469
493
  ]);
470
494
  });
471
495
 
496
+ test("the turn carries the tail of the previous assistant reply for the reply-query pass", async () => {
497
+ shadowEnabled = true;
498
+ messages = [
499
+ {
500
+ role: "user",
501
+ content: JSON.stringify([{ type: "text", text: "first question" }]),
502
+ },
503
+ {
504
+ role: "assistant",
505
+ content: JSON.stringify([
506
+ { type: "text", text: "the thread continues from my last reply" },
507
+ ]),
508
+ },
509
+ {
510
+ role: "user",
511
+ content: JSON.stringify([{ type: "text", text: "and now this" }]),
512
+ },
513
+ ];
514
+ await runShadowObservation("conv-1", 1);
515
+
516
+ const turn = (
517
+ orchestrateSpy.mock.calls as unknown as unknown[][]
518
+ )[0]![0] as MemoryRoutingTurn;
519
+ expect(turn.currentMessage).toBe("and now this");
520
+ expect(turn.previousAssistantMessage).toBe(
521
+ "the thread continues from my last reply",
522
+ );
523
+ });
524
+
525
+ test("a conversation-opening turn has no previous assistant message", async () => {
526
+ shadowEnabled = true;
527
+ await runShadowObservation("conv-1", 0);
528
+
529
+ const turn = (
530
+ orchestrateSpy.mock.calls as unknown as unknown[][]
531
+ )[0]![0] as MemoryRoutingTurn;
532
+ expect(turn.previousAssistantMessage).toBeUndefined();
533
+ });
534
+
472
535
  test("a selection of a core page a finder also hit attributes to core (pool position wins)", () => {
473
536
  const rows = attributeSelections({
474
537
  selections: [{ slug: "page-core", pinned: false }],
@@ -476,6 +539,7 @@ describe("memory-v3 shadow plugin", () => {
476
539
  lanes: {
477
540
  core: ["page-core"],
478
541
  hot: [],
542
+ fresh: [],
479
543
  // The needle also hit the core page this turn — the row still logs
480
544
  // "core" because that is where the candidate lived in the pool.
481
545
  finder: [{ slug: "page-core", descriptor: "", lane: "needle" }],
@@ -598,7 +662,7 @@ describe("memory-v3 shadow plugin", () => {
598
662
  orchestrateSpy.mockImplementationOnce(async () => ({
599
663
  selections: [],
600
664
  matchedSections: new Map(),
601
- lanes: { core: [], hot: [], finder: [] },
665
+ lanes: { core: [], hot: [], fresh: [], finder: [] },
602
666
  }));
603
667
  const block = await produce("conv-1", 0);
604
668
  expect(block).toBeNull();
@@ -72,22 +72,61 @@ function renderTocLine(
72
72
  return `[sections: ${headings.map((h) => `§${h}`).join(" · ")}]`;
73
73
  }
74
74
 
75
+ /** Max characters of a `current:` line carried onto the card. A `current:` is
76
+ * one line by contract; the cap keeps a runaway value from bloating every
77
+ * render of the card. */
78
+ const CURRENT_MAX_CHARS = 280;
79
+
80
+ /**
81
+ * Render a page's `current:` frontmatter (one-line live state) as a card
82
+ * annotation, or `null` when the page has none. Whitespace-collapsed and
83
+ * capped at {@link CURRENT_MAX_CHARS}.
84
+ */
85
+ function renderCurrentLine(fields: Record<string, unknown>): string | null {
86
+ const current = fields.current;
87
+ if (typeof current !== "string") return null;
88
+ const collapsed = current.replace(/\s+/g, " ").trim();
89
+ if (collapsed.length === 0) return null;
90
+ const capped =
91
+ collapsed.length > CURRENT_MAX_CHARS
92
+ ? `${collapsed.slice(0, CURRENT_MAX_CHARS).trimEnd()}…`
93
+ : collapsed;
94
+ return `[current: ${capped}]`;
95
+ }
96
+
75
97
  /**
76
- * Render a page's compact card: header marker, head section (uncapped — the
77
- * corpus's rare long leads inject whole; a length cap is a deliberate
78
- * non-feature), and one-line TOC.
98
+ * Render a page's compact card: header marker, optional annotation line, head
99
+ * section (uncapped — the corpus's rare long leads inject whole; a length cap
100
+ * is a deliberate non-feature), and one-line TOC.
79
101
  *
80
102
  * ```
81
103
  * # memory/concepts/<slug>.md
104
+ * [lane: fresh · updated 2026-06-10 14:23]
82
105
  * <head section verbatim>
83
106
  *
84
107
  * [sections: §… · §…]
85
108
  * ```
86
109
  *
110
+ * The annotation renders directly under the header: selector-visible metadata
111
+ * must sit on the always-rendered card surface (section-grain rendering can
112
+ * hide anything that lives only inside page content). Annotations must derive
113
+ * only from lane-init state (lane membership, page mtime) so the card stays
114
+ * byte-identical across turns between lane recomputes.
115
+ *
116
+ * A page's `current:` frontmatter (one-line live state — open items,
117
+ * deadlines, what's pending) renders the same way, first: it exists so
118
+ * state-shaped questions can select the page, which only works if the
119
+ * selector can see it on every card render. It changes only when the page is
120
+ * edited (consolidation), so it shares the lane annotation's cache story.
121
+ *
87
122
  * The TOC line is omitted when the page has no `## ` sections and no usable
88
- * `links:`. Deterministic for a given (slug, rawPageText).
123
+ * `links:`. Deterministic for a given (slug, rawPageText, annotation).
89
124
  */
90
- export function renderCard(slug: Slug, rawPageText: string): string {
125
+ export function renderCard(
126
+ slug: Slug,
127
+ rawPageText: string,
128
+ annotation?: string,
129
+ ): string {
91
130
  const parsed = parseFrontmatterFields(rawPageText);
92
131
  // `parseFrontmatterFields` returns null both for "no frontmatter block" and
93
132
  // "block present but YAML failed to parse" — strip the block either way so a
@@ -101,6 +140,11 @@ export function renderCard(slug: Slug, rawPageText: string): string {
101
140
  const head = (firstHeading ? body.slice(0, firstHeading.index) : body).trim();
102
141
 
103
142
  let card = injectedConceptHeader(slug);
143
+ const current = renderCurrentLine(fields);
144
+ if (current !== null) card += `\n${current}`;
145
+ if (annotation !== undefined && annotation.length > 0) {
146
+ card += `\n${annotation}`;
147
+ }
104
148
  if (head.length > 0) card += `\n${head}`;
105
149
 
106
150
  const toc = renderTocLine(body, fields);
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Recency fresh-set lane.
3
+ *
4
+ * Returns the top-K page slugs by most-recent on-disk modification
5
+ * (`PageIndexEntry.modifiedAt`). The fresh set keeps *just-written* pages in
6
+ * the candidate pool during the window before the other lanes can reach them:
7
+ * a page consolidated minutes ago has no selection history (so frecency can't
8
+ * rank it into the hot set) and a "what happened today?"-shaped message gives
9
+ * the finder lanes nothing lexical to match it on. Recency is exactly the
10
+ * signal those lanes are missing, so it gets its own lane.
11
+ *
12
+ * Like core and hot, the fresh set is a stable-prefix lane: it is computed at
13
+ * lane init and recomputed only on lane invalidation (the consolidation
14
+ * cadence), so its membership — and therefore the selector's cache-stable
15
+ * prefix — never changes mid-window. Page mtimes move on every consolidation
16
+ * write, which is the same event that invalidates the lanes, so recompute
17
+ * lag is bounded by the consolidation interval.
18
+ *
19
+ * Slugs in `excludeSlugs` (core + hot members) are dropped before the K cut —
20
+ * those pages already sit in the stable prefix, so re-listing them would only
21
+ * spend fresh slots on duplicates. Synthetic capability entries carry
22
+ * `modifiedAt: 0` and are skipped: they have no on-disk write to be fresh by.
23
+ */
24
+
25
+ import type { Slug } from "./types.js";
26
+
27
+ /** The slice of a page-index entry the fresh lane ranks on. */
28
+ export interface FreshSetEntry {
29
+ slug: Slug;
30
+ /** File mtime in epoch ms; `0` for synthetic entries (skills, CLI commands). */
31
+ modifiedAt: number;
32
+ }
33
+
34
+ export interface FreshSetOptions {
35
+ /** Maximum number of slugs returned; `0` disables the lane. */
36
+ k: number;
37
+ /** Slugs excluded from the result (core + hot — fresh never duplicates the
38
+ * rest of the stable prefix). */
39
+ excludeSlugs: Set<string>;
40
+ }
41
+
42
+ /**
43
+ * Compute the top-`k` fresh slugs by file modification time, newest first.
44
+ *
45
+ * Deterministic for fixed inputs: ties break `modifiedAt` desc, then slug asc.
46
+ */
47
+ export function computeFreshSet(
48
+ entries: readonly FreshSetEntry[],
49
+ opts: FreshSetOptions,
50
+ ): Slug[] {
51
+ const { k, excludeSlugs } = opts;
52
+ if (k <= 0) return [];
53
+
54
+ return entries
55
+ .filter((entry) => entry.modifiedAt > 0 && !excludeSlugs.has(entry.slug))
56
+ .sort((a, b) => b.modifiedAt - a.modifiedAt || (a.slug < b.slug ? -1 : 1))
57
+ .slice(0, k)
58
+ .map((entry) => entry.slug);
59
+ }
@@ -301,14 +301,16 @@ export const memoryV3Injector: Injector = {
301
301
  // `produce()` would let a never-attached turn (non-user tail) claim
302
302
  // cards in the store, suppressing them until compaction. The valve is
303
303
  // scheduled after `recordInjected` so the resident accounting includes
304
- // this turn's cards; core/hot lane members are exempt — the selector's
305
- // stable prefix must never be pruned out from under it.
304
+ // this turn's cards; stable-prefix lane members (core/hot/fresh) are
305
+ // exempt — the selector's stable prefix must never be pruned out from
306
+ // under it.
306
307
  const commit = (): void => {
307
308
  recordInjected(ctx.conversationId, entries);
308
309
  schedulePruneValve(ctx.conversationId, {
309
310
  exemptSlugs: new Set<Slug>([
310
311
  ...result.lanes.core,
311
312
  ...result.lanes.hot,
313
+ ...result.lanes.fresh,
312
314
  ]),
313
315
  });
314
316
  };