@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,758 @@
1
+ /**
2
+ * Tests for `prune.ts` — the memory-v3 resident-footprint prune valve:
3
+ * - `filterPrunedCardSections`: card-boundary parsing, byte-identical
4
+ * remainders, all-pruned → `""`, no-op → same reference, capability
5
+ * chunks (`# Skill:` / `# CLI command:` headers) terminating a card and
6
+ * surviving its prune;
7
+ * - `planPrune`: no-op below the cap, oldest-first selection-recency
8
+ * ranking down to the target, core/hot exemption, `injected_at` fallback,
9
+ * zero-byte (capability / fork-seed) rows skipped, idempotence below the
10
+ * cap;
11
+ * - `runPruneValve` + the live strip: v3-owned blocks stripped in place,
12
+ * v2-lookalike blocks untouched, all-pruned blocks removed, and the
13
+ * rehydration filter (the same `filterPrunedCardSections` over persisted
14
+ * metadata) converging to the same bytes;
15
+ * - re-injection round-trip: `recordInjected` clears `pruned_at`, after
16
+ * which the filter keeps the slug again;
17
+ * - accounting-drift regression: a slug with recorded bytes but no
18
+ * locatable persisted card section is tombstoned in ONE pass and the
19
+ * valve does not loop-fire afterwards;
20
+ * - `schedulePruneValve` deferred execution via `flushPruneValveForTests`.
21
+ *
22
+ * `mock.module` is process-global and leaks into sibling files in a directory
23
+ * run, so the db-connection / config stubs DELEGATE to the real implementation
24
+ * unless this test is actively running (`pruneMockActive`) — mirrors
25
+ * `ever-injected-store.test.ts`.
26
+ */
27
+
28
+ import { Database } from "bun:sqlite";
29
+ import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
30
+
31
+ import { drizzle } from "drizzle-orm/bun-sqlite";
32
+
33
+ import { wrapMemoryBlock } from "../../../memory/memory-marker.js";
34
+ import { migrateAddMemoryV3Selections } from "../../../memory/migrations/268-add-memory-v3-selections.js";
35
+ import { migrateAddMemoryV3EverInjected } from "../../../memory/migrations/277-add-memory-v3-ever-injected.js";
36
+ import * as schema from "../../../memory/schema.js";
37
+ import type { Message } from "../../../providers/types.js";
38
+
39
+ const realDb = { ...(await import("../../../memory/db-connection.js")) };
40
+ const realConfigLoader = { ...(await import("../../../config/loader.js")) };
41
+
42
+ let pruneMockActive = false;
43
+ let pruneConfig: {
44
+ maxResidentBytes: number;
45
+ targetResidentBytes: number;
46
+ } | null = null;
47
+
48
+ let testSqlite: Database;
49
+ let testDb = makeDb();
50
+ function makeDb() {
51
+ testSqlite = new Database(":memory:");
52
+ const db = drizzle(testSqlite, { schema });
53
+ migrateAddMemoryV3EverInjected(db);
54
+ migrateAddMemoryV3Selections(db);
55
+ // Minimal `messages` shape — `collectPersistedV3Cards` reads only
56
+ // `conversation_id` and `metadata`.
57
+ testSqlite.run(/*sql*/ `
58
+ CREATE TABLE IF NOT EXISTS messages (
59
+ id TEXT PRIMARY KEY,
60
+ conversation_id TEXT NOT NULL,
61
+ role TEXT NOT NULL,
62
+ content TEXT NOT NULL,
63
+ metadata TEXT,
64
+ created_at INTEGER NOT NULL
65
+ )
66
+ `);
67
+ return db;
68
+ }
69
+
70
+ mock.module("../../../memory/db-connection.js", () => ({
71
+ ...realDb,
72
+ getDb: () => (pruneMockActive ? testDb : realDb.getDb()),
73
+ getSqliteFrom: (db: unknown) =>
74
+ pruneMockActive
75
+ ? testSqlite
76
+ : realDb.getSqliteFrom(db as Parameters<typeof realDb.getSqliteFrom>[0]),
77
+ }));
78
+
79
+ mock.module("../../../config/loader.js", () => ({
80
+ ...realConfigLoader,
81
+ getConfig: () =>
82
+ pruneMockActive
83
+ ? { memory: { v3: { prune: pruneConfig ?? undefined } } }
84
+ : realConfigLoader.getConfig(),
85
+ }));
86
+
87
+ const {
88
+ collectPersistedV3Cards,
89
+ filterPrunedCardSections,
90
+ flushPruneValveForTests,
91
+ parseCardSections,
92
+ planPrune,
93
+ runPruneValve,
94
+ schedulePruneValve,
95
+ stripPrunedCardsFromMessages,
96
+ } = await import("./prune.js");
97
+ const { getActiveSlugs, getInjected, getPrunedSlugs, recordInjected } =
98
+ await import("./ever-injected-store.js");
99
+ const { V3_CARDS_INJECTION_HEADER, renderCardsBlockInner } =
100
+ await import("./render-injection.js");
101
+
102
+ // ─── fixtures ────────────────────────────────────────────────────────────────
103
+
104
+ /** A card exactly as `renderCard` shapes it: path header, head, TOC line. */
105
+ function card(slug: string): string {
106
+ return `# memory/concepts/${slug}.md\nlead for ${slug}\n\n[sections: §One · §Two]`;
107
+ }
108
+
109
+ /** A capability chunk exactly as `renderCapabilityContent` shapes it: its own
110
+ * non-concept top-level header plus the capability content. */
111
+ const CAPABILITY_CHUNK = "# Skill: meet-join\nJoin a video meeting on request.";
112
+
113
+ function insertSelection(
114
+ conversationId: string,
115
+ turn: number,
116
+ slug: string,
117
+ createdAt: number,
118
+ ): void {
119
+ testSqlite
120
+ .query(
121
+ /*sql*/ `
122
+ INSERT OR REPLACE INTO memory_v3_selections
123
+ (conversation_id, turn, slug, source, pinned, created_at)
124
+ VALUES (?, ?, ?, 'needle', 0, ?)
125
+ `,
126
+ )
127
+ .run(conversationId, turn, slug, createdAt);
128
+ }
129
+
130
+ function insertUserRowWithV3Block(
131
+ conversationId: string,
132
+ id: string,
133
+ blockInner: string,
134
+ ): void {
135
+ testSqlite
136
+ .query(
137
+ /*sql*/ `
138
+ INSERT INTO messages (id, conversation_id, role, content, metadata, created_at)
139
+ VALUES (?, ?, 'user', '[]', ?, 0)
140
+ `,
141
+ )
142
+ .run(
143
+ id,
144
+ conversationId,
145
+ JSON.stringify({ memoryV3InjectedBlock: blockInner }),
146
+ );
147
+ }
148
+
149
+ beforeEach(() => {
150
+ pruneMockActive = true;
151
+ pruneConfig = null;
152
+ testDb = makeDb();
153
+ });
154
+
155
+ afterAll(async () => {
156
+ await flushPruneValveForTests();
157
+ pruneMockActive = false;
158
+ });
159
+
160
+ // ─── filterPrunedCardSections ────────────────────────────────────────────────
161
+
162
+ describe("parseCardSections / filterPrunedCardSections", () => {
163
+ const inner = renderCardsBlockInner([
164
+ card("page-a"),
165
+ card("page-b"),
166
+ card("page-c"),
167
+ ]);
168
+
169
+ test("parses preamble and per-card sections at the path headers", () => {
170
+ const parsed = parseCardSections(inner);
171
+ expect(parsed.preamble).toBe(V3_CARDS_INJECTION_HEADER);
172
+ expect(parsed.sections.map((s) => s.slug)).toEqual([
173
+ "page-a",
174
+ "page-b",
175
+ "page-c",
176
+ ]);
177
+ expect(parsed.sections[1]!.text).toBe(card("page-b"));
178
+ });
179
+
180
+ test("no pruned slug present → returns the SAME reference (no-op)", () => {
181
+ expect(filterPrunedCardSections(inner, new Set(["page-z"]))).toBe(inner);
182
+ expect(filterPrunedCardSections(inner, new Set())).toBe(inner);
183
+ });
184
+
185
+ test("strips pruned cards, leaving the remainder byte-identical to a fresh render", () => {
186
+ const filtered = filterPrunedCardSections(inner, new Set(["page-b"]));
187
+ expect(filtered).toBe(
188
+ renderCardsBlockInner([card("page-a"), card("page-c")]),
189
+ );
190
+ // A `# Title` line inside a card head is NOT a card boundary.
191
+ const headerInHead = `${V3_CARDS_INJECTION_HEADER}\n\n# memory/concepts/page-a.md\n# A Title Line\nlead\n\n# memory/concepts/page-b.md\nlead b`;
192
+ expect(filterPrunedCardSections(headerInHead, new Set(["page-b"]))).toBe(
193
+ `${V3_CARDS_INJECTION_HEADER}\n\n# memory/concepts/page-a.md\n# A Title Line\nlead`,
194
+ );
195
+ });
196
+
197
+ test("all cards pruned → empty string (caller drops the block)", () => {
198
+ expect(
199
+ filterPrunedCardSections(inner, new Set(["page-a", "page-b", "page-c"])),
200
+ ).toBe("");
201
+ });
202
+
203
+ test("text with no card headers passes through unchanged", () => {
204
+ const plain = "remember: user prefers tea";
205
+ expect(filterPrunedCardSections(plain, new Set(["page-a"]))).toBe(plain);
206
+ });
207
+
208
+ test("a capability chunk terminates the preceding card's section", () => {
209
+ const mixed = renderCardsBlockInner([
210
+ card("page-a"),
211
+ CAPABILITY_CHUNK,
212
+ card("page-b"),
213
+ ]);
214
+ const parsed = parseCardSections(mixed);
215
+ expect(parsed.sections.map((s) => s.slug)).toEqual(["page-a", "page-b"]);
216
+ // page-a's section stops AT the capability header — it must not absorb it.
217
+ expect(parsed.sections[0]!.text).toBe(card("page-a"));
218
+ expect(parsed.pieces.map((p) => p.kind)).toEqual(["card", "other", "card"]);
219
+ expect(parsed.pieces[1]!.text).toBe(CAPABILITY_CHUNK);
220
+ });
221
+
222
+ test("pruning a concept card never swallows a trailing capability chunk", () => {
223
+ const mixed = renderCardsBlockInner([
224
+ card("page-a"),
225
+ CAPABILITY_CHUNK,
226
+ card("page-b"),
227
+ ]);
228
+ expect(filterPrunedCardSections(mixed, new Set(["page-a"]))).toBe(
229
+ renderCardsBlockInner([CAPABILITY_CHUNK, card("page-b")]),
230
+ );
231
+ // Capability chunk at the block END survives the prune of the last card.
232
+ const trailing = renderCardsBlockInner([card("page-a"), CAPABILITY_CHUNK]);
233
+ expect(filterPrunedCardSections(trailing, new Set(["page-a"]))).toBe(
234
+ renderCardsBlockInner([CAPABILITY_CHUNK]),
235
+ );
236
+ });
237
+
238
+ test("all cards pruned keeps the preamble + capability chunks", () => {
239
+ const mixed = renderCardsBlockInner([card("page-a"), CAPABILITY_CHUNK]);
240
+ expect(
241
+ filterPrunedCardSections(mixed, new Set(["page-a", "page-b", "page-c"])),
242
+ ).toBe(renderCardsBlockInner([CAPABILITY_CHUNK]));
243
+ });
244
+ });
245
+
246
+ // ─── planPrune ───────────────────────────────────────────────────────────────
247
+
248
+ describe("planPrune", () => {
249
+ const deps = {
250
+ maxResidentBytes: 300,
251
+ targetResidentBytes: 200,
252
+ exemptSlugs: new Set<string>(),
253
+ };
254
+
255
+ test("no-op below (or at) the cap", () => {
256
+ recordInjected("conv-1", [{ slug: "page-a", bytes: 300 }], 1_000);
257
+ expect(planPrune(deps, "conv-1")).toBeNull();
258
+ });
259
+
260
+ test("over the cap: prunes oldest-first by last selection recency down to the target", () => {
261
+ recordInjected(
262
+ "conv-1",
263
+ [
264
+ { slug: "page-a", bytes: 100 },
265
+ { slug: "page-b", bytes: 100 },
266
+ { slug: "page-c", bytes: 100 },
267
+ { slug: "page-d", bytes: 100 },
268
+ ],
269
+ 1_000,
270
+ );
271
+ insertSelection("conv-1", 0, "page-a", 1_000);
272
+ insertSelection("conv-1", 0, "page-b", 2_000);
273
+ insertSelection("conv-1", 0, "page-c", 3_000);
274
+ insertSelection("conv-1", 0, "page-d", 4_000);
275
+
276
+ const plan = planPrune(deps, "conv-1");
277
+ expect(plan).toEqual({ slugs: ["page-a", "page-b"], bytesFreed: 200 });
278
+ });
279
+
280
+ test("re-selection recency outranks injection order", () => {
281
+ recordInjected(
282
+ "conv-1",
283
+ [
284
+ { slug: "page-old", bytes: 200 },
285
+ { slug: "page-new", bytes: 200 },
286
+ ],
287
+ 1_000,
288
+ );
289
+ // page-old was injected first but re-selected most recently; page-new was
290
+ // selected only at injection time.
291
+ insertSelection("conv-1", 0, "page-old", 1_000);
292
+ insertSelection("conv-1", 1, "page-new", 2_000);
293
+ insertSelection("conv-1", 5, "page-old", 9_000);
294
+
295
+ const plan = planPrune(deps, "conv-1");
296
+ expect(plan!.slugs).toEqual(["page-new"]);
297
+ });
298
+
299
+ test("never-selected slugs fall back to injected_at for recency", () => {
300
+ recordInjected("conv-1", [{ slug: "page-a", bytes: 200 }], 5_000);
301
+ recordInjected("conv-1", [{ slug: "page-b", bytes: 200 }], 1_000);
302
+
303
+ const plan = planPrune(deps, "conv-1");
304
+ expect(plan!.slugs).toEqual(["page-b"]);
305
+ });
306
+
307
+ test("core/hot exempt slugs are never pruned, even when oldest", () => {
308
+ recordInjected(
309
+ "conv-1",
310
+ [
311
+ { slug: "core-page", bytes: 150 },
312
+ { slug: "page-b", bytes: 150 },
313
+ { slug: "page-c", bytes: 150 },
314
+ ],
315
+ 1_000,
316
+ );
317
+ insertSelection("conv-1", 0, "core-page", 1_000);
318
+ insertSelection("conv-1", 0, "page-b", 2_000);
319
+ insertSelection("conv-1", 0, "page-c", 3_000);
320
+
321
+ // Resident 450 > max 300: reaching the 200 target needs both non-exempt
322
+ // pages — the exempt core page is skipped over even though it is oldest.
323
+ const plan = planPrune(
324
+ { ...deps, exemptSlugs: new Set(["core-page"]) },
325
+ "conv-1",
326
+ );
327
+ expect(plan!.slugs).toEqual(["page-b", "page-c"]);
328
+ });
329
+
330
+ test("zero-byte rows (capability slugs, fork seeds) are skipped (pruning them frees nothing)", () => {
331
+ recordInjected(
332
+ "conv-1",
333
+ [
334
+ { slug: "seeded", bytes: 0 },
335
+ { slug: "skills/meet-join", bytes: 0 },
336
+ { slug: "page-b", bytes: 400 },
337
+ ],
338
+ 1_000,
339
+ );
340
+
341
+ const plan = planPrune(deps, "conv-1");
342
+ expect(plan!.slugs).toEqual(["page-b"]);
343
+ });
344
+
345
+ test("returns null when only exempt/zero-byte candidates remain over the cap", () => {
346
+ recordInjected("conv-1", [{ slug: "core-page", bytes: 400 }], 1_000);
347
+ expect(
348
+ planPrune({ ...deps, exemptSlugs: new Set(["core-page"]) }, "conv-1"),
349
+ ).toBeNull();
350
+ });
351
+ });
352
+
353
+ // ─── live strip & v3-block identification ────────────────────────────────────
354
+
355
+ describe("stripPrunedCardsFromMessages", () => {
356
+ const innerAB = renderCardsBlockInner([card("page-a"), card("page-b")]);
357
+ const knownSections = new Set([card("page-a"), card("page-b")]);
358
+
359
+ function userMessage(...texts: string[]): Message {
360
+ return {
361
+ role: "user",
362
+ content: texts.map((text) => ({ type: "text" as const, text })),
363
+ };
364
+ }
365
+
366
+ test("strips pruned cards from v3-owned blocks in place", () => {
367
+ const message = userMessage(wrapMemoryBlock(innerAB), "hello");
368
+ const messages = [message];
369
+
370
+ const stripped = stripPrunedCardsFromMessages(
371
+ messages,
372
+ new Set(["page-a"]),
373
+ knownSections,
374
+ );
375
+
376
+ expect(stripped).toBe(1);
377
+ expect(message.content).toEqual([
378
+ {
379
+ type: "text",
380
+ text: wrapMemoryBlock(renderCardsBlockInner([card("page-b")])),
381
+ },
382
+ { type: "text", text: "hello" },
383
+ ]);
384
+ });
385
+
386
+ test("removes a block whose cards are ALL pruned (matching rehydration's skip)", () => {
387
+ const message = userMessage(wrapMemoryBlock(innerAB), "hello");
388
+
389
+ stripPrunedCardsFromMessages(
390
+ [message],
391
+ new Set(["page-a", "page-b"]),
392
+ knownSections,
393
+ );
394
+
395
+ expect(message.content).toEqual([{ type: "text", text: "hello" }]);
396
+ });
397
+
398
+ test("leaves v2-lookalike blocks untouched even when they name a pruned slug", () => {
399
+ // Same wrapper + header convention, but the section body is a v2 SUMMARY,
400
+ // not a card — so it fails the known-sections ownership test.
401
+ const v2Inner = `${V3_CARDS_INJECTION_HEADER}\n\n# memory/concepts/page-a.md\nv2 summary of page a`;
402
+ const message = userMessage(wrapMemoryBlock(v2Inner));
403
+
404
+ const stripped = stripPrunedCardsFromMessages(
405
+ [message],
406
+ new Set(["page-a"]),
407
+ knownSections,
408
+ );
409
+
410
+ expect(stripped).toBe(0);
411
+ expect(message.content).toEqual([
412
+ { type: "text", text: wrapMemoryBlock(v2Inner) },
413
+ ]);
414
+ });
415
+
416
+ test("stripping a card from a capability-bearing v3 block keeps the capability chunk", () => {
417
+ const mixedInner = renderCardsBlockInner([
418
+ card("page-a"),
419
+ CAPABILITY_CHUNK,
420
+ card("page-b"),
421
+ ]);
422
+ const message = userMessage(wrapMemoryBlock(mixedInner), "hello");
423
+
424
+ // Ownership is judged on the card sections only — the capability chunk
425
+ // (a non-card piece on both the persisted and live side) doesn't break it.
426
+ const stripped = stripPrunedCardsFromMessages(
427
+ [message],
428
+ new Set(["page-a"]),
429
+ knownSections,
430
+ );
431
+
432
+ expect(stripped).toBe(1);
433
+ expect(message.content[0]).toEqual({
434
+ type: "text",
435
+ text: wrapMemoryBlock(
436
+ renderCardsBlockInner([CAPABILITY_CHUNK, card("page-b")]),
437
+ ),
438
+ });
439
+ });
440
+
441
+ test("ignores assistant messages, non-memory blocks, and unpruned v3 blocks", () => {
442
+ const assistant: Message = {
443
+ role: "assistant",
444
+ content: [{ type: "text", text: wrapMemoryBlock(innerAB) }],
445
+ };
446
+ const untouched = userMessage(wrapMemoryBlock(innerAB), "tail");
447
+ const before = untouched.content;
448
+
449
+ const stripped = stripPrunedCardsFromMessages(
450
+ [assistant, untouched],
451
+ new Set(["page-z"]),
452
+ knownSections,
453
+ );
454
+
455
+ expect(stripped).toBe(0);
456
+ // No-op leaves the original content array reference in place.
457
+ expect(untouched.content).toBe(before);
458
+ expect(assistant.content[0]).toEqual({
459
+ type: "text",
460
+ text: wrapMemoryBlock(innerAB),
461
+ });
462
+ });
463
+ });
464
+
465
+ describe("collectPersistedV3Cards", () => {
466
+ test("collects card sections from persisted v3 metadata, skipping malformed rows", () => {
467
+ insertUserRowWithV3Block(
468
+ "conv-1",
469
+ "m1",
470
+ renderCardsBlockInner([card("page-a")]),
471
+ );
472
+ insertUserRowWithV3Block(
473
+ "conv-1",
474
+ "m2",
475
+ renderCardsBlockInner([card("page-b")]),
476
+ );
477
+ testSqlite
478
+ .query(
479
+ /*sql*/ `
480
+ INSERT INTO messages (id, conversation_id, role, content, metadata, created_at)
481
+ VALUES ('m3', 'conv-1', 'user', '[]', 'not json memoryV3InjectedBlock', 0)
482
+ `,
483
+ )
484
+ .run();
485
+
486
+ expect(collectPersistedV3Cards("conv-1")).toEqual(
487
+ new Set([card("page-a"), card("page-b")]),
488
+ );
489
+ expect(collectPersistedV3Cards("conv-other").size).toBe(0);
490
+ });
491
+
492
+ test("capability chunks contribute no section (they are non-card chunks)", () => {
493
+ insertUserRowWithV3Block(
494
+ "conv-1",
495
+ "m1",
496
+ renderCardsBlockInner([card("page-a"), CAPABILITY_CHUNK]),
497
+ );
498
+
499
+ expect(collectPersistedV3Cards("conv-1")).toEqual(
500
+ new Set([card("page-a")]),
501
+ );
502
+ });
503
+ });
504
+
505
+ // ─── runPruneValve (end-to-end against the temp DB) ──────────────────────────
506
+
507
+ describe("runPruneValve", () => {
508
+ test("below the cap: no-op, nothing marked pruned (idempotent)", async () => {
509
+ pruneConfig = { maxResidentBytes: 1_000, targetResidentBytes: 500 };
510
+ recordInjected("conv-1", [{ slug: "page-a", bytes: 100 }], 1_000);
511
+
512
+ expect(
513
+ await runPruneValve("conv-1", { exemptSlugs: new Set() }),
514
+ ).toBeNull();
515
+ expect(
516
+ await runPruneValve("conv-1", { exemptSlugs: new Set() }),
517
+ ).toBeNull();
518
+ expect(getPrunedSlugs("conv-1").size).toBe(0);
519
+ });
520
+
521
+ test("missing prune config: bails before touching the store", async () => {
522
+ pruneConfig = null;
523
+ recordInjected("conv-1", [{ slug: "page-a", bytes: 100 }], 1_000);
524
+ expect(
525
+ await runPruneValve("conv-1", { exemptSlugs: new Set() }),
526
+ ).toBeNull();
527
+ });
528
+
529
+ test("zero-byte capability rows never trigger the valve (the injector's bytes:0 contract)", async () => {
530
+ // Capability content can never be located/stripped by slug, so the
531
+ // injector records capability slugs at zero bytes — they contribute
532
+ // nothing to the resident measure and are never candidates.
533
+ insertUserRowWithV3Block(
534
+ "conv-1",
535
+ "m1",
536
+ renderCardsBlockInner([card("page-a"), CAPABILITY_CHUNK]),
537
+ );
538
+ recordInjected(
539
+ "conv-1",
540
+ [
541
+ { slug: "page-a", bytes: 100 },
542
+ { slug: "skills/meet-join", bytes: 0 },
543
+ ],
544
+ 1_000,
545
+ );
546
+
547
+ pruneConfig = { maxResidentBytes: 300, targetResidentBytes: 200 };
548
+ expect(
549
+ await runPruneValve("conv-1", { exemptSlugs: new Set() }),
550
+ ).toBeNull();
551
+ expect(getPrunedSlugs("conv-1").size).toBe(0);
552
+ });
553
+
554
+ test("accounting drift (bytes recorded, no locatable section) is tombstoned in ONE pass — no loop-fire", async () => {
555
+ // Regression: a slug whose recorded bytes have no locatable persisted
556
+ // card section (e.g. its metadata row was lost) pushes the store total
557
+ // over the cap. The valve tombstones it like any candidate — the strip
558
+ // finds nothing to remove, but the tombstone removes its bytes from the
559
+ // resident accounting, so the very next pass is a no-op rather than the
560
+ // valve loop-firing against bytes it cannot free.
561
+ const inner = renderCardsBlockInner([card("page-a")]);
562
+ insertUserRowWithV3Block("conv-1", "m1", inner);
563
+ recordInjected(
564
+ "conv-1",
565
+ [
566
+ { slug: "page-drifted", bytes: 500 }, // no persisted section anywhere
567
+ { slug: "page-a", bytes: 100 },
568
+ ],
569
+ 1_000,
570
+ );
571
+ insertSelection("conv-1", 0, "page-drifted", 1_000);
572
+ insertSelection("conv-1", 0, "page-a", 2_000);
573
+
574
+ const liveMessages: Message[] = [
575
+ {
576
+ role: "user",
577
+ content: [
578
+ { type: "text", text: wrapMemoryBlock(inner) },
579
+ { type: "text", text: "turn 1" },
580
+ ],
581
+ },
582
+ ];
583
+
584
+ // Resident 600 > max 300; tombstoning the drifted slug's 500 bytes
585
+ // reaches the 200 target in one pass without touching page-a.
586
+ pruneConfig = { maxResidentBytes: 300, targetResidentBytes: 200 };
587
+ const plan = await runPruneValve("conv-1", {
588
+ exemptSlugs: new Set(),
589
+ liveMessages: () => liveMessages,
590
+ now: 9_000,
591
+ });
592
+ expect(plan).toEqual({ slugs: ["page-drifted"], bytesFreed: 500 });
593
+ expect(getActiveSlugs("conv-1")).toEqual(new Set(["page-a"]));
594
+
595
+ // The live history is untouched — the drifted slug had no card section
596
+ // to strip, and page-a was not pruned.
597
+ expect(liveMessages[0]!.content).toEqual([
598
+ { type: "text", text: wrapMemoryBlock(inner) },
599
+ { type: "text", text: "turn 1" },
600
+ ]);
601
+
602
+ // One pass self-heals the accounting: the next run is a no-op (no
603
+ // loop-fire), with nothing further tombstoned.
604
+ expect(
605
+ await runPruneValve("conv-1", {
606
+ exemptSlugs: new Set(),
607
+ liveMessages: () => liveMessages,
608
+ }),
609
+ ).toBeNull();
610
+ expect(getPrunedSlugs("conv-1")).toEqual(new Set(["page-drifted"]));
611
+ });
612
+
613
+ test("over the cap: marks pruned, strips the live history, and converges with rehydration", async () => {
614
+ const innerTurn1 = renderCardsBlockInner([card("page-a"), card("page-b")]);
615
+ const innerTurn2 = renderCardsBlockInner([card("page-c")]);
616
+ insertUserRowWithV3Block("conv-1", "m1", innerTurn1);
617
+ insertUserRowWithV3Block("conv-1", "m2", innerTurn2);
618
+
619
+ recordInjected(
620
+ "conv-1",
621
+ [
622
+ { slug: "page-a", bytes: 100 },
623
+ { slug: "page-b", bytes: 100 },
624
+ { slug: "page-c", bytes: 100 },
625
+ ],
626
+ 1_000,
627
+ );
628
+ insertSelection("conv-1", 0, "page-a", 1_000);
629
+ insertSelection("conv-1", 0, "page-b", 2_000);
630
+ insertSelection("conv-1", 1, "page-c", 3_000);
631
+
632
+ // Live history as rehydration would build it, plus a v2-lookalike block
633
+ // that must survive untouched.
634
+ const v2Inner = `${V3_CARDS_INJECTION_HEADER}\n\n# memory/concepts/page-a.md\nv2 summary of page a`;
635
+ const liveMessages: Message[] = [
636
+ {
637
+ role: "user",
638
+ content: [
639
+ { type: "text", text: wrapMemoryBlock(v2Inner) },
640
+ { type: "text", text: wrapMemoryBlock(innerTurn1) },
641
+ { type: "text", text: "turn 1" },
642
+ ],
643
+ },
644
+ { role: "assistant", content: [{ type: "text", text: "reply" }] },
645
+ {
646
+ role: "user",
647
+ content: [
648
+ { type: "text", text: wrapMemoryBlock(innerTurn2) },
649
+ { type: "text", text: "turn 2" },
650
+ ],
651
+ },
652
+ ];
653
+
654
+ pruneConfig = { maxResidentBytes: 250, targetResidentBytes: 100 };
655
+ const plan = await runPruneValve("conv-1", {
656
+ exemptSlugs: new Set(),
657
+ liveMessages: () => liveMessages,
658
+ now: 9_000,
659
+ });
660
+
661
+ expect(plan).toEqual({ slugs: ["page-a", "page-b"], bytesFreed: 200 });
662
+ expect(getActiveSlugs("conv-1")).toEqual(new Set(["page-c"]));
663
+ expect(getInjected("conv-1").get("page-a")!.prunedAt).toBe(9_000);
664
+
665
+ // Turn-1's v3 block lost BOTH pruned cards → removed outright; the
666
+ // v2-lookalike and turn-2's block are byte-identical.
667
+ expect(liveMessages[0]!.content).toEqual([
668
+ { type: "text", text: wrapMemoryBlock(v2Inner) },
669
+ { type: "text", text: "turn 1" },
670
+ ]);
671
+ expect(liveMessages[2]!.content).toEqual([
672
+ { type: "text", text: wrapMemoryBlock(innerTurn2) },
673
+ { type: "text", text: "turn 2" },
674
+ ]);
675
+
676
+ // Rehydration converges: the same filter over the persisted metadata
677
+ // produces exactly what the live strip left in place.
678
+ const pruned = getPrunedSlugs("conv-1");
679
+ expect(filterPrunedCardSections(innerTurn1, pruned)).toBe("");
680
+ expect(filterPrunedCardSections(innerTurn2, pruned)).toBe(innerTurn2);
681
+
682
+ // Idempotent: resident is at 100 ≤ target → next pass is a no-op.
683
+ expect(
684
+ await runPruneValve("conv-1", { exemptSlugs: new Set() }),
685
+ ).toBeNull();
686
+ });
687
+
688
+ test("a pruned page later re-selected re-injects and is kept by the filter again", async () => {
689
+ const inner = renderCardsBlockInner([card("page-a")]);
690
+ insertUserRowWithV3Block("conv-1", "m1", inner);
691
+ recordInjected("conv-1", [{ slug: "page-a", bytes: 300 }], 1_000);
692
+
693
+ pruneConfig = { maxResidentBytes: 200, targetResidentBytes: 100 };
694
+ const plan = await runPruneValve("conv-1", {
695
+ exemptSlugs: new Set(),
696
+ liveMessages: () => null,
697
+ now: 2_000,
698
+ });
699
+ expect(plan!.slugs).toEqual(["page-a"]);
700
+ expect(filterPrunedCardSections(inner, getPrunedSlugs("conv-1"))).toBe("");
701
+
702
+ // Re-selection re-injects (PR 4 contract: recordInjected clears
703
+ // pruned_at) — the slug is active again and the filter keeps its card.
704
+ recordInjected("conv-1", [{ slug: "page-a", bytes: 50 }], 3_000);
705
+ expect(getActiveSlugs("conv-1")).toEqual(new Set(["page-a"]));
706
+ expect(filterPrunedCardSections(inner, getPrunedSlugs("conv-1"))).toBe(
707
+ inner,
708
+ );
709
+ });
710
+ });
711
+
712
+ describe("schedulePruneValve", () => {
713
+ test("defers the valve run; flush awaits completion", async () => {
714
+ const inner = renderCardsBlockInner([card("page-a"), card("page-b")]);
715
+ insertUserRowWithV3Block("conv-1", "m1", inner);
716
+ recordInjected(
717
+ "conv-1",
718
+ [
719
+ { slug: "page-a", bytes: 200 },
720
+ { slug: "page-b", bytes: 200 },
721
+ ],
722
+ 1_000,
723
+ );
724
+ insertSelection("conv-1", 0, "page-a", 1_000);
725
+ insertSelection("conv-1", 0, "page-b", 2_000);
726
+
727
+ pruneConfig = { maxResidentBytes: 300, targetResidentBytes: 200 };
728
+ schedulePruneValve("conv-1", {
729
+ exemptSlugs: new Set(),
730
+ liveMessages: () => null,
731
+ });
732
+ // Synchronously after scheduling, nothing has been pruned yet.
733
+ expect(getPrunedSlugs("conv-1").size).toBe(0);
734
+
735
+ await flushPruneValveForTests();
736
+ expect(getPrunedSlugs("conv-1")).toEqual(new Set(["page-a"]));
737
+ });
738
+
739
+ test("valve failures are swallowed (never affect the turn)", async () => {
740
+ pruneConfig = { maxResidentBytes: 0, targetResidentBytes: 0 };
741
+ insertUserRowWithV3Block(
742
+ "conv-1",
743
+ "m1",
744
+ renderCardsBlockInner([card("page-a")]),
745
+ );
746
+ recordInjected("conv-1", [{ slug: "page-a", bytes: 100 }], 1_000);
747
+ schedulePruneValve("conv-1", {
748
+ exemptSlugs: new Set(),
749
+ liveMessages: () => {
750
+ throw new Error("boom");
751
+ },
752
+ });
753
+ await flushPruneValveForTests();
754
+ // The markPruned preceding the failing strip still landed; the error
755
+ // itself did not propagate.
756
+ expect(getPrunedSlugs("conv-1")).toEqual(new Set(["page-a"]));
757
+ });
758
+ });