@vellumai/assistant 0.8.10 → 0.8.11-staging.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 (400) hide show
  1. package/bun.lock +62 -1
  2. package/docs/workspace-tools.md +196 -0
  3. package/examples/plugins/echo/README.md +3 -3
  4. package/knip.json +1 -0
  5. package/openapi.yaml +460 -128
  6. package/package.json +2 -1
  7. package/scripts/build-plugin-api.ts +299 -0
  8. package/src/__tests__/agent-loop-callsite-precedence.test.ts +7 -0
  9. package/src/__tests__/agent-loop-compaction-events.test.ts +197 -0
  10. package/src/__tests__/agent-loop-exit-reason.test.ts +93 -96
  11. package/src/__tests__/agent-loop-mutable-latest-user-message.test.ts +2 -0
  12. package/src/__tests__/agent-loop-output-hooks.test.ts +274 -1
  13. package/src/__tests__/agent-loop-override-profile.test.ts +3 -0
  14. package/src/__tests__/agent-loop-provider-error-recording.test.ts +4 -0
  15. package/src/__tests__/agent-loop-thinking.test.ts +4 -0
  16. package/src/__tests__/agent-loop.test.ts +578 -5
  17. package/src/__tests__/agent-wake-disk-pressure-callsite.test.ts +0 -1
  18. package/src/__tests__/approval-cascade.test.ts +1 -0
  19. package/src/__tests__/background-workers-disk-pressure.test.ts +0 -2
  20. package/src/__tests__/btw-routes.test.ts +0 -1
  21. package/src/__tests__/build-persisted-content.test.ts +75 -1
  22. package/src/__tests__/catalog-install-normalize.test.ts +141 -0
  23. package/src/__tests__/ces-startup-timeout.test.ts +60 -0
  24. package/src/__tests__/compaction-events.test.ts +1 -0
  25. package/src/__tests__/config-managed-gemini-defaults.test.ts +2 -46
  26. package/src/__tests__/context-overflow-reducer.test.ts +264 -124
  27. package/src/__tests__/context-window-manager-overflow-rung.test.ts +351 -0
  28. package/src/__tests__/conversation-abort-tool-results.test.ts +1 -1
  29. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +13 -5
  30. package/src/__tests__/conversation-agent-loop-overflow.test.ts +284 -455
  31. package/src/__tests__/conversation-agent-loop.test.ts +131 -551
  32. package/src/__tests__/conversation-app-control-instantiation.test.ts +13 -0
  33. package/src/__tests__/conversation-confirmation-signals.test.ts +1 -0
  34. package/src/__tests__/conversation-fork-crud.test.ts +259 -0
  35. package/src/__tests__/conversation-history-web-search.test.ts +1 -1
  36. package/src/__tests__/conversation-lifecycle.test.ts +257 -1
  37. package/src/__tests__/conversation-process-callsite.test.ts +1 -0
  38. package/src/__tests__/conversation-provider-retry-repair.test.ts +38 -377
  39. package/src/__tests__/conversation-queue.test.ts +1 -39
  40. package/src/__tests__/conversation-runtime-assembly.test.ts +119 -8
  41. package/src/__tests__/conversation-skill-tools.test.ts +491 -5
  42. package/src/__tests__/conversation-slash-queue.test.ts +1 -1
  43. package/src/__tests__/conversation-slash-unknown.test.ts +1 -0
  44. package/src/__tests__/conversation-speed-override.test.ts +1 -0
  45. package/src/__tests__/conversation-store.test.ts +74 -0
  46. package/src/__tests__/conversation-surfaces-app-control.test.ts +4 -1
  47. package/src/__tests__/conversation-tool-setup-attribution.test.ts +323 -0
  48. package/src/__tests__/conversation-tool-setup-tools-disabled.test.ts +34 -0
  49. package/src/__tests__/conversation-workspace-cache-state.test.ts +1 -0
  50. package/src/__tests__/conversation-workspace-injection.test.ts +1 -1
  51. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -0
  52. package/src/__tests__/corrected-target.test.ts +93 -0
  53. package/src/__tests__/credential-execution-feature-gates.test.ts +3 -5
  54. package/src/__tests__/credential-execution-tools.test.ts +23 -11
  55. package/src/__tests__/credential-security-invariants.test.ts +6 -1
  56. package/src/__tests__/db-schedule-syntax-migration.test.ts +80 -0
  57. package/src/__tests__/device-id.test.ts +70 -1
  58. package/src/__tests__/embedding-managed-proxy-selection.test.ts +6 -40
  59. package/src/__tests__/empty-response-hook.test.ts +242 -66
  60. package/src/__tests__/external-plugin-loader.test.ts +0 -31
  61. package/src/__tests__/get-skill-detail-audit.test.ts +43 -1
  62. package/src/__tests__/guardian-routing-invariants.test.ts +91 -0
  63. package/src/__tests__/history-repair-hook.test.ts +228 -3
  64. package/src/__tests__/host-app-control-proxy.test.ts +45 -0
  65. package/src/__tests__/host-browser-proxy.test.ts +254 -9
  66. package/src/__tests__/identity-routes.test.ts +1 -0
  67. package/src/__tests__/image-recovery-hook.test.ts +387 -0
  68. package/src/__tests__/injector-chain.test.ts +5 -4
  69. package/src/__tests__/injector-v3-suppression.test.ts +373 -47
  70. package/src/__tests__/intent-routing.test.ts +7 -0
  71. package/src/__tests__/memory-retrieval-hook.test.ts +117 -15
  72. package/src/__tests__/notification-decision-strategy.test.ts +3 -3
  73. package/src/__tests__/oauth-store.test.ts +0 -85
  74. package/src/__tests__/{context-overflow-policy.test.ts → overflow-policy.test.ts} +1 -1
  75. package/src/__tests__/parallel-tool.benchmark.test.ts +4 -0
  76. package/src/__tests__/persist-unsendable-image-downscale.test.ts +29 -9
  77. package/src/__tests__/persist-unsendable-image.test.ts +4 -4
  78. package/src/__tests__/persistence-secret-redaction.test.ts +78 -0
  79. package/src/__tests__/plugin-bootstrap.test.ts +82 -73
  80. package/src/__tests__/plugin-tool-contribution.test.ts +7 -4
  81. package/src/__tests__/plugin-types.test.ts +0 -8
  82. package/src/__tests__/provider-catalog-visibility.test.ts +1 -9
  83. package/src/__tests__/prune-old-conversations-job.test.ts +99 -0
  84. package/src/__tests__/registry.test.ts +240 -1
  85. package/src/__tests__/require-fresh-approval.test.ts +3 -0
  86. package/src/__tests__/schedule-routes.test.ts +116 -1
  87. package/src/__tests__/schedule-store.test.ts +28 -0
  88. package/src/__tests__/schedule-tools.test.ts +94 -1
  89. package/src/__tests__/server-history-render.test.ts +39 -0
  90. package/src/__tests__/skill-projection-feature-flag.test.ts +13 -0
  91. package/src/__tests__/skill-projection.benchmark.test.ts +25 -7
  92. package/src/__tests__/skills.test.ts +202 -0
  93. package/src/__tests__/slim-skill-category.test.ts +195 -0
  94. package/src/__tests__/strip-memory-injections.test.ts +33 -39
  95. package/src/__tests__/test-support/tool-invocation-seed.ts +79 -0
  96. package/src/__tests__/title-generate-hook.test.ts +9 -7
  97. package/src/__tests__/tool-audit-listener.test.ts +264 -1
  98. package/src/__tests__/tool-error-hook.test.ts +4 -3
  99. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +1 -0
  100. package/src/__tests__/tool-executor-lifecycle-events.test.ts +273 -0
  101. package/src/__tests__/tool-result-truncate-hook.test.ts +1 -0
  102. package/src/__tests__/tool-start-timestamp.test.ts +218 -0
  103. package/src/__tests__/tools-get-route.test.ts +202 -0
  104. package/src/__tests__/workspace-tool-loader.test.ts +319 -0
  105. package/src/__tests__/workspace-tools-watcher-flag.test.ts +70 -0
  106. package/src/agent/loop.ts +569 -319
  107. package/src/api/events/tool-result.ts +9 -0
  108. package/src/api/events/tool-use-start.ts +7 -0
  109. package/src/api/index.ts +10 -0
  110. package/src/api/responses/conversation-message.ts +135 -27
  111. package/src/api/responses/memory-v3-selection-log.ts +4 -4
  112. package/src/approvals/guardian-request-resolvers.ts +26 -0
  113. package/src/browser-session/backends/host-bridge.ts +29 -0
  114. package/src/browser-session/index.ts +1 -0
  115. package/src/browser-session/types.ts +5 -1
  116. package/src/cli/commands/__tests__/schedules.test.ts +62 -4
  117. package/src/cli/commands/__tests__/skills.test.ts +53 -0
  118. package/src/cli/commands/channel-verification-sessions.ts +6 -6
  119. package/src/cli/commands/inference-providers.ts +0 -8
  120. package/src/cli/commands/plugins.ts +2 -2
  121. package/src/cli/commands/schedules.ts +27 -4
  122. package/src/cli/commands/skills.ts +187 -146
  123. package/src/cli/commands/tools.ts +106 -0
  124. package/src/cli/lib/__tests__/install-from-github.test.ts +256 -328
  125. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +6 -2
  126. package/src/cli/lib/__tests__/plugin-details.test.ts +10 -16
  127. package/src/cli/lib/__tests__/plugin-marketplace.test.ts +2 -2
  128. package/src/cli/lib/__tests__/search-plugins.test.ts +145 -240
  129. package/src/cli/lib/install-from-github.ts +187 -117
  130. package/src/cli/lib/plugin-catalog-cache.ts +9 -9
  131. package/src/cli/lib/plugin-details.ts +38 -68
  132. package/src/cli/lib/plugin-marketplace.ts +42 -14
  133. package/src/cli/lib/search-plugins.ts +29 -129
  134. package/src/cli/program.ts +2 -0
  135. package/src/config/bundled-skills/acp/SKILL.md +1 -0
  136. package/src/config/bundled-skills/app-builder/SKILL.md +1 -0
  137. package/src/config/bundled-skills/app-control/SKILL.md +1 -0
  138. package/src/config/bundled-skills/computer-use/SKILL.md +1 -0
  139. package/src/config/bundled-skills/contacts/SKILL.md +1 -0
  140. package/src/config/bundled-skills/document-editor/SKILL.md +1 -0
  141. package/src/config/bundled-skills/followups/SKILL.md +1 -0
  142. package/src/config/bundled-skills/image-studio/SKILL.md +1 -0
  143. package/src/config/bundled-skills/media-processing/SKILL.md +1 -0
  144. package/src/config/bundled-skills/messaging/SKILL.md +1 -0
  145. package/src/config/bundled-skills/phone-calls/SKILL.md +1 -0
  146. package/src/config/bundled-skills/playbooks/SKILL.md +1 -0
  147. package/src/config/bundled-skills/schedule/SKILL.md +1 -0
  148. package/src/config/bundled-skills/schedule/TOOLS.json +11 -3
  149. package/src/config/bundled-skills/sequences/SKILL.md +1 -0
  150. package/src/config/bundled-skills/settings/SKILL.md +1 -0
  151. package/src/config/bundled-skills/skill-management/SKILL.md +101 -1
  152. package/src/config/bundled-skills/subagent/SKILL.md +1 -0
  153. package/src/config/bundled-skills/transcribe/SKILL.md +1 -0
  154. package/src/config/env-registry.ts +23 -0
  155. package/src/config/feature-flag-registry.json +17 -81
  156. package/src/config/loader.ts +5 -22
  157. package/src/config/schema.ts +2 -0
  158. package/src/config/schemas/__tests__/compaction-logs.test.ts +56 -0
  159. package/src/config/schemas/__tests__/memory-v2.test.ts +0 -1
  160. package/src/config/schemas/__tests__/memory-v3.test.ts +61 -1
  161. package/src/config/schemas/compaction-logs.ts +79 -0
  162. package/src/config/schemas/memory-v2.ts +0 -8
  163. package/src/config/schemas/memory-v3.ts +104 -33
  164. package/src/config/seed-inference-profiles.ts +1 -1
  165. package/src/config/skills.ts +117 -47
  166. package/src/context/compactor.ts +11 -0
  167. package/src/context/strip-injections.ts +38 -4
  168. package/src/credential-execution/feature-gates.ts +0 -21
  169. package/src/credential-execution/startup-timeout.ts +32 -4
  170. package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +18 -0
  171. package/src/daemon/conversation-agent-loop-handlers.ts +140 -91
  172. package/src/daemon/conversation-agent-loop.ts +123 -658
  173. package/src/daemon/conversation-error.ts +6 -33
  174. package/src/daemon/conversation-lifecycle.ts +1 -1
  175. package/src/daemon/conversation-runtime-assembly.ts +183 -22
  176. package/src/daemon/conversation-skill-tools.ts +137 -8
  177. package/src/daemon/conversation-slash.ts +0 -14
  178. package/src/daemon/conversation-store.ts +2 -19
  179. package/src/daemon/conversation-tool-setup.ts +87 -1
  180. package/src/daemon/conversation.ts +119 -50
  181. package/src/daemon/external-plugins-bootstrap.ts +36 -95
  182. package/src/daemon/handlers/config-channels.ts +11 -2
  183. package/src/daemon/handlers/shared.ts +9 -1
  184. package/src/daemon/handlers/skills.ts +10 -4
  185. package/src/daemon/host-app-control-proxy.ts +72 -57
  186. package/src/daemon/host-browser-proxy.ts +117 -22
  187. package/src/daemon/lifecycle.ts +34 -5
  188. package/src/daemon/message-protocol.ts +0 -7
  189. package/src/daemon/message-types/schedules.ts +1 -0
  190. package/src/daemon/message-types/skills.ts +17 -0
  191. package/src/daemon/providers-setup.ts +3 -0
  192. package/src/daemon/server.ts +3 -3
  193. package/src/daemon/tool-setup-types.ts +9 -3
  194. package/src/daemon/trust-context.ts +23 -0
  195. package/src/daemon/workspace-tools-watcher.ts +324 -0
  196. package/src/events/tool-audit-listener.ts +78 -16
  197. package/src/events/tool-metrics-listener.ts +2 -5
  198. package/src/memory/__tests__/compaction-log-writer-clickhouse.test.ts +227 -0
  199. package/src/memory/__tests__/conversation-queries.test.ts +176 -0
  200. package/src/memory/__tests__/jobs-worker-v2-schedule.test.ts +20 -32
  201. package/src/memory/compaction-log-writer-clickhouse.ts +418 -0
  202. package/src/memory/conversation-crud.ts +134 -3
  203. package/src/memory/conversation-queries.ts +64 -7
  204. package/src/memory/db-init.ts +14 -0
  205. package/src/memory/embedding-backend.test.ts +130 -1
  206. package/src/memory/embedding-backend.ts +79 -106
  207. package/src/memory/embedding-gemini.ts +5 -0
  208. package/src/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +12 -0
  209. package/src/memory/graph/__tests__/handle-remember-v2.test.ts +19 -0
  210. package/src/memory/graph/conversation-graph-memory.ts +36 -25
  211. package/src/memory/graph/tool-handlers.ts +3 -0
  212. package/src/memory/job-handlers/cleanup.ts +3 -1
  213. package/src/memory/jobs-store.ts +0 -28
  214. package/src/memory/jobs-worker.ts +10 -22
  215. package/src/memory/memory-marker.ts +29 -0
  216. package/src/memory/memory-retrospective-startup-cleanup.ts +1 -1
  217. package/src/memory/migrations/268-add-memory-v3-selections.ts +6 -0
  218. package/src/memory/migrations/270-schedule-description.ts +36 -0
  219. package/src/memory/migrations/275-tool-invocations-add-skill-id.test.ts +81 -0
  220. package/src/memory/migrations/275-tool-invocations-add-skill-id.ts +20 -0
  221. package/src/memory/migrations/276-tool-invocations-created-at-id-index.test.ts +68 -0
  222. package/src/memory/migrations/276-tool-invocations-created-at-id-index.ts +20 -0
  223. package/src/memory/migrations/277-add-memory-v3-ever-injected.ts +29 -0
  224. package/src/memory/migrations/278-tool-invocations-telemetry-columns.test.ts +96 -0
  225. package/src/memory/migrations/278-tool-invocations-telemetry-columns.ts +39 -0
  226. package/src/memory/migrations/279-create-skill-loaded-events.test.ts +84 -0
  227. package/src/memory/migrations/279-create-skill-loaded-events.ts +26 -0
  228. package/src/memory/migrations/280-conversations-surfaced-at.test.ts +88 -0
  229. package/src/memory/migrations/280-conversations-surfaced-at.ts +24 -0
  230. package/src/memory/migrations/index.ts +10 -0
  231. package/src/memory/migrations/registry.ts +8 -0
  232. package/src/memory/schema/conversations.ts +16 -0
  233. package/src/memory/schema/infrastructure.ts +26 -0
  234. package/src/memory/skill-loaded-events-store.test.ts +160 -0
  235. package/src/memory/skill-loaded-events-store.ts +95 -0
  236. package/src/memory/tool-executed-events-store.test.ts +219 -0
  237. package/src/memory/tool-executed-events-store.ts +102 -0
  238. package/src/memory/tool-usage-store.ts +15 -3
  239. package/src/memory/v2/__tests__/consolidation-job.test.ts +117 -12
  240. package/src/memory/v2/__tests__/consolidation-prompt-flag-gating-guard.test.ts +189 -0
  241. package/src/memory/v2/__tests__/injected-block-slugs.test.ts +90 -0
  242. package/src/memory/v2/__tests__/page-store.test.ts +33 -0
  243. package/src/memory/v2/__tests__/prompts-consolidation.test.ts +88 -15
  244. package/src/memory/v2/activation-store.ts +50 -1
  245. package/src/memory/v2/consolidation-job.ts +113 -29
  246. package/src/memory/v2/injected-block-slugs.ts +79 -0
  247. package/src/memory/v2/injection.ts +6 -1
  248. package/src/memory/v2/prompts/consolidation.ts +414 -13
  249. package/src/memory/v2/router.ts +2 -28
  250. package/src/memory/v2/static-context.ts +1 -1
  251. package/src/memory/v2/types.ts +16 -0
  252. package/src/notifications/__tests__/copy-composer.test.ts +244 -0
  253. package/src/notifications/access-request-copy.ts +298 -0
  254. package/src/notifications/adapters/slack.ts +3 -3
  255. package/src/notifications/adapters/telegram.ts +2 -1
  256. package/src/notifications/copy-composer.ts +49 -267
  257. package/src/notifications/decision-engine.ts +16 -35
  258. package/src/notifications/home-feed-side-effect.ts +1 -6
  259. package/src/oauth/oauth-store.ts +0 -9
  260. package/src/permissions/checker.test.ts +83 -1
  261. package/src/permissions/checker.ts +25 -2
  262. package/src/permissions/gateway-threshold-reader.test.ts +182 -0
  263. package/src/permissions/gateway-threshold-reader.ts +80 -0
  264. package/src/platform/client.ts +1 -3
  265. package/src/platform/feature-gate.ts +3 -12
  266. package/src/plugin-api/constants.ts +4 -2
  267. package/src/plugin-api/index.ts +63 -11
  268. package/src/plugin-api/types.ts +236 -71
  269. package/src/plugins/defaults/compaction/compact.ts +66 -2
  270. package/src/plugins/defaults/compaction/context-overflow-reducer.ts +240 -32
  271. package/src/plugins/defaults/compaction/corrected-target.ts +53 -0
  272. package/src/{daemon/context-overflow-policy.ts → plugins/defaults/compaction/overflow-policy.ts} +1 -1
  273. package/src/plugins/defaults/compaction/window-manager.ts +303 -1
  274. package/src/plugins/defaults/empty-response/hooks/post-model-call.ts +173 -0
  275. package/src/plugins/defaults/empty-response/hooks/stop.ts +11 -115
  276. package/src/plugins/defaults/empty-response/nudge-state-store.ts +46 -0
  277. package/src/plugins/defaults/history-repair/hooks/post-model-call.ts +50 -0
  278. package/src/plugins/defaults/history-repair/hooks/stop.ts +22 -0
  279. package/src/plugins/defaults/history-repair/repair-state-store.ts +51 -0
  280. package/src/plugins/defaults/history-repair/terminal.ts +39 -2
  281. package/src/plugins/defaults/image-recovery/detect.ts +25 -0
  282. package/src/plugins/defaults/image-recovery/hooks/post-model-call.ts +73 -0
  283. package/src/plugins/defaults/image-recovery/hooks/stop.ts +22 -0
  284. package/src/plugins/defaults/image-recovery/image-recovery-state-store.ts +48 -0
  285. package/src/plugins/defaults/image-recovery/package.json +14 -0
  286. package/src/{daemon/persist-unsendable-image.ts → plugins/defaults/image-recovery/recover.ts} +67 -14
  287. package/src/plugins/defaults/index.ts +71 -5
  288. package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +76 -112
  289. package/src/plugins/defaults/memory-retrieval/hooks/{user-prompt-submit-temp.ts → user-prompt-submit.ts} +83 -74
  290. package/src/plugins/defaults/memory-retrieval/injector-chain.ts +14 -8
  291. package/src/plugins/defaults/memory-retrieval/injectors.ts +2 -18
  292. package/src/plugins/defaults/memory-retrieval/package.json +14 -0
  293. package/src/plugins/defaults/memory-v3-shadow/__tests__/carry-integration.test.ts +1157 -0
  294. package/src/plugins/defaults/memory-v3-shadow/__tests__/injection.test.ts +683 -0
  295. package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +161 -140
  296. package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +160 -0
  297. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +335 -316
  298. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +145 -53
  299. package/src/plugins/defaults/memory-v3-shadow/__tests__/render-injection.test.ts +38 -1
  300. package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +18 -8
  301. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +112 -71
  302. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +192 -51
  303. package/src/plugins/defaults/memory-v3-shadow/__tests__/types.test.ts +4 -16
  304. package/src/plugins/defaults/memory-v3-shadow/card.test.ts +173 -0
  305. package/src/plugins/defaults/memory-v3-shadow/card.ts +116 -0
  306. package/src/plugins/defaults/memory-v3-shadow/core-set.test.ts +104 -0
  307. package/src/plugins/defaults/memory-v3-shadow/core-set.ts +59 -0
  308. package/src/plugins/defaults/memory-v3-shadow/ever-injected-store.test.ts +305 -0
  309. package/src/plugins/defaults/memory-v3-shadow/ever-injected-store.ts +278 -0
  310. package/src/plugins/defaults/memory-v3-shadow/hot-set.test.ts +138 -0
  311. package/src/plugins/defaults/memory-v3-shadow/hot-set.ts +85 -0
  312. package/src/plugins/defaults/memory-v3-shadow/injector.ts +331 -24
  313. package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +119 -13
  314. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +169 -114
  315. package/src/plugins/defaults/memory-v3-shadow/page-content.ts +47 -16
  316. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +144 -66
  317. package/src/plugins/defaults/memory-v3-shadow/prune.test.ts +758 -0
  318. package/src/plugins/defaults/memory-v3-shadow/prune.ts +471 -0
  319. package/src/plugins/defaults/memory-v3-shadow/render-injection.ts +68 -16
  320. package/src/plugins/defaults/memory-v3-shadow/selection-log-store.ts +19 -12
  321. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +96 -43
  322. package/src/plugins/defaults/memory-v3-shadow/types.ts +34 -17
  323. package/src/plugins/defaults/title-generate/hooks/stop.ts +9 -11
  324. package/src/plugins/pipeline.ts +8 -5
  325. package/src/plugins/types.ts +5 -51
  326. package/src/providers/cache-control.ts +26 -0
  327. package/src/providers/inference/__tests__/base-url-route-validation.test.ts +1 -2
  328. package/src/providers/model-catalog.ts +13 -1
  329. package/src/providers/openai/__tests__/tool-choice-mapping.test.ts +147 -0
  330. package/src/providers/openai/chat-completions-provider.ts +46 -0
  331. package/src/providers/openai/responses-provider.ts +45 -0
  332. package/src/providers/registry.ts +0 -8
  333. package/src/runtime/__tests__/agent-wake.test.ts +0 -1
  334. package/src/runtime/agent-wake.ts +13 -0
  335. package/src/runtime/routes/__tests__/acp-routes.test.ts +151 -0
  336. package/src/runtime/routes/__tests__/consolidation-routes.test.ts +12 -50
  337. package/src/runtime/routes/__tests__/conversation-surface-routes.test.ts +322 -0
  338. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +0 -62
  339. package/src/runtime/routes/__tests__/plugins-routes.test.ts +31 -11
  340. package/src/runtime/routes/acp-routes.test.ts +106 -0
  341. package/src/runtime/routes/acp-routes.ts +248 -2
  342. package/src/runtime/routes/browser-tabs-routes.ts +1 -1
  343. package/src/runtime/routes/channel-verification-routes.ts +14 -5
  344. package/src/runtime/routes/chatgpt-subscription-auth-routes.ts +0 -14
  345. package/src/runtime/routes/consolidation-routes.ts +6 -82
  346. package/src/runtime/routes/conversation-list-routes.ts +6 -0
  347. package/src/runtime/routes/conversation-management-routes.ts +71 -0
  348. package/src/runtime/routes/identity-routes.ts +8 -0
  349. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +33 -34
  350. package/src/runtime/routes/inference-provider-connection-routes.ts +0 -45
  351. package/src/runtime/routes/plugins-routes.ts +34 -45
  352. package/src/runtime/routes/schedule-routes.ts +43 -5
  353. package/src/runtime/routes/settings-routes.ts +140 -15
  354. package/src/runtime/routes/skills-routes.ts +18 -6
  355. package/src/runtime/services/__tests__/conversation-serializer.test.ts +140 -0
  356. package/src/runtime/services/conversation-serializer.ts +38 -1
  357. package/src/runtime/verification-outbound-actions.ts +147 -2
  358. package/src/runtime/verification-templates.ts +29 -3
  359. package/src/schedule/schedule-store.ts +19 -0
  360. package/src/skills/catalog-install.ts +77 -13
  361. package/src/tasks/task-scheduler.ts +1 -0
  362. package/src/telemetry/types.ts +66 -1
  363. package/src/telemetry/usage-telemetry-reporter.test.ts +542 -13
  364. package/src/telemetry/usage-telemetry-reporter.ts +213 -20
  365. package/src/tools/browser/__tests__/browser-execution-acquire.test.ts +49 -2
  366. package/src/tools/browser/__tests__/browser-status.test.ts +29 -5
  367. package/src/tools/browser/browser-execution.ts +27 -9
  368. package/src/tools/browser/cdp-client/__tests__/factory.test.ts +380 -4
  369. package/src/tools/browser/cdp-client/__tests__/host-bridge-cdp-client.test.ts +107 -0
  370. package/src/tools/browser/cdp-client/__tests__/types.test.ts +6 -1
  371. package/src/tools/browser/cdp-client/factory.ts +217 -17
  372. package/src/tools/browser/cdp-client/host-bridge-cdp-client.ts +67 -0
  373. package/src/tools/browser/cdp-client/types.ts +22 -2
  374. package/src/tools/credential-execution/make-authenticated-request.ts +2 -1
  375. package/src/tools/credential-execution/manage-secure-command-tool.ts +169 -164
  376. package/src/tools/credential-execution/run-authenticated-command.ts +2 -1
  377. package/src/tools/executor.ts +39 -7
  378. package/src/tools/registry.ts +387 -5
  379. package/src/tools/schedule/create.ts +16 -0
  380. package/src/tools/schedule/list.ts +12 -4
  381. package/src/tools/schedule/update.ts +12 -0
  382. package/src/tools/skills/load.ts +11 -6
  383. package/src/tools/terminal/safe-env.ts +2 -0
  384. package/src/tools/types.ts +65 -9
  385. package/src/tools/workspace-tools/loader.ts +673 -0
  386. package/src/usage/attribution.ts +28 -0
  387. package/src/util/device-id.ts +17 -3
  388. package/src/util/platform.ts +16 -0
  389. package/tsconfig.plugin-api.json +13 -0
  390. package/src/__tests__/plugin-external-api.test.ts +0 -68
  391. package/src/__tests__/plugin-skill-contribution.test.ts +0 -355
  392. package/src/daemon/message-types/browser.ts +0 -10
  393. package/src/notifications/__tests__/emit-signal-home-feed.test.ts +0 -187
  394. package/src/plugins/defaults/memory-v3-shadow/__tests__/fixtures/eval-turns.json +0 -36
  395. package/src/plugins/defaults/memory-v3-shadow/__tests__/fixtures/live-turns.json +0 -37
  396. package/src/plugins/defaults/memory-v3-shadow/__tests__/working-set-eviction.test.ts +0 -106
  397. package/src/plugins/defaults/memory-v3-shadow/__tests__/working-set-skeleton.test.ts +0 -44
  398. package/src/plugins/defaults/memory-v3-shadow/working-set.ts +0 -91
  399. package/src/plugins/external-api.ts +0 -114
  400. package/src/plugins/plugin-skill-contributions.ts +0 -292
@@ -0,0 +1,1157 @@
1
+ /**
2
+ * End-to-end integration test + measured footprint gate for the memory-v3
3
+ * cache-aware carry rework (the whole PR 1–10 composition).
4
+ *
5
+ * SCOPE / ALTITUDE. A full daemon-assembly run is too heavy and too
6
+ * mock-fragile for a unit test (same altitude call as the sibling
7
+ * `live-integration.test.ts` / `shadow-integration.test.ts`). Instead this
8
+ * drives a scripted 10-turn conversation through the REAL pipeline units —
9
+ *
10
+ * orchestrate (core+hot stable prefix from real `loadCoreSet` /
11
+ * `computeHotSet`, real needle finder lane, pre-rendered prefix cards)
12
+ * → selectPool (real two-segment render + cache breakpoint; the PROVIDER
13
+ * is stubbed to return deterministic ids per scripted turn)
14
+ * → the real injectors (`memoryV3Injector` net-new cards + recordInjected
15
+ * + schedulePruneValve; `memoryV3SpotlightInjector` ephemeral window)
16
+ * → simulated runtime assembly (splice the card block onto the current
17
+ * user message; scoped spotlight strip-and-replace via the real
18
+ * `stripSpotlightInjections`) and metadata persistence (the
19
+ * user-prompt-submit hook's `memoryV3InjectedBlock` write)
20
+ * → the real prune valve against the live history (conversation-registry
21
+ * stubbed to the simulated message arrays)
22
+ * → rehydration from the temp DB (mirroring `daemon/conversation.ts`'s
23
+ * metadata splice + pruned-section filter) for the restart contract
24
+ *
25
+ * — and asserts the four contracts the rework ships on:
26
+ * 1. CACHE: the selector input's stable prefix is byte-identical across all
27
+ * turns (and carries the cache breakpoint); per-turn persistent renders
28
+ * are net-new cards only; prior turns' blocks stay frozen in history.
29
+ * 2. PRUNE: the valve trips when resident bytes exceed the cap, drops to
30
+ * target, exempts core/hot, and a pruned slug re-selected re-injects.
31
+ * 3. FORK: a fork inherits the dedup record and renders no duplicate cards.
32
+ * 4. RESTART: rebuilding history from the DB mid-script reproduces the live
33
+ * persistent layer byte-identically, with pruned sections still absent.
34
+ *
35
+ * The final test emits the measured per-turn footprint table (net-new card
36
+ * bytes and spotlight bytes as separate columns, plus resident bytes) — the
37
+ * cutover-gate evidence that steady-state per-turn fresh cost is
38
+ * net-new + spotlight, not O(working set).
39
+ *
40
+ * `mock.module` is process-global, so every stub delegates to the real
41
+ * implementation unless this file's tests are running (`carryMockActive`) —
42
+ * mirrors the sibling test files. Slugs/terms are generic placeholders.
43
+ */
44
+
45
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
46
+ import { tmpdir } from "node:os";
47
+ import { join } from "node:path";
48
+ import { Database } from "bun:sqlite";
49
+ import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";
50
+
51
+ import { drizzle } from "drizzle-orm/bun-sqlite";
52
+
53
+ import { stripSpotlightInjections } from "../../../../context/strip-injections.js";
54
+ import {
55
+ unwrapMemoryBlock,
56
+ wrapMemoryBlock,
57
+ } from "../../../../memory/memory-marker.js";
58
+ import { migrateAddMemoryV3Selections } from "../../../../memory/migrations/268-add-memory-v3-selections.js";
59
+ import { migrateAddMemoryV3EverInjected } from "../../../../memory/migrations/277-add-memory-v3-ever-injected.js";
60
+ import * as schema from "../../../../memory/schema.js";
61
+ import type { PageIndexEntry } from "../../../../memory/v2/page-index.js";
62
+ import type {
63
+ ContentBlock,
64
+ Message,
65
+ Provider,
66
+ ProviderResponse,
67
+ } from "../../../../providers/types.js";
68
+ import { cardBytes, renderCard } from "../card.js";
69
+ import { loadCoreSet } from "../core-set.js";
70
+ import type { EdgeGraph } from "../edge.js";
71
+ import { buildEdgeGraph } from "../edge.js";
72
+ import { buildSectionNeedle } from "../section-needle.js";
73
+ import { buildSectionIndex } from "../sections.js";
74
+ import {
75
+ MEMORY_V3_COMMIT_META_KEY,
76
+ type SectionIndex,
77
+ type Slug,
78
+ } from "../types.js";
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Module stubs (installed before the dynamic imports below; each delegates to
82
+ // the real implementation while `carryMockActive` is false).
83
+ // ---------------------------------------------------------------------------
84
+
85
+ let carryMockActive = false;
86
+
87
+ const realProvider = {
88
+ ...(await import("../../../../providers/provider-send-message.js")),
89
+ };
90
+ const realFlags = {
91
+ ...(await import("../../../../config/assistant-feature-flags.js")),
92
+ };
93
+ const realConfigLoader = { ...(await import("../../../../config/loader.js")) };
94
+ const realDbConnection = {
95
+ ...(await import("../../../../memory/db-connection.js")),
96
+ };
97
+ const realDense = { ...(await import("../dense.js")) };
98
+ const realPageContent = { ...(await import("../page-content.js")) };
99
+
100
+ let providerStub: Provider | null = null;
101
+ mock.module("../../../../providers/provider-send-message.js", () => ({
102
+ ...realProvider,
103
+ getConfiguredProvider: async (
104
+ ...args: Parameters<typeof realProvider.getConfiguredProvider>
105
+ ) =>
106
+ carryMockActive
107
+ ? providerStub
108
+ : realProvider.getConfiguredProvider(...args),
109
+ extractToolUse: (response: ProviderResponse) =>
110
+ response.content.find((b) => b.type === "tool_use"),
111
+ }));
112
+
113
+ mock.module("../../../../util/logger.js", () => ({
114
+ getLogger: () =>
115
+ new Proxy({} as Record<string, unknown>, {
116
+ get: (_t, prop) => (prop === "child" ? () => ({}) : () => {}),
117
+ }),
118
+ }));
119
+
120
+ // The dense lane is inert in this fixture — the needle drives the finder tail.
121
+ mock.module("../dense.js", () => ({
122
+ ...realDense,
123
+ denseLane: async (...args: Parameters<typeof realDense.denseLane>) =>
124
+ carryMockActive ? [] : realDense.denseLane(...args),
125
+ }));
126
+
127
+ let testSqlite: Database;
128
+ let testDb = makeDb();
129
+ function makeDb() {
130
+ testSqlite = new Database(":memory:");
131
+ const db = drizzle(testSqlite, { schema });
132
+ migrateAddMemoryV3EverInjected(db);
133
+ migrateAddMemoryV3Selections(db);
134
+ // Minimal `messages` shape — metadata persistence, the prune valve's
135
+ // v3-ownership scan, and the restart rehydration read only these columns.
136
+ testSqlite.run(/*sql*/ `
137
+ CREATE TABLE IF NOT EXISTS messages (
138
+ id TEXT PRIMARY KEY,
139
+ conversation_id TEXT NOT NULL,
140
+ role TEXT NOT NULL,
141
+ content TEXT NOT NULL,
142
+ metadata TEXT,
143
+ created_at INTEGER NOT NULL
144
+ )
145
+ `);
146
+ return db;
147
+ }
148
+ mock.module("../../../../memory/db-connection.js", () => ({
149
+ ...realDbConnection,
150
+ getDb: () => (carryMockActive ? testDb : realDbConnection.getDb()),
151
+ getSqliteFrom: (db: unknown) =>
152
+ carryMockActive
153
+ ? testSqlite
154
+ : realDbConnection.getSqliteFrom(
155
+ db as Parameters<typeof realDbConnection.getSqliteFrom>[0],
156
+ ),
157
+ }));
158
+
159
+ /** Mutable prune config: `null` until the script opens the valve window. */
160
+ let pruneConfig: {
161
+ maxResidentBytes: number;
162
+ targetResidentBytes: number;
163
+ } | null = null;
164
+ const SPOTLIGHT_N = 6;
165
+ const SPOTLIGHT_WINDOW_TURNS = 2;
166
+ mock.module("../../../../config/loader.js", () => ({
167
+ ...realConfigLoader,
168
+ getConfig: () =>
169
+ carryMockActive
170
+ ? {
171
+ memory: {
172
+ v3: {
173
+ spotlight: {
174
+ n: SPOTLIGHT_N,
175
+ windowTurns: SPOTLIGHT_WINDOW_TURNS,
176
+ },
177
+ prune: pruneConfig ?? undefined,
178
+ },
179
+ },
180
+ }
181
+ : realConfigLoader.getConfig(),
182
+ }));
183
+
184
+ mock.module("../../../../config/assistant-feature-flags.js", () => ({
185
+ ...realFlags,
186
+ isAssistantFeatureFlagEnabled: (
187
+ key: string,
188
+ config: Parameters<typeof realFlags.isAssistantFeatureFlagEnabled>[1],
189
+ ) => {
190
+ if (!carryMockActive) {
191
+ return realFlags.isAssistantFeatureFlagEnabled(
192
+ key as Parameters<typeof realFlags.isAssistantFeatureFlagEnabled>[0],
193
+ config,
194
+ );
195
+ }
196
+ return key === "memory-v3-live";
197
+ },
198
+ }));
199
+
200
+ // Cards render from the in-memory fixture corpus via the REAL renderer, so
201
+ // injected bytes are exactly what `renderCard` produces for these pages.
202
+ mock.module("../page-content.js", () => ({
203
+ ...realPageContent,
204
+ renderV3CardContent: async (slug: Slug) =>
205
+ carryMockActive
206
+ ? RAW[slug]
207
+ ? renderCard(slug, RAW[slug])
208
+ : ""
209
+ : realPageContent.renderV3CardContent(slug),
210
+ }));
211
+
212
+ // The prune valve resolves the live conversation through the daemon registry.
213
+ // Route it at the simulated per-conversation histories (and keep the heavy
214
+ // daemon module graph out of this test process — same call as injection.test.ts).
215
+ const histories = new Map<string, Message[]>();
216
+ mock.module("../../../../daemon/conversation-registry.js", () => ({
217
+ findConversationOrSubagent: (conversationId: string) => {
218
+ const messages = histories.get(conversationId);
219
+ return messages ? { messages } : undefined;
220
+ },
221
+ }));
222
+
223
+ // Real orchestration is wired under the injectors' `observeTurn` seam: each
224
+ // scripted turn runs the REAL `orchestrate` over the fixture lanes and logs
225
+ // selections through the shadow plugin's REAL attribution/writer (so hot-set
226
+ // frecency and the prune valve's recency ranking see real rows), then
227
+ // normalizes the rows' `created_at` to a per-turn stamp so recency ranking is
228
+ // deterministic regardless of wall-clock resolution.
229
+ const { orchestrate } = await import("../orchestrate.js");
230
+ const realShadowPlugin = { ...(await import("../shadow-plugin.js")) };
231
+
232
+ /** The scripted query per (conversation, turn) — the selector's `keep` list
233
+ * lives in the module-level `keep` the provider stub reads. */
234
+ const scriptedTurns = new Map<string, string>();
235
+
236
+ async function scriptedObserveTurn(conversationId: string, turnIndex: number) {
237
+ if (!carryMockActive) {
238
+ return realShadowPlugin.observeTurn(conversationId, turnIndex);
239
+ }
240
+ const query = scriptedTurns.get(`${conversationId}:${turnIndex}`);
241
+ if (query === undefined) {
242
+ throw new Error(`no scripted turn for ${conversationId}:${turnIndex}`);
243
+ }
244
+ const result = await orchestrate(
245
+ {
246
+ conversationId,
247
+ turnNumber: turnIndex,
248
+ currentMessage: query,
249
+ recentContext: "prior context",
250
+ },
251
+ {
252
+ sectionIndex: lanes.sectionIndex,
253
+ needle: lanes.needle,
254
+ denseConfig: {} as never,
255
+ edgeGraph: lanes.edgeGraph,
256
+ coreSlugs: lanes.coreSlugs,
257
+ hotSlugs: lanes.hotSlugs,
258
+ prefixCards: lanes.prefixCards,
259
+ },
260
+ );
261
+ realShadowPlugin.writeSelections(
262
+ conversationId,
263
+ turnIndex,
264
+ realShadowPlugin.attributeSelections(result),
265
+ );
266
+ testSqlite
267
+ .query(
268
+ /*sql*/ `
269
+ UPDATE memory_v3_selections SET created_at = ?
270
+ WHERE conversation_id = ? AND turn = ?
271
+ `,
272
+ )
273
+ .run(BASE + turnIndex * 1000, conversationId, turnIndex);
274
+ return result;
275
+ }
276
+
277
+ mock.module("../shadow-plugin.js", () => ({
278
+ ...realShadowPlugin,
279
+ observeTurn: scriptedObserveTurn,
280
+ }));
281
+
282
+ const {
283
+ memoryV3Injector,
284
+ memoryV3SpotlightInjector,
285
+ resetMemoryV3InjectorStateForTests,
286
+ } = await import("../injector.js");
287
+ const {
288
+ forkEverInjected,
289
+ getActiveSlugs,
290
+ getInjected,
291
+ getPrunedSlugs,
292
+ MEMORY_V3_INJECTED_BLOCK_METADATA_KEY,
293
+ residentBytes,
294
+ } = await import("../ever-injected-store.js");
295
+ const { filterPrunedCardSections, flushPruneValveForTests, parseCardSections } =
296
+ await import("../prune.js");
297
+ const { renderCardsBlockInner, V3_CARDS_INJECTION_HEADER } =
298
+ await import("../render-injection.js");
299
+ const { computeHotSet } = await import("../hot-set.js");
300
+
301
+ // ---------------------------------------------------------------------------
302
+ // Fixture corpus: 20 generic pages, each with a distinctive term so a needle
303
+ // query selects exactly the intended pages. `page-a`/`page-b` carry padded
304
+ // leads so the prune window (which must free their two cards) stays
305
+ // deterministic (their bytes exceed turn 7's incoming cards).
306
+ // ---------------------------------------------------------------------------
307
+
308
+ const PAGE_TERMS: Record<Slug, string> = {
309
+ "core-alpha": "tamarind",
310
+ "core-beta": "ugli",
311
+ "hot-one": "quince",
312
+ "hot-two": "raspberry",
313
+ "hot-three": "strawberry",
314
+ "page-a": "apple",
315
+ "page-b": "banana",
316
+ "page-c": "cherry",
317
+ "page-d": "dragonfruit",
318
+ "page-e": "elderberry",
319
+ "page-f": "fig",
320
+ "page-g": "guava",
321
+ "page-h": "honeydew",
322
+ "page-i": "imbe",
323
+ "page-j": "jackfruit",
324
+ "page-k": "kiwi",
325
+ "page-l": "lemon",
326
+ "page-m": "mango",
327
+ "page-n": "nectarine",
328
+ "page-o": "olive",
329
+ };
330
+ const ALL_SLUGS = Object.keys(PAGE_TERMS);
331
+
332
+ const LONG_LEAD_PAD =
333
+ " This page carries a deliberately longer descriptive lead so its card" +
334
+ " outweighs later cards and the scripted prune window frees exactly this" +
335
+ " page when the valve trips.";
336
+
337
+ function pageText(slug: Slug): string {
338
+ const term = PAGE_TERMS[slug]!;
339
+ const pad = slug === "page-a" || slug === "page-b" ? LONG_LEAD_PAD : "";
340
+ return (
341
+ `lead for ${slug} covering ${term}.${pad}\n\n` +
342
+ `## Detail\n${term} detail material for ${slug}\n\n` +
343
+ `## Notes\ngeneral notes for ${slug}`
344
+ );
345
+ }
346
+
347
+ const RAW: Record<Slug, string> = Object.fromEntries(
348
+ ALL_SLUGS.map((slug) => [slug, pageText(slug)]),
349
+ );
350
+
351
+ /** A slug's card exactly as the real renderer produces it from the fixture. */
352
+ function card(slug: Slug): string {
353
+ return renderCard(slug, RAW[slug]!);
354
+ }
355
+
356
+ const CORE_SLUGS: Slug[] = ["core-alpha", "core-beta"];
357
+ const HOT_SLUGS: Slug[] = ["hot-one", "hot-two", "hot-three"];
358
+
359
+ const CONV = "conv-carry";
360
+ const FORK_CONV = "conv-carry-fork";
361
+ /** Fixed epoch base for all timestamps (determinism). */
362
+ const BASE = 1_700_000_000_000;
363
+ const DAY_MS = 24 * 60 * 60 * 1000;
364
+
365
+ interface FixtureLanes {
366
+ sectionIndex: SectionIndex;
367
+ needle: ReturnType<typeof buildSectionNeedle>;
368
+ edgeGraph: EdgeGraph;
369
+ coreSlugs: Slug[];
370
+ hotSlugs: Slug[];
371
+ prefixCards: Map<Slug, string>;
372
+ }
373
+ let lanes: FixtureLanes;
374
+ let workspaceDir: string;
375
+
376
+ /** Build the lanes exactly as `initLanes` does: real core-set load (curated
377
+ * file in a temp workspace), real frecency hot set over seeded selection
378
+ * rows, real section index/needle/edge graph, pre-rendered prefix cards. */
379
+ async function buildFixtureLanes(): Promise<FixtureLanes> {
380
+ workspaceDir = mkdtempSync(join(tmpdir(), "carry-integration-"));
381
+ mkdirSync(join(workspaceDir, "memory"), { recursive: true });
382
+ writeFileSync(
383
+ join(workspaceDir, "memory", "core-pages.md"),
384
+ [
385
+ "# Core pages (maintainer-curated)",
386
+ "- [[core-alpha]]",
387
+ "- core-beta",
388
+ "Prose annotation lines are ignored by the loader.",
389
+ "",
390
+ ].join("\n"),
391
+ );
392
+
393
+ // Seed a selections history (a PRIOR conversation) making three slugs hot,
394
+ // with distinct frecency so the hot order is deterministic.
395
+ const seed = testSqlite.query(/*sql*/ `
396
+ INSERT INTO memory_v3_selections
397
+ (conversation_id, turn, slug, source, pinned, created_at)
398
+ VALUES (?, ?, ?, 'needle', 0, ?)
399
+ `);
400
+ const seedCounts: Array<[Slug, number]> = [
401
+ ["hot-one", 3],
402
+ ["hot-two", 2],
403
+ ["hot-three", 1],
404
+ ];
405
+ for (const [slug, count] of seedCounts) {
406
+ for (let turn = 1; turn <= count; turn++) {
407
+ seed.run("conv-seed", turn, slug, BASE - 60_000);
408
+ }
409
+ }
410
+
411
+ const sectionIndex = await buildSectionIndex(
412
+ ALL_SLUGS,
413
+ async (slug) => RAW[slug]!,
414
+ );
415
+ const needle = buildSectionNeedle(sectionIndex);
416
+ const entries: PageIndexEntry[] = ALL_SLUGS.map((slug, i) => ({
417
+ id: i + 1,
418
+ slug,
419
+ summary: `summary of ${slug}`,
420
+ edges: [],
421
+ leaves: [],
422
+ modifiedAt: 0,
423
+ }));
424
+ const edgeGraph = await buildEdgeGraph(entries, async (slug) => RAW[slug]!);
425
+
426
+ const coreSlugs = loadCoreSet(workspaceDir).filter((slug) =>
427
+ sectionIndex.byArticle.has(slug),
428
+ );
429
+ const hotSlugs = computeHotSet(
430
+ { db: testDb },
431
+ {
432
+ k: 3,
433
+ halfLifeMs: 14 * DAY_MS,
434
+ now: BASE,
435
+ excludeSlugs: new Set(coreSlugs),
436
+ },
437
+ )
438
+ .map((entry) => entry.slug)
439
+ .filter((slug) => sectionIndex.byArticle.has(slug));
440
+
441
+ const prefixCards = new Map<Slug, string>(
442
+ [...coreSlugs, ...hotSlugs].map((slug) => [slug, card(slug)]),
443
+ );
444
+ return { sectionIndex, needle, edgeGraph, coreSlugs, hotSlugs, prefixCards };
445
+ }
446
+
447
+ // ---------------------------------------------------------------------------
448
+ // Selector provider stub: selects the scripted `keep` slugs by pool id and
449
+ // captures the rendered stable-prefix block (text + cache_control) per call.
450
+ // ---------------------------------------------------------------------------
451
+
452
+ let keep: Slug[] = [];
453
+ let selectCalls = 0;
454
+ const stablePrefixCaptures: Array<{
455
+ text: string;
456
+ cacheControl: unknown;
457
+ } | null> = [];
458
+
459
+ function toolUseResponse(input: Record<string, unknown>): ProviderResponse {
460
+ return {
461
+ model: "stub-model",
462
+ stopReason: "tool_use",
463
+ usage: { inputTokens: 0, outputTokens: 0 },
464
+ content: [{ type: "tool_use", id: "tu-1", name: "select_pages", input }],
465
+ };
466
+ }
467
+
468
+ /** Parse the two-segment selector input into the globally-numbered pool slug
469
+ * list (same helper as the sibling integration tests). */
470
+ function candidateSlugs(messages: Message[]): Slug[] {
471
+ const entries: Array<{ id: number; slug: string }> = [];
472
+ for (const msg of messages) {
473
+ for (const block of msg.content) {
474
+ if (block.type !== "text") continue;
475
+ const cards = /<candidate_cards>\n([\s\S]*?)\n<\/candidate_cards>/.exec(
476
+ block.text,
477
+ );
478
+ if (cards) {
479
+ for (const m of cards[1].matchAll(
480
+ /^\[(\d+)\] # memory\/concepts\/(.+)\.md$/gm,
481
+ )) {
482
+ entries.push({ id: Number(m[1]), slug: m[2]! });
483
+ }
484
+ }
485
+ const finder = /<candidates>\n([\s\S]*?)\n<\/candidates>/.exec(
486
+ block.text,
487
+ );
488
+ if (finder) {
489
+ for (const line of finder[1].split("\n")) {
490
+ const m = /^\[(\d+)\] (\S+)(?: — |$)/.exec(line);
491
+ if (m) entries.push({ id: Number(m[1]), slug: m[2]! });
492
+ }
493
+ }
494
+ }
495
+ }
496
+ return entries.sort((a, b) => a.id - b.id).map((e) => e.slug);
497
+ }
498
+
499
+ function makeProviderStub(): Provider {
500
+ return {
501
+ name: "stub",
502
+ sendMessage: async (messages) => {
503
+ selectCalls += 1;
504
+ const first = messages[0]!.content[0] as
505
+ | (ContentBlock & { cache_control?: unknown })
506
+ | undefined;
507
+ stablePrefixCaptures.push(
508
+ first && first.type === "text" && first.cache_control
509
+ ? { text: first.text, cacheControl: first.cache_control }
510
+ : null,
511
+ );
512
+ const ids: number[] = [];
513
+ candidateSlugs(messages).forEach((slug, i) => {
514
+ if (keep.includes(slug)) ids.push(i + 1);
515
+ });
516
+ return toolUseResponse({ ids });
517
+ },
518
+ };
519
+ }
520
+
521
+ // ---------------------------------------------------------------------------
522
+ // Turn driver: simulates exactly what runtime assembly + the user-prompt-submit
523
+ // hook do around the injectors each turn (see the module doc).
524
+ // ---------------------------------------------------------------------------
525
+
526
+ interface TurnRecord {
527
+ turn: number;
528
+ netNewSlugs: Slug[];
529
+ netNewBytes: number;
530
+ blockText: string;
531
+ cardsPlacement: string;
532
+ spotlightText: string;
533
+ spotlightBytes: number;
534
+ spotlightEntries: number;
535
+ spotlightPlacement: string;
536
+ residentBytes: number;
537
+ prunedSlugs: Set<string>;
538
+ /** JSON of the spotlight-stripped (persistent-layer) history after the turn. */
539
+ snapshot: string;
540
+ }
541
+
542
+ function insertMessageRow(
543
+ convId: string,
544
+ id: string,
545
+ role: "user" | "assistant",
546
+ content: ContentBlock[],
547
+ createdAt: number,
548
+ ): void {
549
+ testSqlite
550
+ .query(
551
+ /*sql*/ `
552
+ INSERT INTO messages (id, conversation_id, role, content, metadata, created_at)
553
+ VALUES (?, ?, ?, ?, NULL, ?)
554
+ `,
555
+ )
556
+ .run(id, convId, role, JSON.stringify(content), createdAt);
557
+ }
558
+
559
+ function persistentView(history: Message[]): string {
560
+ return JSON.stringify(stripSpotlightInjections(history));
561
+ }
562
+
563
+ async function runTurn(
564
+ convId: string,
565
+ turnIndex: number,
566
+ query: string,
567
+ keepList: Slug[],
568
+ ): Promise<TurnRecord> {
569
+ const history = histories.get(convId)!;
570
+ scriptedTurns.set(`${convId}:${turnIndex}`, query);
571
+ keep = keepList;
572
+
573
+ const userRowId = `${convId}-m${turnIndex}-user`;
574
+ const userContent: ContentBlock[] = [
575
+ { type: "text", text: `user message ${turnIndex}: ${query}` },
576
+ ];
577
+ insertMessageRow(
578
+ convId,
579
+ userRowId,
580
+ "user",
581
+ userContent,
582
+ BASE + turnIndex * 1000,
583
+ );
584
+ history.push({ role: "user", content: [...userContent] });
585
+
586
+ const ctx = {
587
+ requestId: `req-${turnIndex}`,
588
+ conversationId: convId,
589
+ turnIndex,
590
+ trust: {
591
+ sourceChannel: "vellum" as const,
592
+ trustClass: "guardian" as const,
593
+ },
594
+ };
595
+
596
+ const activeBefore = getActiveSlugs(convId);
597
+ const cards = await memoryV3Injector.produce(ctx);
598
+ if (!cards)
599
+ throw new Error(`turn ${turnIndex}: cards injector returned null`);
600
+ // Runtime assembly invokes the block's attachment-commit callback at its
601
+ // user-tail commit point — this is where the everInjected store records
602
+ // the turn's cards (and the prune valve is scheduled).
603
+ const commit = cards.meta?.[MEMORY_V3_COMMIT_META_KEY];
604
+ if (typeof commit === "function") (commit as () => void)();
605
+ const netNewSlugs = [...getActiveSlugs(convId)].filter(
606
+ (slug) => !activeBefore.has(slug),
607
+ );
608
+ const injected = getInjected(convId);
609
+ const netNewBytes = netNewSlugs.reduce(
610
+ (sum, slug) => sum + injected.get(slug)!.bytes,
611
+ 0,
612
+ );
613
+
614
+ // Runtime assembly: a non-empty card block splices onto the CURRENT user
615
+ // message; the user-prompt-submit hook persists the unwrapped inner text
616
+ // under the v3 metadata key (assembly captures it unwrapped).
617
+ if (cards.text.length > 0) {
618
+ const tail = history[history.length - 1]!;
619
+ tail.content = [{ type: "text", text: cards.text }, ...tail.content];
620
+ testSqlite
621
+ .query(/*sql*/ `UPDATE messages SET metadata = ? WHERE id = ?`)
622
+ .run(
623
+ JSON.stringify({
624
+ [MEMORY_V3_INJECTED_BLOCK_METADATA_KEY]: unwrapMemoryBlock(
625
+ cards.text,
626
+ ),
627
+ }),
628
+ userRowId,
629
+ );
630
+ }
631
+
632
+ // Spotlight: scoped strip of the stale block (real assembly helper), then
633
+ // append the fresh one at the current-message tail.
634
+ const spotlight = await memoryV3SpotlightInjector.produce(ctx);
635
+ const stripped = stripSpotlightInjections(history);
636
+ history.splice(0, history.length, ...stripped);
637
+ if (spotlight) {
638
+ const tail = history[history.length - 1]!;
639
+ tail.content = [...tail.content, { type: "text", text: spotlight.text }];
640
+ }
641
+
642
+ const replyContent: ContentBlock[] = [
643
+ { type: "text", text: `reply ${turnIndex}` },
644
+ ];
645
+ insertMessageRow(
646
+ convId,
647
+ `${convId}-m${turnIndex}-assistant`,
648
+ "assistant",
649
+ replyContent,
650
+ BASE + turnIndex * 1000 + 500,
651
+ );
652
+ history.push({ role: "assistant", content: replyContent });
653
+
654
+ // End of turn: the deferred prune valve (scheduled by the injector) runs.
655
+ await flushPruneValveForTests();
656
+
657
+ return {
658
+ turn: turnIndex,
659
+ netNewSlugs,
660
+ netNewBytes,
661
+ blockText: cards.text,
662
+ cardsPlacement: cards.placement ?? "",
663
+ spotlightText: spotlight?.text ?? "",
664
+ spotlightBytes: spotlight ? Buffer.byteLength(spotlight.text, "utf8") : 0,
665
+ spotlightEntries: spotlight
666
+ ? (spotlight.text.match(/^## memory\/concepts\//gm) ?? []).length
667
+ : 0,
668
+ spotlightPlacement: spotlight?.placement ?? "",
669
+ residentBytes: residentBytes(convId),
670
+ prunedSlugs: getPrunedSlugs(convId),
671
+ snapshot: persistentView(history),
672
+ };
673
+ }
674
+
675
+ /** Rebuild a conversation's history from the temp DB — mirrors the
676
+ * `daemon/conversation.ts` v3 rehydration splice: re-wrap the persisted
677
+ * metadata block, filter pruned slugs' card sections, skip an all-pruned
678
+ * block, and prepend onto the stored content. */
679
+ function rehydrateFromDb(convId: string): Message[] {
680
+ const rows = testSqlite
681
+ .query(
682
+ /*sql*/ `
683
+ SELECT role, content, metadata FROM messages
684
+ WHERE conversation_id = ? ORDER BY created_at ASC, rowid ASC
685
+ `,
686
+ )
687
+ .all(convId) as Array<{
688
+ role: "user" | "assistant";
689
+ content: string;
690
+ metadata: string | null;
691
+ }>;
692
+ const pruned = getPrunedSlugs(convId);
693
+ return rows.map((row) => {
694
+ let content = JSON.parse(row.content) as ContentBlock[];
695
+ if (row.role === "user" && row.metadata) {
696
+ const meta = JSON.parse(row.metadata) as Record<string, unknown>;
697
+ const block = meta[MEMORY_V3_INJECTED_BLOCK_METADATA_KEY];
698
+ if (typeof block === "string") {
699
+ const resident = filterPrunedCardSections(
700
+ unwrapMemoryBlock(block),
701
+ pruned,
702
+ );
703
+ if (resident.length > 0) {
704
+ content = [
705
+ { type: "text", text: wrapMemoryBlock(resident) },
706
+ ...content,
707
+ ];
708
+ }
709
+ }
710
+ }
711
+ return { role: row.role, content };
712
+ });
713
+ }
714
+
715
+ // ---------------------------------------------------------------------------
716
+ // The scripted 10-turn run (+ fork, + restart) executed once; tests assert on
717
+ // the collected artifacts.
718
+ // ---------------------------------------------------------------------------
719
+
720
+ const records: TurnRecord[] = [];
721
+ let forkRecord: TurnRecord;
722
+ let pruneWindow: { max: number; target: number; bytesFreedExpected: number };
723
+ let restartLiveJson = "";
724
+ let restartRehydratedJson = "";
725
+
726
+ /** Per-turn script: query terms drive the needle; `keep` is the deterministic
727
+ * selector output (subset of stable prefix ∪ needle hits). */
728
+ const SCRIPT: Array<{ query: string; keep: Slug[]; expectNetNew: Slug[] }> = [
729
+ // 1 — first turn: core + hot pages selected via the stable prefix, plus one
730
+ // finder page.
731
+ {
732
+ query: "apple",
733
+ keep: ["core-alpha", "core-beta", "hot-one", "page-a"],
734
+ expectNetNew: ["core-alpha", "core-beta", "hot-one", "page-a"],
735
+ },
736
+ // 2 — finder hit on a HOT page (raspberry) keeps its matched section.
737
+ {
738
+ query: "banana raspberry",
739
+ keep: ["hot-two", "page-b"],
740
+ expectNetNew: ["hot-two", "page-b"],
741
+ },
742
+ // 3 — ALL-REPEAT turn: every selection already resident → zero new bytes.
743
+ {
744
+ query: "apple banana",
745
+ keep: ["page-a", "page-b", "core-alpha"],
746
+ expectNetNew: [],
747
+ },
748
+ // 4 — topic shift: four fresh pages.
749
+ {
750
+ query: "cherry dragonfruit elderberry fig",
751
+ keep: ["page-c", "page-d", "page-e", "page-f"],
752
+ expectNetNew: ["page-c", "page-d", "page-e", "page-f"],
753
+ },
754
+ // 5–6 — steady accumulation.
755
+ {
756
+ query: "guava honeydew imbe",
757
+ keep: ["page-g", "page-h", "page-i"],
758
+ expectNetNew: ["page-g", "page-h", "page-i"],
759
+ },
760
+ {
761
+ query: "jackfruit kiwi lemon",
762
+ keep: ["page-j", "page-k", "page-l"],
763
+ expectNetNew: ["page-j", "page-k", "page-l"],
764
+ },
765
+ // 7 — the prune valve trips after this turn (window configured in beforeAll).
766
+ {
767
+ query: "mango nectarine",
768
+ keep: ["page-m", "page-n"],
769
+ expectNetNew: ["page-m", "page-n"],
770
+ },
771
+ // 8 — one more page; the restart + fork checkpoints follow this turn.
772
+ { query: "olive", keep: ["page-o"], expectNetNew: ["page-o"] },
773
+ // 9 — a PRUNED slug re-selected re-injects as a fresh card.
774
+ { query: "apple", keep: ["page-a"], expectNetNew: ["page-a"] },
775
+ // 10 — final all-repeat turn (steady state: fresh cost is spotlight-only).
776
+ { query: "cherry", keep: ["page-c", "core-beta"], expectNetNew: [] },
777
+ ];
778
+
779
+ beforeAll(async () => {
780
+ carryMockActive = true;
781
+ testDb = makeDb();
782
+ providerStub = makeProviderStub();
783
+ resetMemoryV3InjectorStateForTests();
784
+ records.length = 0;
785
+ stablePrefixCaptures.length = 0;
786
+ selectCalls = 0;
787
+ pruneConfig = null;
788
+ histories.clear();
789
+ scriptedTurns.clear();
790
+
791
+ lanes = await buildFixtureLanes();
792
+ histories.set(CONV, []);
793
+
794
+ // Turns 1–6.
795
+ for (let turn = 1; turn <= 6; turn++) {
796
+ const step = SCRIPT[turn - 1]!;
797
+ records.push(await runTurn(CONV, turn, step.query, step.keep));
798
+ }
799
+
800
+ // Open the prune window so turn 7 tips over the cap and one pass frees
801
+ // EXACTLY page-a + page-b (the least-recently-selected non-exempt cards;
802
+ // their padded leads make them outweigh turn 7's incoming cards, so
803
+ // target < max holds).
804
+ const residentAfter6 = residentBytes(CONV);
805
+ const incoming = cardBytes(card("page-m")) + cardBytes(card("page-n"));
806
+ const bytesFreedExpected =
807
+ cardBytes(card("page-a")) + cardBytes(card("page-b"));
808
+ pruneWindow = {
809
+ max: residentAfter6,
810
+ target: residentAfter6 + incoming - bytesFreedExpected,
811
+ bytesFreedExpected,
812
+ };
813
+ pruneConfig = {
814
+ maxResidentBytes: pruneWindow.max,
815
+ targetResidentBytes: pruneWindow.target,
816
+ };
817
+ records.push(await runTurn(CONV, 7, SCRIPT[6]!.query, SCRIPT[6]!.keep));
818
+ // Close the valve window so the remaining turns exercise re-injection
819
+ // without a second prune.
820
+ pruneConfig = null;
821
+
822
+ records.push(await runTurn(CONV, 8, SCRIPT[7]!.query, SCRIPT[7]!.keep));
823
+
824
+ // RESTART checkpoint: rebuild history from the DB, compare against the live
825
+ // persistent layer, then ADOPT the rehydrated history and reset the
826
+ // injectors' in-memory state (orchestration memo + spotlight ring) — a real
827
+ // daemon restart does both.
828
+ restartLiveJson = persistentView(histories.get(CONV)!);
829
+ const rehydrated = rehydrateFromDb(CONV);
830
+ restartRehydratedJson = JSON.stringify(rehydrated);
831
+ histories.set(CONV, rehydrated);
832
+ resetMemoryV3InjectorStateForTests();
833
+
834
+ // FORK checkpoint: copy the messages (metadata blocks ride along, as in a
835
+ // real fork) and the everInjected record (the `forkConversation()` hook),
836
+ // then load the fork like a fresh conversation and run one turn on it.
837
+ testSqlite
838
+ .query(
839
+ /*sql*/ `
840
+ INSERT INTO messages (id, conversation_id, role, content, metadata, created_at)
841
+ SELECT id || '-fork', ?, role, content, metadata, created_at
842
+ FROM messages WHERE conversation_id = ?
843
+ `,
844
+ )
845
+ .run(FORK_CONV, CONV);
846
+ forkEverInjected(testDb, CONV, FORK_CONV);
847
+ histories.set(FORK_CONV, rehydrateFromDb(FORK_CONV));
848
+ forkRecord = await runTurn(FORK_CONV, 9, "strawberry guava", [
849
+ "page-g",
850
+ "hot-three",
851
+ ]);
852
+
853
+ // Turns 9–10 on the (restarted) parent.
854
+ records.push(await runTurn(CONV, 9, SCRIPT[8]!.query, SCRIPT[8]!.keep));
855
+ records.push(await runTurn(CONV, 10, SCRIPT[9]!.query, SCRIPT[9]!.keep));
856
+ });
857
+
858
+ afterAll(async () => {
859
+ await flushPruneValveForTests();
860
+ carryMockActive = false;
861
+ if (workspaceDir) rmSync(workspaceDir, { recursive: true, force: true });
862
+ });
863
+
864
+ // ---------------------------------------------------------------------------
865
+ // Contract assertions.
866
+ // ---------------------------------------------------------------------------
867
+
868
+ describe("memory-v3 carry integration — cache contract", () => {
869
+ test("lanes: curated core in file order; frecency hot set in score order", () => {
870
+ expect(lanes.coreSlugs).toEqual(CORE_SLUGS);
871
+ expect(lanes.hotSlugs).toEqual(HOT_SLUGS);
872
+ });
873
+
874
+ test("selector stable prefix is byte-identical across all 10 turns and carries the cache breakpoint", () => {
875
+ // 10 parent turns + 1 fork turn, one selector call each.
876
+ expect(selectCalls).toBe(11);
877
+ expect(stablePrefixCaptures).toHaveLength(11);
878
+ const first = stablePrefixCaptures[0];
879
+ expect(first).not.toBeNull();
880
+ expect(first!.cacheControl).toEqual({ type: "ephemeral", ttl: "1h" });
881
+ for (const capture of stablePrefixCaptures) {
882
+ expect(capture).not.toBeNull();
883
+ expect(capture!.text).toBe(first!.text);
884
+ expect(capture!.cacheControl).toEqual(first!.cacheControl);
885
+ }
886
+ // The prefix is the five core+hot FULL cards, numbered in lane order.
887
+ const prefixSlugs = [...CORE_SLUGS, ...HOT_SLUGS];
888
+ for (const [i, slug] of prefixSlugs.entries()) {
889
+ expect(first!.text).toContain(`[${i + 1}] ${card(slug)}`);
890
+ }
891
+ });
892
+
893
+ test("per-turn persistent render is net-new cards only; all-repeat turns render zero bytes", () => {
894
+ for (const [i, record] of records.entries()) {
895
+ const expected = SCRIPT[i]!.expectNetNew;
896
+ expect(record.cardsPlacement).toBe("after-memory-prefix");
897
+ expect(new Set(record.netNewSlugs)).toEqual(new Set(expected));
898
+ if (expected.length === 0) {
899
+ // All-repeat turn: the block is still produced (v2 suppression) but
900
+ // carries no bytes.
901
+ expect(record.blockText).toBe("");
902
+ expect(record.netNewBytes).toBe(0);
903
+ continue;
904
+ }
905
+ // The block contains exactly the net-new cards, each byte-identical to
906
+ // a fresh render, behind the shared read-affordance header.
907
+ const inner = unwrapMemoryBlock(record.blockText);
908
+ const parsed = parseCardSections(inner);
909
+ expect(parsed.preamble).toBe(V3_CARDS_INJECTION_HEADER);
910
+ expect(new Set(parsed.sections.map((s) => s.slug))).toEqual(
911
+ new Set(expected),
912
+ );
913
+ for (const section of parsed.sections) {
914
+ expect(section.text).toBe(card(section.slug));
915
+ }
916
+ expect(record.netNewBytes).toBe(
917
+ expected.reduce((sum, slug) => sum + cardBytes(card(slug)), 0),
918
+ );
919
+ }
920
+ });
921
+
922
+ test("turn 1's block is byte-exact in selection order (stable prefix first, then finder)", () => {
923
+ expect(records[0]!.blockText).toBe(
924
+ wrapMemoryBlock(
925
+ renderCardsBlockInner([
926
+ card("core-alpha"),
927
+ card("core-beta"),
928
+ card("hot-one"),
929
+ card("page-a"),
930
+ ]),
931
+ ),
932
+ );
933
+ });
934
+
935
+ test("prior turns' blocks stay frozen in history (byte-prefix), except the prune valve's one amortized strip", () => {
936
+ // Pre-prune (turns 1–6): each snapshot is a byte-prefix of the next.
937
+ for (let i = 0; i < 5; i++) {
938
+ expect(
939
+ records[i + 1]!.snapshot.startsWith(records[i]!.snapshot.slice(0, -1)),
940
+ ).toBe(true);
941
+ }
942
+ // Post-prune (turns 8–10, spanning the restart): frozen again.
943
+ for (let i = 7; i < 9; i++) {
944
+ expect(
945
+ records[i + 1]!.snapshot.startsWith(records[i]!.snapshot.slice(0, -1)),
946
+ ).toBe(true);
947
+ }
948
+ // Turn 7's strip touched EXACTLY the pruned cards: turn 1's block lost
949
+ // page-a (byte-exact remainder), turn 2's lost page-b.
950
+ const after7 = JSON.parse(records[6]!.snapshot) as Message[];
951
+ const turn1Block = (after7[0]!.content[0] as { text: string }).text;
952
+ expect(turn1Block).toBe(
953
+ wrapMemoryBlock(
954
+ renderCardsBlockInner([
955
+ card("core-alpha"),
956
+ card("core-beta"),
957
+ card("hot-one"),
958
+ ]),
959
+ ),
960
+ );
961
+ const turn2Block = (after7[2]!.content[0] as { text: string }).text;
962
+ expect(turn2Block).toBe(
963
+ wrapMemoryBlock(renderCardsBlockInner([card("hot-two")])),
964
+ );
965
+ });
966
+
967
+ test("accounting: resident bytes equal cumulative net-new card bytes minus pruned bytes", () => {
968
+ let expected = 0;
969
+ for (const record of records) {
970
+ expected += record.netNewBytes;
971
+ if (record.turn === 7) expected -= pruneWindow.bytesFreedExpected;
972
+ expect(record.residentBytes).toBe(expected);
973
+ }
974
+ });
975
+ });
976
+
977
+ describe("memory-v3 carry integration — spotlight contract", () => {
978
+ test("spotlight is present every turn, at the user tail, bounded by n × (window + 1)", () => {
979
+ for (const record of records) {
980
+ expect(record.spotlightText.startsWith("<memory_spotlight>\n")).toBe(
981
+ true,
982
+ );
983
+ expect(record.spotlightText.endsWith("\n</memory_spotlight>")).toBe(true);
984
+ expect(record.spotlightPlacement).toBe("append-user-tail");
985
+ expect(record.spotlightEntries).toBeGreaterThanOrEqual(1);
986
+ expect(record.spotlightEntries).toBeLessThanOrEqual(
987
+ SPOTLIGHT_N * (SPOTLIGHT_WINDOW_TURNS + 1),
988
+ );
989
+ }
990
+ });
991
+
992
+ test("a finder hit on a stable-prefix (hot) page spotlights its matched section", () => {
993
+ expect(records[1]!.spotlightText).toContain(
994
+ "## memory/concepts/hot-two.md § ",
995
+ );
996
+ });
997
+
998
+ test("spotlight entries age out of the window instead of accumulating", () => {
999
+ // Turn 6's window covers turns 4–6 only: turn 1–3 entries (e.g. page-a)
1000
+ // are gone.
1001
+ expect(records[5]!.spotlightText).not.toContain("page-a.md");
1002
+ });
1003
+
1004
+ test("the spotlight never reaches the persistent layer (blocks, metadata, history)", () => {
1005
+ for (const record of records) {
1006
+ expect(record.blockText).not.toContain("<memory_spotlight>");
1007
+ // The persistent-layer snapshot is spotlight-stripped by construction;
1008
+ // the raw live history must carry exactly ONE spotlight block (the
1009
+ // current turn's).
1010
+ }
1011
+ const metadataRows = testSqlite
1012
+ .query(
1013
+ /*sql*/ `
1014
+ SELECT metadata FROM messages
1015
+ WHERE conversation_id = ? AND metadata IS NOT NULL
1016
+ `,
1017
+ )
1018
+ .all(CONV) as Array<{ metadata: string }>;
1019
+ expect(metadataRows.length).toBeGreaterThan(0);
1020
+ for (const row of metadataRows) {
1021
+ expect(row.metadata).not.toContain("memory_spotlight");
1022
+ }
1023
+ const liveSpotlights = histories
1024
+ .get(CONV)!
1025
+ .flatMap((m) => m.content)
1026
+ .filter(
1027
+ (b): b is { type: "text"; text: string } =>
1028
+ b.type === "text" && b.text.startsWith("<memory_spotlight>\n"),
1029
+ );
1030
+ expect(liveSpotlights).toHaveLength(1);
1031
+ });
1032
+ });
1033
+
1034
+ describe("memory-v3 carry integration — prune contract", () => {
1035
+ test("the valve trips at turn 7: resident drops to target, core/hot survive", () => {
1036
+ // Nothing pruned through turn 6.
1037
+ for (let i = 0; i < 6; i++) {
1038
+ expect(records[i]!.prunedSlugs.size).toBe(0);
1039
+ }
1040
+ // Turn 7: exactly the two least-recently-selected non-exempt cards.
1041
+ expect(records[6]!.prunedSlugs).toEqual(new Set(["page-a", "page-b"]));
1042
+ expect(records[6]!.residentBytes).toBe(pruneWindow.target);
1043
+ expect(records[6]!.residentBytes).toBeLessThanOrEqual(pruneWindow.max);
1044
+ // Core and hot lane members were exempt and remain active.
1045
+ const activeAfter7 = getActiveSlugs(CONV);
1046
+ for (const slug of [...CORE_SLUGS, ...HOT_SLUGS.slice(0, 2)]) {
1047
+ expect(activeAfter7.has(slug)).toBe(true);
1048
+ }
1049
+ });
1050
+
1051
+ test("a pruned slug re-selected at turn 9 re-injects as a fresh card", () => {
1052
+ const turn9 = records[8]!;
1053
+ expect(turn9.netNewSlugs).toEqual(["page-a"]);
1054
+ expect(turn9.blockText).toBe(
1055
+ wrapMemoryBlock(renderCardsBlockInner([card("page-a")])),
1056
+ );
1057
+ // page-a is active again; page-b stays pruned.
1058
+ expect(turn9.prunedSlugs).toEqual(new Set(["page-b"]));
1059
+ expect(getActiveSlugs(CONV).has("page-a")).toBe(true);
1060
+ });
1061
+ });
1062
+
1063
+ describe("memory-v3 carry integration — restart contract", () => {
1064
+ test("rehydrating from the DB reproduces the live persistent layer byte-identically", () => {
1065
+ expect(restartRehydratedJson).toBe(restartLiveJson);
1066
+ });
1067
+
1068
+ test("pruned sections are absent from the rehydrated history (metadata stays intact)", () => {
1069
+ const rehydrated = JSON.parse(restartRehydratedJson) as Message[];
1070
+ const allText = rehydrated
1071
+ .flatMap((m) => m.content)
1072
+ .filter((b): b is { type: "text"; text: string } => b.type === "text")
1073
+ .map((b) => b.text)
1074
+ .join("\n");
1075
+ expect(allText).not.toContain("# memory/concepts/page-a.md");
1076
+ expect(allText).not.toContain("# memory/concepts/page-b.md");
1077
+ // Resident cards survive the round trip…
1078
+ expect(allText).toContain(card("page-c"));
1079
+ // …and the persisted metadata still carries the pruned cards (the filter
1080
+ // is rehydration-time, never a metadata rewrite).
1081
+ const metadata = testSqlite
1082
+ .query(
1083
+ /*sql*/ `
1084
+ SELECT metadata FROM messages
1085
+ WHERE conversation_id = ? AND id = ?
1086
+ `,
1087
+ )
1088
+ .get(CONV, `${CONV}-m1-user`) as { metadata: string };
1089
+ expect(metadata.metadata).toContain("# memory/concepts/page-a.md");
1090
+ });
1091
+ });
1092
+
1093
+ describe("memory-v3 carry integration — fork contract", () => {
1094
+ test("a fork inherits the dedup record and renders no duplicate cards for inherited slugs", () => {
1095
+ // page-g was injected on the parent before the fork; hot-three never was.
1096
+ expect(forkRecord.netNewSlugs).toEqual(["hot-three"]);
1097
+ expect(forkRecord.blockText).toBe(
1098
+ wrapMemoryBlock(renderCardsBlockInner([card("hot-three")])),
1099
+ );
1100
+ // Inherited active and pruned state both copied (full-fork semantics).
1101
+ const forkActive = getActiveSlugs(FORK_CONV);
1102
+ expect(forkActive.has("page-g")).toBe(true);
1103
+ expect(forkActive.has("core-alpha")).toBe(true);
1104
+ expect(getPrunedSlugs(FORK_CONV)).toEqual(new Set(["page-a", "page-b"]));
1105
+ // The fork's rehydrated history carries the inherited page-g card exactly
1106
+ // once (from the copied metadata), not a re-render.
1107
+ const forkText = histories
1108
+ .get(FORK_CONV)!
1109
+ .flatMap((m) => m.content)
1110
+ .filter((b): b is { type: "text"; text: string } => b.type === "text")
1111
+ .map((b) => b.text)
1112
+ .join("\n");
1113
+ expect(forkText.split(card("page-g")).length - 1).toBe(1);
1114
+ });
1115
+ });
1116
+
1117
+ describe("memory-v3 carry integration — footprint gate", () => {
1118
+ test("steady-state per-turn fresh cost is net-new + spotlight, not the working set", () => {
1119
+ // The all-repeat turns pay ZERO persistent bytes while the resident
1120
+ // working set stays in the thousands — the cache win the rework ships.
1121
+ const turn3 = records[2]!;
1122
+ const turn10 = records[9]!;
1123
+ expect(turn3.netNewBytes).toBe(0);
1124
+ expect(turn10.netNewBytes).toBe(0);
1125
+ expect(turn10.spotlightBytes).toBeGreaterThan(0);
1126
+ // The resident working set holds the whole accumulated card footprint
1127
+ // (15+ active pages here) while the turn pays only the spotlight.
1128
+ expect(getActiveSlugs(CONV).size).toBeGreaterThanOrEqual(15);
1129
+ expect(turn10.netNewBytes + turn10.spotlightBytes).toBeLessThan(
1130
+ turn10.residentBytes,
1131
+ );
1132
+
1133
+ // The measured footprint table — the PR-body cutover-gate artifact.
1134
+ // Fresh (uncached) per-turn cost = net-new + spotlight; resident = the
1135
+ // accumulated frozen-card footprint riding the provider prefix cache.
1136
+ const lines = [
1137
+ "| turn | net-new cards | net-new bytes | spotlight bytes | fresh bytes (net-new + spotlight) | resident bytes | note |",
1138
+ "|---|---|---|---|---|---|---|",
1139
+ ...records.map((r) => {
1140
+ const note =
1141
+ r.turn === 3
1142
+ ? "all-repeat turn"
1143
+ : r.turn === 7
1144
+ ? `prune valve fired (−${pruneWindow.bytesFreedExpected}B)`
1145
+ : r.turn === 9
1146
+ ? "pruned slug re-injected"
1147
+ : r.turn === 10
1148
+ ? "all-repeat (steady state)"
1149
+ : "";
1150
+ return `| ${r.turn} | ${r.netNewSlugs.length} | ${r.netNewBytes} | ${r.spotlightBytes} | ${r.netNewBytes + r.spotlightBytes} | ${r.residentBytes} | ${note} |`;
1151
+ }),
1152
+ ];
1153
+ console.log(
1154
+ `\nmemory-v3 carry footprint (measured):\n${lines.join("\n")}\n`,
1155
+ );
1156
+ });
1157
+ });