@vellumai/assistant 0.11.3 → 0.11.4-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 (324) hide show
  1. package/ARCHITECTURE.md +11 -6
  2. package/docs/architecture/memory.md +11 -0
  3. package/docs/architecture/turn-actor.md +70 -0
  4. package/docs/flux-turn-detection-spike.md +243 -0
  5. package/docs/stt-provider-onboarding.md +3 -1
  6. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
  8. package/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +34 -0
  9. package/node_modules/@vellumai/gateway-client/src/index.ts +2 -0
  10. package/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
  11. package/openapi.yaml +140 -38
  12. package/package.json +1 -1
  13. package/scripts/voice-ttft-spike.ts +3 -3
  14. package/src/__tests__/app-compiler.test.ts +38 -3
  15. package/src/__tests__/attachments-store.test.ts +21 -12
  16. package/src/__tests__/byok-default-profile-ensure.test.ts +2 -0
  17. package/src/__tests__/call-setup-flow-name-capture.test.ts +0 -1
  18. package/src/__tests__/call-site-routing-provider.test.ts +1 -1
  19. package/src/__tests__/channel-availability-routes.test.ts +14 -1
  20. package/src/__tests__/channel-capabilities-dedupe.test.ts +214 -0
  21. package/src/__tests__/channel-delivery-store.test.ts +14 -14
  22. package/src/__tests__/config-loader-backfill.test.ts +3 -3
  23. package/src/__tests__/config-schema.test.ts +25 -10
  24. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +8 -11
  25. package/src/__tests__/conversation-agent-loop-overflow.test.ts +8 -11
  26. package/src/__tests__/conversation-agent-loop.test.ts +28 -20
  27. package/src/__tests__/conversation-attention-store.test.ts +63 -0
  28. package/src/__tests__/conversation-delete-schedule-cleanup.test.ts +0 -4
  29. package/src/__tests__/conversation-fork-crud.test.ts +69 -0
  30. package/src/__tests__/conversation-fork-referential.test.ts +67 -0
  31. package/src/__tests__/conversation-fork-retrospective.test.ts +24 -0
  32. package/src/__tests__/conversation-notifiers-provenance.test.ts +59 -0
  33. package/src/__tests__/conversation-queue.test.ts +55 -4
  34. package/src/__tests__/conversation-runtime-assembly.test.ts +134 -102
  35. package/src/__tests__/conversation-runtime-workspace.test.ts +14 -10
  36. package/src/__tests__/credential-prompt-route.test.ts +7 -10
  37. package/src/__tests__/custom-profile-ensure.test.ts +5 -1
  38. package/src/__tests__/discord-access-request-privacy.test.ts +5 -1
  39. package/src/__tests__/discord-requester-notice-privacy.test.ts +3 -3
  40. package/src/__tests__/document-append-idempotency.test.ts +233 -0
  41. package/src/__tests__/edit-propagation.test.ts +0 -7
  42. package/src/__tests__/helpers/mock-actor-context.ts +49 -0
  43. package/src/__tests__/helpers/mock-conversation.ts +13 -1
  44. package/src/__tests__/injector-chain.test.ts +63 -41
  45. package/src/__tests__/injector-disk-pressure.test.ts +11 -23
  46. package/src/__tests__/llm-context-resolution.test.ts +73 -1
  47. package/src/__tests__/llm-schema.test.ts +5 -2
  48. package/src/__tests__/mcp-list-plugin-servers.test.ts +250 -0
  49. package/src/__tests__/memory-retrieval-hook.test.ts +6 -5
  50. package/src/__tests__/messages-read-boundary-guard.test.ts +134 -0
  51. package/src/__tests__/mtime-cache.test.ts +1 -1
  52. package/src/__tests__/non-member-access-request.test.ts +0 -20
  53. package/src/__tests__/outbound-slack-persistence.test.ts +40 -1
  54. package/src/__tests__/plugin-import-boundary-guard.test.ts +5 -0
  55. package/src/__tests__/plugin-secret-pattern-contribution.test.ts +1 -1
  56. package/src/__tests__/post-compaction-reinjection-idempotency.test.ts +14 -7
  57. package/src/__tests__/provider-commit-message-generator.test.ts +20 -0
  58. package/src/__tests__/run-conversation-turn-persistence.test.ts +434 -105
  59. package/src/__tests__/scoped-approval-grants.test.ts +11 -6
  60. package/src/__tests__/secret-ingress-channel.test.ts +0 -1
  61. package/src/__tests__/skills.test.ts +32 -0
  62. package/src/__tests__/slack-edit-ordering-characterization.test.ts +0 -1
  63. package/src/__tests__/subagent-call-site-routing.test.ts +31 -19
  64. package/src/__tests__/subagent-spawn-and-await.test.ts +14 -10
  65. package/src/__tests__/turn-events-store.test.ts +43 -0
  66. package/src/__tests__/user-plugin-loader.test.ts +1 -1
  67. package/src/__tests__/visible-app-context.test.ts +16 -9
  68. package/src/__tests__/worker-entrypoint-guards.test.ts +54 -0
  69. package/src/__tests__/worker-plugin-surface.test.ts +77 -0
  70. package/src/__tests__/workspace-migration-142-consolidate-voice-front-door.test.ts +158 -0
  71. package/src/__tests__/workspace-migration-143-repair-deprecated-codex-model-id.test.ts +133 -0
  72. package/src/__tests__/workspace-migration-144-convert-stranded-subscription-openai-profiles.test.ts +316 -0
  73. package/src/__tests__/workspace-migration-145-collapse-profile-bindings-to-entries.test.ts +325 -0
  74. package/src/acp/__tests__/acp-claude-oauth.test.ts +10 -2
  75. package/src/acp/__tests__/auth-required.test.ts +161 -0
  76. package/src/acp/acp-claude-oauth.ts +19 -2
  77. package/src/acp/agent-process.test.ts +100 -0
  78. package/src/acp/agent-process.ts +29 -26
  79. package/src/acp/auth-required.ts +102 -0
  80. package/src/acp/session-manager.test.ts +119 -0
  81. package/src/acp/session-manager.ts +68 -2
  82. package/src/api/events/acp-auth-required.ts +55 -0
  83. package/src/api/index.ts +7 -0
  84. package/src/apps/app-store.ts +3 -0
  85. package/src/bundler/package-resolver.ts +2 -30
  86. package/src/calls/__tests__/voice-session-bridge.test.ts +173 -1
  87. package/src/calls/__tests__/voice-triage-escalate.test.ts +94 -0
  88. package/src/calls/call-controller.ts +9 -2
  89. package/src/calls/call-setup-flow.ts +0 -1
  90. package/src/calls/media-stream-stt-session.ts +15 -0
  91. package/src/calls/voice-session-bridge.ts +71 -16
  92. package/src/calls/voice-triage-escalate.ts +104 -2
  93. package/src/channels/__tests__/plugin-channel-declarations.test.ts +161 -0
  94. package/src/channels/config.ts +13 -0
  95. package/src/channels/plugin-channel-declarations.ts +108 -0
  96. package/src/channels/types.ts +30 -0
  97. package/src/cli/AGENTS.md +5 -2
  98. package/src/cli/commands/credentials.help.ts +2 -2
  99. package/src/cli/commands/inference-providers.ts +1 -1
  100. package/src/cli/commands/mcp.help.ts +13 -4
  101. package/src/cli/commands/mcp.ts +9 -0
  102. package/src/cli/commands/memory/__tests__/memory-v3.test.ts +128 -5
  103. package/src/cli/commands/memory/index.help.ts +43 -1
  104. package/src/cli/commands/memory/memory-v3.ts +64 -0
  105. package/src/cli/commands/stt.help.ts +27 -2
  106. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +39 -0
  107. package/src/cli/lib/bundled-marketplace.json +13 -0
  108. package/src/cli/lib/upgrade-plugin.ts +42 -0
  109. package/src/config/__tests__/default-profile-catalog.test.ts +34 -2
  110. package/src/config/__tests__/default-provider.test.ts +6 -1
  111. package/src/config/__tests__/profile-materialization.test.ts +75 -19
  112. package/src/config/bundled-skills/acp/SKILL.md +6 -7
  113. package/src/config/bundled-skills/document-editor/SKILL.md +2 -2
  114. package/src/config/bundled-skills/document-editor/TOOLS.json +2 -2
  115. package/src/config/bundled-skills/media-processing/services/preprocess.ts +14 -4
  116. package/src/config/bundled-skills/settings/TOOLS.json +3 -3
  117. package/src/config/bundled-skills/transcribe/tools/transcribe-media.test.ts +22 -1
  118. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +9 -2
  119. package/src/config/call-site-defaults.ts +4 -5
  120. package/src/config/default-profile-catalog.ts +82 -11
  121. package/src/config/default-profile-names.ts +4 -1
  122. package/src/config/default-provider-resolution.ts +4 -0
  123. package/src/config/llm-context-resolution.ts +11 -3
  124. package/src/config/llm-resolver.ts +28 -1
  125. package/src/config/profile-materialization.ts +70 -22
  126. package/src/config/schemas/__tests__/live-voice.test.ts +107 -4
  127. package/src/config/schemas/call-site-catalog.ts +4 -4
  128. package/src/config/schemas/live-voice.ts +57 -23
  129. package/src/config/schemas/llm.ts +59 -32
  130. package/src/config/schemas/mcp.ts +23 -0
  131. package/src/config/schemas/plugin-updates.ts +6 -2
  132. package/src/config/schemas/stt.ts +1 -0
  133. package/src/context/outbound-sanitize.ts +96 -1
  134. package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +82 -0
  135. package/src/daemon/conversation-agent-loop-handlers.ts +15 -10
  136. package/src/daemon/conversation-agent-loop.ts +17 -6
  137. package/src/daemon/conversation-messaging.ts +5 -1
  138. package/src/daemon/conversation-notifiers.ts +9 -1
  139. package/src/daemon/conversation-process.ts +9 -6
  140. package/src/daemon/conversation-runtime-assembly.ts +3 -4
  141. package/src/daemon/conversation-tool-setup.ts +1 -2
  142. package/src/daemon/conversation.ts +48 -0
  143. package/src/daemon/mcp-reload-service.ts +36 -6
  144. package/src/daemon/process-message.ts +13 -3
  145. package/src/daemon/providers-setup.ts +6 -3
  146. package/src/daemon/trust-context-types.ts +29 -0
  147. package/src/daemon/wake-conversation-ops.ts +3 -2
  148. package/src/documents/document-store.ts +138 -5
  149. package/src/hooks/hook-loader.ts +3 -3
  150. package/src/hooks/registry.ts +50 -6
  151. package/src/inbound/__tests__/oauth-callback-url.test.ts +83 -0
  152. package/src/inbound/oauth-callback-url.ts +61 -0
  153. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +1 -104
  154. package/src/live-voice/__tests__/live-voice-events.test.ts +7 -8
  155. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +932 -0
  156. package/src/live-voice/__tests__/live-voice-metrics.test.ts +115 -8
  157. package/src/live-voice/__tests__/live-voice-photo.test.ts +100 -0
  158. package/src/live-voice/__tests__/live-voice-progress.test.ts +60 -194
  159. package/src/live-voice/__tests__/live-voice-stt.test.ts +14 -0
  160. package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +29 -0
  161. package/src/live-voice/__tests__/live-voice-tts-session.test.ts +0 -483
  162. package/src/live-voice/__tests__/live-voice-vad.test.ts +0 -16
  163. package/src/live-voice/__tests__/progress-narration.test.ts +214 -0
  164. package/src/live-voice/live-voice-archive.ts +2 -0
  165. package/src/live-voice/live-voice-metrics.ts +57 -32
  166. package/src/live-voice/live-voice-photo.ts +1 -2
  167. package/src/live-voice/live-voice-session.ts +535 -314
  168. package/src/live-voice/progress-narration.ts +277 -0
  169. package/src/live-voice/protocol.ts +21 -1
  170. package/src/mcp/__tests__/effective-config.test.ts +238 -0
  171. package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +0 -1
  172. package/src/mcp/__tests__/mcp-oauth-client-registration.test.ts +200 -0
  173. package/src/mcp/__tests__/mcp-oauth-provider.test.ts +9 -9
  174. package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +95 -0
  175. package/src/mcp/client.ts +16 -11
  176. package/src/mcp/effective-config.ts +113 -0
  177. package/src/mcp/manager.ts +11 -6
  178. package/src/mcp/mcp-auth-orchestrator.ts +13 -22
  179. package/src/mcp/mcp-oauth-provider.ts +205 -240
  180. package/src/monitoring/__tests__/plugin-auto-update.test.ts +166 -3
  181. package/src/monitoring/plugin-auto-update.ts +128 -24
  182. package/src/notifications/signal.ts +1 -0
  183. package/src/permissions/confirmation-guardian-request.test.ts +15 -11
  184. package/src/permissions/confirmation-guardian-request.ts +2 -2
  185. package/src/permissions/question-guardian-request.test.ts +14 -6
  186. package/src/permissions/question-guardian-request.ts +1 -2
  187. package/src/persistence/attachments-store.ts +8 -1
  188. package/src/persistence/bookmark-crud.ts +3 -7
  189. package/src/persistence/conversation-attention-store.ts +16 -45
  190. package/src/persistence/conversation-crud.ts +33 -4
  191. package/src/persistence/conversation-lineage.ts +9 -0
  192. package/src/persistence/conversation-queries.ts +108 -41
  193. package/src/persistence/delivery-crud.ts +38 -29
  194. package/src/persistence/external-conversation-store.ts +32 -4
  195. package/src/persistence/llm-request-log-store.ts +4 -10
  196. package/src/persistence/llm-usage-store.ts +8 -3
  197. package/src/persistence/message-reads.test.ts +197 -0
  198. package/src/persistence/message-reads.ts +211 -0
  199. package/src/persistence/migrations/366-chatgpt-subscription-row-identity.test.ts +120 -0
  200. package/src/persistence/migrations/366-chatgpt-subscription-row-identity.ts +62 -0
  201. package/src/persistence/real-user-turn-filter.ts +27 -3
  202. package/src/persistence/steps.ts +9 -0
  203. package/src/plugin-api/__tests__/oauth-callback-url-export.test.ts +29 -0
  204. package/src/plugin-api/conversation-turn.ts +168 -5
  205. package/src/plugin-api/index.ts +21 -5
  206. package/src/plugins/__tests__/mcp-servers.test.ts +371 -0
  207. package/src/plugins/defaults/memory/AGENTS.md +4 -0
  208. package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +204 -0
  209. package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +72 -0
  210. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +4 -1
  211. package/src/plugins/defaults/memory/__tests__/memory-retrospective-provider-path.test.ts +4 -1
  212. package/src/plugins/defaults/memory/buffer-format.ts +165 -0
  213. package/src/plugins/defaults/memory/context-search/sources/conversations.ts +6 -0
  214. package/src/plugins/defaults/memory/graph/image-ref-utils.ts +3 -0
  215. package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -30
  216. package/src/plugins/defaults/memory/graph-topology/pending-buffer.test.ts +34 -0
  217. package/src/plugins/defaults/memory/graph-topology/pending-buffer.ts +8 -12
  218. package/src/plugins/defaults/memory/hooks/post-compact.ts +1 -4
  219. package/src/plugins/defaults/memory/indexer.ts +3 -1
  220. package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +19 -7
  221. package/src/plugins/defaults/memory/src/__tests__/memory-v3-gate-stats.test.ts +281 -0
  222. package/src/plugins/defaults/memory/src/memory-v3-routes.ts +207 -0
  223. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +33 -0
  224. package/src/plugins/defaults/memory/substrate/__tests__/static-context.test.ts +199 -2
  225. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +25 -18
  226. package/src/plugins/defaults/memory/substrate/skill-content.ts +8 -1
  227. package/src/plugins/defaults/memory/substrate/static-context.ts +160 -4
  228. package/src/plugins/defaults/memory/substrate/sweep-job.ts +2 -4
  229. package/src/plugins/defaults/memory/v1/graph/extraction.ts +3 -1
  230. package/src/plugins/defaults/memory/v3/prune.ts +2 -0
  231. package/src/plugins/defaults/memory/v3/selection-log-store.ts +2 -0
  232. package/src/plugins/defaults/memory/worker.ts +6 -3
  233. package/src/plugins/external-plugin-loader.ts +47 -0
  234. package/src/plugins/mcp-servers.ts +361 -0
  235. package/src/plugins/mtime-cache.ts +23 -49
  236. package/src/plugins/worker-plugin-surface.ts +33 -0
  237. package/src/providers/__tests__/connection-model-compat.test.ts +1 -1
  238. package/src/providers/__tests__/dispatch-connection-routing.test.ts +214 -2
  239. package/src/providers/__tests__/preflight-resolved-config.test.ts +57 -0
  240. package/src/providers/__tests__/retry-callsite.test.ts +5 -2
  241. package/src/providers/call-site-routing.ts +30 -3
  242. package/src/providers/connection-resolution.ts +194 -11
  243. package/src/providers/inference/auth.ts +6 -0
  244. package/src/providers/inference/connection-availability.ts +24 -2
  245. package/src/providers/inference/connections.ts +2 -0
  246. package/src/providers/model-intents.ts +26 -6
  247. package/src/providers/openai/codex-models.ts +2 -1
  248. package/src/providers/provider-send-message.ts +32 -3
  249. package/src/providers/speech-to-text/__tests__/deepgram-flux-frames.test.ts +433 -0
  250. package/src/providers/speech-to-text/__tests__/deepgram-flux-realtime.test.ts +620 -0
  251. package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +34 -0
  252. package/src/providers/speech-to-text/__tests__/resolve.test.ts +285 -6
  253. package/src/providers/speech-to-text/deepgram-flux-frames.ts +395 -0
  254. package/src/providers/speech-to-text/deepgram-flux-realtime.ts +719 -0
  255. package/src/providers/speech-to-text/provider-catalog.ts +99 -8
  256. package/src/providers/speech-to-text/resolve.ts +25 -2
  257. package/src/routes/worker.ts +17 -5
  258. package/src/runtime/access-request-helper.ts +9 -12
  259. package/src/runtime/agent-wake.ts +3 -3
  260. package/src/runtime/pre-first-message-gate.ts +4 -0
  261. package/src/runtime/routes/__tests__/acp-claude-auth-routes.test.ts +12 -4
  262. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +219 -1
  263. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +52 -0
  264. package/src/runtime/routes/__tests__/default-provider-routes.test.ts +61 -0
  265. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +44 -0
  266. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +102 -1
  267. package/src/runtime/routes/__tests__/plugins-routes.test.ts +44 -0
  268. package/src/runtime/routes/__tests__/stt-routes.test.ts +25 -0
  269. package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +62 -1
  270. package/src/runtime/routes/channel-availability-routes.ts +32 -14
  271. package/src/runtime/routes/channel-route-shared.ts +0 -6
  272. package/src/runtime/routes/chatgpt-subscription-auth-routes.ts +6 -6
  273. package/src/runtime/routes/conversation-list-routes.ts +112 -1
  274. package/src/runtime/routes/conversation-query-routes.ts +40 -27
  275. package/src/runtime/routes/credential-prompt-routes.ts +4 -7
  276. package/src/runtime/routes/default-provider-routes.ts +15 -0
  277. package/src/runtime/routes/inbound-message-handler.ts +17 -41
  278. package/src/runtime/routes/inbound-stages/acl-enforcement.test.ts +0 -1
  279. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +0 -9
  280. package/src/runtime/routes/inbound-stages/admission-policy.ts +1 -17
  281. package/src/runtime/routes/inbound-stages/bootstrap-intercept.test.ts +0 -1
  282. package/src/runtime/routes/inbound-stages/bootstrap-intercept.ts +2 -3
  283. package/src/runtime/routes/inbound-stages/edit-intercept.ts +1 -3
  284. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.test.ts +0 -1
  285. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +3 -4
  286. package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +0 -1
  287. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +11 -20
  288. package/src/runtime/routes/inbound-stages/secret-ingress-check.ts +2 -3
  289. package/src/runtime/routes/inference-profiles-routes.ts +20 -11
  290. package/src/runtime/routes/inference-provider-connection-routes.ts +77 -15
  291. package/src/runtime/routes/log-export-routes.ts +3 -0
  292. package/src/runtime/routes/mcp-auth-routes.ts +148 -57
  293. package/src/runtime/routes/plugins-routes.ts +21 -3
  294. package/src/runtime/routes/stt-routes.ts +31 -25
  295. package/src/runtime/routes/surface-conversation-resolver.ts +3 -0
  296. package/src/runtime/routes/user-route-dispatcher.ts +39 -14
  297. package/src/runtime/routes/user-route-import.ts +108 -0
  298. package/src/schedule/worker.ts +6 -0
  299. package/src/security/oauth2.ts +6 -22
  300. package/src/stt/__tests__/daemon-batch-transcriber.test.ts +22 -0
  301. package/src/stt/__tests__/types.test.ts +94 -0
  302. package/src/stt/daemon-batch-transcriber.ts +10 -0
  303. package/src/stt/stt-stream-session.ts +8 -4
  304. package/src/stt/types.ts +103 -0
  305. package/src/subagent/manager.ts +1 -3
  306. package/src/subagent/types.ts +7 -6
  307. package/src/tools/acp/spawn.test.ts +97 -0
  308. package/src/tools/acp/spawn.ts +32 -0
  309. package/src/tools/document/document-tool.ts +12 -3
  310. package/src/tools/registry.ts +2 -1
  311. package/src/tools/workflows/run-workflow.ts +1 -2
  312. package/src/tts/__tests__/reasoning-tag-filter.test.ts +63 -0
  313. package/src/tts/reasoning-tag-filter.ts +110 -0
  314. package/src/workspace/byok-default-profile-ensure.ts +61 -18
  315. package/src/workspace/custom-profile-ensure.ts +4 -24
  316. package/src/workspace/migrations/142-consolidate-voice-front-door.ts +70 -0
  317. package/src/workspace/migrations/143-repair-deprecated-codex-model-id.ts +134 -0
  318. package/src/workspace/migrations/144-convert-stranded-subscription-openai-profiles.ts +265 -0
  319. package/src/workspace/migrations/145-collapse-profile-bindings-to-entries.ts +328 -0
  320. package/src/workspace/migrations/__tests__/141-stt-english-default-to-multilingual.test.ts +0 -10
  321. package/src/workspace/migrations/registry.ts +8 -0
  322. package/src/workspace/provider-commit-message-generator.ts +7 -5
  323. package/src/live-voice/__tests__/front-decision.test.ts +0 -645
  324. package/src/live-voice/front-decision.ts +0 -476
@@ -5,6 +5,7 @@
5
5
  * - Reads the four files in canonical order and joins them under headings.
6
6
  * - Skips empty / missing files.
7
7
  * - Returns null when every file is empty or missing.
8
+ * - Caps the Buffer section at `consolidation_max_buffer_lines`.
8
9
  */
9
10
 
10
11
  import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
@@ -33,12 +34,19 @@ mock.module("../../../../../util/logger.js", () => ({
33
34
 
34
35
  let configMemoryV2Enabled = true;
35
36
  let configMemoryEnabled = true;
37
+ // Mirrors the `memory.v2.consolidation_max_buffer_lines` schema default; the
38
+ // Buffer-cap tests move it.
39
+ let configMaxBufferLines: number | null = 100;
36
40
 
37
- // static-context reads its gates through the plugin's own config accessor.
41
+ // static-context reads its gates and the substrate tuning through the
42
+ // plugin's own config accessor.
38
43
  mock.module("../../config.js", () => ({
39
44
  getMemoryConfig: () => ({
40
45
  enabled: configMemoryEnabled,
41
- v2: { enabled: configMemoryV2Enabled },
46
+ v2: {
47
+ enabled: configMemoryV2Enabled,
48
+ consolidation_max_buffer_lines: configMaxBufferLines,
49
+ },
42
50
  }),
43
51
  }));
44
52
 
@@ -70,6 +78,7 @@ describe("readMemoryV2StaticContent", () => {
70
78
  mkdirSync(TEST_DIR, { recursive: true });
71
79
  configMemoryV2Enabled = true;
72
80
  configMemoryEnabled = true;
81
+ configMaxBufferLines = 100;
73
82
  });
74
83
 
75
84
  afterEach(() => {
@@ -165,6 +174,194 @@ describe("readMemoryV2StaticContent", () => {
165
174
  });
166
175
  });
167
176
 
177
+ describe("readMemoryV2StaticContent Buffer cap", () => {
178
+ /** `n` timestamped buffer entries, oldest first, in `remember()`'s shape. */
179
+ function bufferEntries(n: number): string {
180
+ return Array.from(
181
+ { length: n },
182
+ (_, i) => `- [Jan 1, 9:00 AM] entry-${i}`,
183
+ ).join("\n");
184
+ }
185
+
186
+ beforeEach(() => {
187
+ mkdirSync(TEST_DIR, { recursive: true });
188
+ configMemoryV2Enabled = true;
189
+ configMemoryEnabled = true;
190
+ configMaxBufferLines = 10;
191
+ });
192
+
193
+ afterEach(() => {
194
+ cleanupMemoryDir();
195
+ });
196
+
197
+ test("passes a buffer at or under the cap through byte-identically", () => {
198
+ const buffer = bufferEntries(10);
199
+ writeMemoryFile("buffer.md", buffer);
200
+
201
+ expect(readMemoryV2StaticContent()).toBe(`## Buffer\n\n${buffer}`);
202
+ });
203
+
204
+ test("keeps the newest entries and drops the oldest when over the cap", () => {
205
+ writeMemoryFile("buffer.md", bufferEntries(30));
206
+
207
+ const text = readMemoryV2StaticContent()!;
208
+ expect(text).toContain(
209
+ "(Older entries trimmed. Read memory/buffer.md for the full backlog.)",
210
+ );
211
+ // The cap counts non-empty lines, matching the scheduler's
212
+ // `countBufferLines`, so exactly the newest 10 entries survive.
213
+ const kept = text
214
+ .split("\n")
215
+ .filter((line) => line.startsWith("- ["))
216
+ .map((line) => line.slice(line.lastIndexOf(" ") + 1));
217
+ expect(kept).toEqual(
218
+ Array.from({ length: 10 }, (_, i) => `entry-${i + 20}`),
219
+ );
220
+ });
221
+
222
+ test("never opens mid-entry when the cap lands inside a multiline fact", () => {
223
+ // The 5-line fact straddles the cap: counting back 10 non-empty lines
224
+ // lands on one of its continuation lines. Injecting from there would show
225
+ // orphan bullets with no timestamp and no opening clause.
226
+ const multiline = [
227
+ "- [Jan 1, 9:00 AM] the straddling fact",
228
+ " - [ ] a checklist item inside the fact",
229
+ " continuation prose",
230
+ " - another bullet",
231
+ " closing line",
232
+ ].join("\n");
233
+ writeMemoryFile(
234
+ "buffer.md",
235
+ `${bufferEntries(20)}\n${multiline}\n${bufferEntries(8)}`,
236
+ );
237
+
238
+ const text = readMemoryV2StaticContent()!;
239
+ const body = text.slice(text.indexOf("buffer.md for the full backlog.)\n"));
240
+ const firstLine = body.split("\n")[1]!;
241
+ expect(firstLine).toMatch(/^- \[Jan 1, 9:00 AM\]/);
242
+ // The partial entry is dropped whole: none of its continuation lines
243
+ // survive on their own.
244
+ expect(text).not.toContain("continuation prose");
245
+ expect(text).not.toContain("the straddling fact");
246
+ });
247
+
248
+ test("keeps the newest entry attributable when its body alone exceeds the cap", () => {
249
+ // The newest entry has no successor to fall back to: counting back 10
250
+ // non-empty lines lands inside its body and there is no later timestamped
251
+ // entry to open on. Dropping it would leave the section with no facts at
252
+ // all, so the entry's opening line is kept and its head elided.
253
+ writeMemoryFile(
254
+ "buffer.md",
255
+ [
256
+ ...Array.from(
257
+ { length: 20 },
258
+ (_, i) => `- [Jan 1, 9:00 AM] entry-${i}`,
259
+ ),
260
+ "- [Jan 2, 9:00 AM] the oversized fact",
261
+ ...Array.from({ length: 14 }, (_, i) => ` body line ${i}`),
262
+ ].join("\n"),
263
+ );
264
+
265
+ const text = readMemoryV2StaticContent()!;
266
+ const body = text
267
+ .slice(text.indexOf("full backlog.)\n") + "full backlog.)\n".length)
268
+ .split("\n")
269
+ .filter((line) => line.trim().length > 0);
270
+
271
+ // Opens on the entry's own timestamped line, never on an orphan body line.
272
+ expect(body[0]).toBe("- [Jan 2, 9:00 AM] the oversized fact");
273
+ expect(body[1]).toBe(
274
+ "(This entry's body was trimmed. Read memory/buffer.md for the rest of it.)",
275
+ );
276
+ // The retained tail is the newest end of the body, not its head.
277
+ expect(body.at(-1)).toBe(" body line 13");
278
+ expect(body).toContain(" body line 4");
279
+ expect(body).not.toContain(" body line 3");
280
+ // Bounded at the cap plus exactly the opening line and the marker, so the
281
+ // oversized entry cannot reintroduce an unbounded injection.
282
+ expect(body).toHaveLength(12);
283
+ // The older entries are still dropped.
284
+ expect(text).not.toContain("entry-19");
285
+ });
286
+
287
+ test("does not claim a trim when the newest entry's body exactly fills the cap", () => {
288
+ // The cut lands on the line right after the opening, so nothing of the
289
+ // entry was elided and the marker would be a lie.
290
+ writeMemoryFile(
291
+ "buffer.md",
292
+ [
293
+ ...Array.from(
294
+ { length: 20 },
295
+ (_, i) => `- [Jan 1, 9:00 AM] entry-${i}`,
296
+ ),
297
+ "- [Jan 2, 9:00 AM] the exact-fit fact",
298
+ ...Array.from({ length: 10 }, (_, i) => ` body line ${i}`),
299
+ ].join("\n"),
300
+ );
301
+
302
+ const text = readMemoryV2StaticContent()!;
303
+ const body = text
304
+ .slice(text.indexOf("full backlog.)\n") + "full backlog.)\n".length)
305
+ .split("\n")
306
+ .filter((line) => line.trim().length > 0);
307
+
308
+ expect(body[0]).toBe("- [Jan 2, 9:00 AM] the exact-fit fact");
309
+ expect(text).not.toContain("This entry's body was trimmed");
310
+ // The entry survives whole: opening line plus its full 10-line body.
311
+ expect(body).toHaveLength(11);
312
+ expect(body.at(-1)).toBe(" body line 9");
313
+ expect(body).toContain(" body line 0");
314
+ });
315
+
316
+ test("keeps a multiline entry intact when it sits fully inside the cap", () => {
317
+ const multiline = [
318
+ "- [Jan 1, 9:00 AM] a fact with a body",
319
+ " second line of the same fact",
320
+ ].join("\n");
321
+ writeMemoryFile("buffer.md", `${bufferEntries(20)}\n${multiline}`);
322
+
323
+ const text = readMemoryV2StaticContent()!;
324
+ expect(text).toContain("a fact with a body");
325
+ expect(text).toContain("second line of the same fact");
326
+ });
327
+
328
+ test("falls back to the line cut for a buffer with no timestamped entries", () => {
329
+ // A hand-written buffer that never went through `remember()` has no entry
330
+ // structure to preserve, so the line-based cut stands rather than dropping
331
+ // everything.
332
+ const handWritten = Array.from(
333
+ { length: 30 },
334
+ (_, i) => `just a line ${i}`,
335
+ ).join("\n");
336
+ writeMemoryFile("buffer.md", handWritten);
337
+
338
+ const text = readMemoryV2StaticContent()!;
339
+ expect(text).toContain("just a line 29");
340
+ expect(text).not.toContain("just a line 0");
341
+ });
342
+
343
+ test("leaves the buffer unbounded when the size trigger is disabled", () => {
344
+ configMaxBufferLines = null;
345
+ const buffer = bufferEntries(30);
346
+ writeMemoryFile("buffer.md", buffer);
347
+
348
+ expect(readMemoryV2StaticContent()).toBe(`## Buffer\n\n${buffer}`);
349
+ });
350
+
351
+ test("caps only the Buffer section, never the curated views", () => {
352
+ const long = Array.from({ length: 30 }, (_, i) => `line-${i}`).join("\n");
353
+ writeMemoryFile("essentials.md", long);
354
+ writeMemoryFile("threads.md", long);
355
+ writeMemoryFile("recent.md", long);
356
+ writeMemoryFile("buffer.md", bufferEntries(30));
357
+
358
+ const lines = readMemoryV2StaticContent()!.split("\n");
359
+ expect(lines.filter((line) => line === "line-0")).toHaveLength(3);
360
+ expect(lines.filter((line) => line === "line-29")).toHaveLength(3);
361
+ expect(lines.some((line) => line.endsWith("entry-0"))).toBe(false);
362
+ });
363
+ });
364
+
168
365
  describe("shouldExposePersonalMemory", () => {
169
366
  test("allows guardian-trusted local conversations", () => {
170
367
  expect(
@@ -91,7 +91,10 @@ import {
91
91
  type MemoryJobType,
92
92
  } from "../../../../persistence/jobs-store.js";
93
93
  import { runBackgroundJob } from "../../../../runtime/background-job-runner.js";
94
- import { formatBufferTimestamp } from "../graph/tool-handlers.js";
94
+ import {
95
+ formatBufferTimestamp,
96
+ matchBufferEntryStart,
97
+ } from "../buffer-format.js";
95
98
  import { getLogger } from "../logging.js";
96
99
  import { getWorkspaceDir } from "../paths.js";
97
100
  import {
@@ -597,32 +600,36 @@ function readBufferContent(bufferPath: string): string {
597
600
 
598
601
  /**
599
602
  * Extract the bracketed timestamp from a `buffer.md` entry line
600
- * (`- [Mon D, h:mm AM/PM] …`, see `formatBufferEntry`). Returned verbatim so
601
- * it can serve directly as a consolidation cutoff — both sides of the agent's
602
- * "timestamp ≥ cutoff" comparison then share the exact `formatBufferTimestamp`
603
- * shape.
603
+ * (`- [Mon D, h:mm AM/PM] …`, see {@link formatRememberEntry}). Returned
604
+ * verbatim so it can serve directly as a consolidation cutoff: both sides of
605
+ * the agent's "timestamp >= cutoff" comparison then share the exact
606
+ * {@link formatBufferTimestamp} shape.
604
607
  *
605
- * The bracket contents must match that shape exactly: a remembered fact's
606
- * continuation lines can themselves start with `- [` (markdown checklists
607
- * `- [ ] …`, wikilink bullets `- [[…]]`), and counting those as entries
608
- * would inflate the per-run budget — or worse, hand the agent a garbage
609
- * cutoff like a blank string. Returns `null` for anything that isn't a real
610
- * timestamped entry start.
608
+ * Recognition is delegated to the shared matcher, so a remembered fact's
609
+ * continuation lines never register as entries. That matters here beyond
610
+ * tidiness: counting a fact's `- [ ] …` checklist lines or an indented
611
+ * entry-shaped body line as entries would inflate the per-run budget, or hand
612
+ * the agent a cutoff drawn from the middle of a fact.
611
613
  */
612
614
  function extractBufferEntryTimestamp(line: string): string | null {
613
- const match = /^\s*-\s*\[([A-Z][a-z]{2} \d{1,2}, \d{1,2}:\d{2} [AP]M)\]/.exec(
614
- line,
615
- );
616
- return match ? match[1] : null;
615
+ return matchBufferEntryStart(line)?.timestamp ?? null;
617
616
  }
618
617
 
619
618
  /**
620
619
  * Count non-empty lines in `memory/buffer.md`. Used by the scheduler to
621
620
  * implement the size-based consolidation trigger. Missing file → 0.
622
621
  *
623
- * Each entry is one line (`- [Mon D, h:mm AM/PM] …\n`), so non-empty-line
624
- * count == entry count for a well-formed buffer; blank lines and trailing
625
- * newlines don't inflate the count.
622
+ * Lines, deliberately, not entries. A multiline `remember()` fact is one
623
+ * entry spread over several lines, so the two counts diverge and each answers
624
+ * a different question. This trigger and the injected-Buffer cap that reuses
625
+ * it (`capBufferSection` in `static-context.ts`) both care about how much
626
+ * context the buffer costs, which scales with lines. The per-run budget
627
+ * `consolidation_max_entries_per_run` cares about how many facts the agent
628
+ * must file, so it counts entry-start lines instead.
629
+ *
630
+ * Do not "fix" this to count entries: a single 200-line fact is a context
631
+ * problem the trigger should fire on, even though it is one entry. Blank
632
+ * lines and trailing newlines don't inflate the count.
626
633
  */
627
634
  export function countBufferLines(bufferPath: string): number {
628
635
  return countNonEmptyLines(readBufferContent(bufferPath));
@@ -22,6 +22,13 @@ export interface SkillCapabilityInput {
22
22
  */
23
23
  export const ALWAYS_CANDIDATE_CARD_CHARS = 900;
24
24
 
25
+ /**
26
+ * Character budget for an ordinary skill's capability statement. A statement
27
+ * that overruns is hard-truncated, dropping its trailing activation hints and
28
+ * its entire avoid-when list from what the selector sees.
29
+ */
30
+ export const DEFAULT_CARD_CHARS = 500;
31
+
25
32
  /**
26
33
  * Render the prose-style capability statement embedded into the unified
27
34
  * `memory_v2_concept_pages` Qdrant collection (under the `skills/<id>` slug
@@ -32,7 +39,7 @@ export const ALWAYS_CANDIDATE_CARD_CHARS = 900;
32
39
  */
33
40
  export function buildSkillContent(
34
41
  input: SkillCapabilityInput,
35
- maxChars = 500,
42
+ maxChars: number = DEFAULT_CARD_CHARS,
36
43
  ): string {
37
44
  const list = maxChars > 500;
38
45
  let content = `The "${input.displayName}" skill (${input.id}) is available. ${input.description}.`;
@@ -19,29 +19,180 @@
19
19
  // Refresh cadence is owned by the caller: the agent loop only passes the
20
20
  // content through when `mode === "full"` (first turn / post-compaction),
21
21
  // matching the existing PKB auto-inject pattern.
22
+ //
23
+ // Three of the four files are curated aggregate views that consolidation
24
+ // keeps lean. `memory/buffer.md` is not: it is the raw append-only staging
25
+ // log that `remember()` writes and consolidation drains, so its length is
26
+ // bounded only by how far behind consolidation has fallen. The injected
27
+ // Buffer section is therefore capped: see `capBufferSection`.
22
28
 
23
29
  import type { ChannelId } from "../../../../channels/types.js";
24
30
  import { usesConceptPageMemory } from "../../../../config/memory-v3-gate.js";
25
31
  import { readPromptFile } from "../../../../prompts/system-prompt.js";
32
+ import { type BufferEntryLines, splitBufferEntries } from "../buffer-format.js";
26
33
  import { getMemoryConfig } from "../config.js";
27
34
  import { getWorkspacePromptPath } from "../paths.js";
35
+ import { resolveSubstrateTuning } from "./tuning.js";
28
36
 
29
37
  interface MemoryV2StaticBlock {
30
38
  heading: string;
31
39
  file: string;
32
40
  }
33
41
 
42
+ const BUFFER_FILE = "memory/buffer.md";
43
+
34
44
  const MEMORY_V2_STATIC_BLOCKS: readonly MemoryV2StaticBlock[] = [
35
45
  { heading: "## Essentials", file: "memory/essentials.md" },
36
46
  { heading: "## Threads", file: "memory/threads.md" },
37
47
  { heading: "## Recent", file: "memory/recent.md" },
38
- { heading: "## Buffer", file: "memory/buffer.md" },
48
+ { heading: "## Buffer", file: BUFFER_FILE },
39
49
  ];
40
50
 
51
+ /**
52
+ * Leader line on a Buffer section that was capped, so the model knows the
53
+ * section is a tail rather than the whole backlog and where the rest lives.
54
+ */
55
+ const BUFFER_INJECTION_NOTICE =
56
+ "(Older entries trimmed. Read memory/buffer.md for the full backlog.)";
57
+
58
+ /**
59
+ * Cap the Buffer section at its most recent `maxLines` non-empty lines,
60
+ * returning the content unchanged when it already fits or when `maxLines` is
61
+ * `null`.
62
+ *
63
+ * The bound is `consolidation_max_buffer_lines`, the same threshold at which
64
+ * the scheduler already considers the buffer overdue for consolidation (see
65
+ * `jobs-worker.ts`). Reusing it means the injected buffer never exceeds one
66
+ * consolidation's worth of backlog, and that a workspace whose consolidation
67
+ * keeps up sees byte-identical output. `null` disables the size trigger, so
68
+ * the operator has opted out of size-based buffer management and the
69
+ * injection is left unbounded to match.
70
+ *
71
+ * The unit is the entry, not the line. `remember()` stores a multiline fact
72
+ * as one timestamped line plus its body, so trimming by raw line count can
73
+ * land inside a fact and inject continuation lines stripped of the timestamp
74
+ * and opening clause that give them meaning. Whole entries are taken
75
+ * newest-first while they fit, which makes a mid-entry cut unrepresentable.
76
+ * Retained lines keep their original spacing; the notice replaces everything
77
+ * before them.
78
+ *
79
+ * `maxLines` is still measured in non-empty lines, because that is what the
80
+ * scheduler counts (`countBufferLines`), so the two readings of "how big is
81
+ * the buffer" agree. See {@link capOversizedNewestEntry} for the one case
82
+ * that can exceed it.
83
+ */
84
+ function capBufferSection(content: string, maxLines: number | null): string {
85
+ if (maxLines === null) {
86
+ return content;
87
+ }
88
+ const lines = content.split("\n");
89
+ if (countNonEmpty(lines) <= maxLines) {
90
+ return content;
91
+ }
92
+
93
+ const entries = splitBufferEntries(lines);
94
+ const kept: BufferEntryLines[] = [];
95
+ let budget = maxLines;
96
+ // Newest-first: the buffer is append-only, so the tail is the most recent.
97
+ for (let i = entries.length - 1; i >= 0; i--) {
98
+ const entry = entries[i]!;
99
+ // A headless leading group is prose before the first entry, not a fact.
100
+ if (entry.start === null) {
101
+ break;
102
+ }
103
+ const cost = countNonEmpty(entry.lines);
104
+ if (cost > budget) {
105
+ break;
106
+ }
107
+ budget -= cost;
108
+ kept.unshift(entry);
109
+ }
110
+
111
+ const body =
112
+ kept.length > 0
113
+ ? kept.flatMap((entry) => entry.lines).join("\n")
114
+ : capOversizedNewestEntry(lines, entries, maxLines);
115
+ return `${BUFFER_INJECTION_NOTICE}\n${body}`;
116
+ }
117
+
118
+ /**
119
+ * Marker replacing the elided head of an entry too large to inject whole, so
120
+ * the retained tail reads as a fragment of the entry above it rather than as
121
+ * the whole fact.
122
+ */
123
+ const ENTRY_BODY_TRIMMED_NOTICE =
124
+ "(This entry's body was trimmed. Read memory/buffer.md for the rest of it.)";
125
+
126
+ /**
127
+ * Render the tail when not even the newest entry fits inside the cap.
128
+ *
129
+ * There is no whole entry to fall back to here, and all three options cost
130
+ * something. Dropping the entry would leave the section with a "trimmed"
131
+ * notice and no facts at all, which is the worst outcome because the newest
132
+ * fact is usually the one that mattered. Admitting it whole would reopen the
133
+ * unbounded injection the cap exists to prevent, since a fact has no size
134
+ * limit. So the entry keeps its opening line, which carries the timestamp and
135
+ * first clause the tail needs to be readable, then the marker, then as much
136
+ * of the tail as the cap allows. Every injected line stays attributable to a
137
+ * timestamped entry, at a fixed cost of two lines over `maxLines`.
138
+ *
139
+ * Two cases return early. When the cut falls on the opening line's immediate
140
+ * successor nothing was actually elided, so the entry is returned whole
141
+ * without a marker that would claim a trim that did not happen. When the
142
+ * buffer has no entry structure at all, it is hand-written and never went
143
+ * through `remember()`, so there is nothing to preserve and the plain line
144
+ * cut stands.
145
+ */
146
+ function capOversizedNewestEntry(
147
+ lines: string[],
148
+ entries: readonly BufferEntryLines[],
149
+ maxLines: number,
150
+ ): string {
151
+ const cut = lineCutStart(lines, maxLines);
152
+ const newest = entries.at(-1);
153
+ if (newest === undefined || newest.start === null) {
154
+ return lines.slice(cut).join("\n");
155
+ }
156
+ const elidedHead = countNonEmpty(lines.slice(newest.firstLine + 1, cut)) > 0;
157
+ if (!elidedHead) {
158
+ return lines.slice(newest.firstLine).join("\n");
159
+ }
160
+ return [
161
+ lines[newest.firstLine]!,
162
+ ENTRY_BODY_TRIMMED_NOTICE,
163
+ ...lines.slice(cut),
164
+ ].join("\n");
165
+ }
166
+
167
+ /**
168
+ * Index of the first line in the trailing window of `maxLines` non-empty
169
+ * lines, advanced past any blank lines that would otherwise open the section.
170
+ */
171
+ function lineCutStart(lines: readonly string[], maxLines: number): number {
172
+ let kept = 0;
173
+ let cut = lines.length;
174
+ while (cut > 0 && kept < maxLines) {
175
+ cut--;
176
+ if (lines[cut]!.trim().length > 0) {
177
+ kept++;
178
+ }
179
+ }
180
+ while (cut < lines.length && lines[cut]!.trim().length === 0) {
181
+ cut++;
182
+ }
183
+ return cut;
184
+ }
185
+
186
+ /** Non-empty line count, the unit the scheduler's `countBufferLines` uses. */
187
+ function countNonEmpty(lines: readonly string[]): number {
188
+ return lines.filter((line) => line.trim().length > 0).length;
189
+ }
190
+
41
191
  /**
42
192
  * Build the static memory block, gated on concept-page memory being active
43
193
  * ({@link usesConceptPageMemory}). Empty/missing files are skipped; returns
44
- * `null` when the gate is off or every file is empty.
194
+ * `null` when the gate is off or every file is empty. The Buffer section is
195
+ * capped by {@link capBufferSection}.
45
196
  *
46
197
  * `excludeBuffer` drops the `## Buffer` section. The consolidation run sets
47
198
  * it: the agent's contract there is the `memory/buffer.md` FILE — it reads,
@@ -57,17 +208,22 @@ export function readMemoryV2StaticContent(
57
208
  if (!usesConceptPageMemory(memoryConfig)) {
58
209
  return null;
59
210
  }
211
+ const maxBufferLines =
212
+ resolveSubstrateTuning(memoryConfig).consolidation_max_buffer_lines;
60
213
 
61
214
  const sections: string[] = [];
62
215
  for (const { heading, file } of MEMORY_V2_STATIC_BLOCKS) {
63
- if (options.excludeBuffer === true && file === "memory/buffer.md") {
216
+ const isBuffer = file === BUFFER_FILE;
217
+ if (options.excludeBuffer === true && isBuffer) {
64
218
  continue;
65
219
  }
66
220
  const content = readPromptFile(getWorkspacePromptPath(file));
67
221
  if (!content) {
68
222
  continue;
69
223
  }
70
- sections.push(`${heading}\n\n${content}`);
224
+ sections.push(
225
+ `${heading}\n\n${isBuffer ? capBufferSection(content, maxBufferLines) : content}`,
226
+ );
71
227
  }
72
228
  return sections.length > 0 ? sections.join("\n\n") : null;
73
229
  }
@@ -44,10 +44,8 @@ import {
44
44
  conversations,
45
45
  messages,
46
46
  } from "../../../../persistence/schema/index.js";
47
- import {
48
- appendBufferAndArchive,
49
- formatRememberEntry,
50
- } from "../graph/tool-handlers.js";
47
+ import { formatRememberEntry } from "../buffer-format.js";
48
+ import { appendBufferAndArchive } from "../graph/tool-handlers.js";
51
49
  import {
52
50
  extractToolUse,
53
51
  type ToolDefinition,
@@ -1365,7 +1365,9 @@ function resolveConversationTimestamp(conversationId: string): number | null {
1365
1365
  const db = getDb();
1366
1366
  // Use the last message timestamp, not the conversation creation time.
1367
1367
  // A conversation can span hours/days — memories should be timestamped
1368
- // to when the relevant content was actually discussed.
1368
+ // to when the relevant content was actually discussed. Any-state read,
1369
+ // deliberately: only `createdAt` is consumed, and a streaming last row
1370
+ // still marks when discussion happened.
1369
1371
  const lastMsg = db
1370
1372
  .select({ createdAt: messages.createdAt })
1371
1373
  .from(messages)
@@ -293,6 +293,8 @@ export function collectPersistedV3Cards(conversationId: string): Set<string> {
293
293
  const rows = getSqliteFrom(getDb())
294
294
  .query(
295
295
  /*sql*/ `
296
+ -- Any-state scan: only metadata markers are read, and the marker is
297
+ -- written by the injection path on rows it owns regardless of state.
296
298
  SELECT metadata FROM messages
297
299
  WHERE conversation_id = ? AND metadata LIKE '%' || ? || '%'
298
300
  `,
@@ -97,6 +97,8 @@ function forkSourceIdsOf(messageIds: string[]): string[] {
97
97
  .query(
98
98
  /*sql*/ `
99
99
  SELECT json_extract(metadata, '$.forkSourceMessageId') AS src
100
+ -- No completeness predicate: ids come from this store's own selection
101
+ -- log, written for rows the injection pipeline already processed.
100
102
  FROM messages
101
103
  WHERE id IN (${placeholders})
102
104
  `,
@@ -27,6 +27,7 @@ import {
27
27
  cleanupWorkerPidFile,
28
28
  startWorkerPidFileGuard,
29
29
  } from "../../../util/worker-process.js";
30
+ import { registerWorkerPluginSurface } from "../../worker-plugin-surface.js";
30
31
  import { registerMemoryPluginJobHandlers } from "./job-handler-registration.js";
31
32
  import { startMemoryJobsWorkerLoop } from "./jobs-worker.js";
32
33
  import { getLogger } from "./logging.js";
@@ -59,9 +60,11 @@ async function main(): Promise<void> {
59
60
  // and are rejected.
60
61
  await rehydratePlatformCredentials();
61
62
 
62
- // This process does not run plugin bootstrap, so self-register the job
63
- // handlers the worker dispatches from before starting it — the memory
64
- // plugin's own plus the host's non-plugin domain handlers.
63
+ // Jobs in this process wake real agent conversations, so it registers the
64
+ // default plugin hook surface (hooks and injectors, no init hooks) the same
65
+ // way the daemon does, and the job handlers the worker dispatches from: the
66
+ // memory plugin's own plus the host's non-plugin domain handlers.
67
+ registerWorkerPluginSurface();
65
68
  registerMemoryPluginJobHandlers();
66
69
 
67
70
  // Populate the tool registry (core built-ins + workspace tools), exactly as
@@ -92,6 +92,12 @@ const PluginPackageJsonSchema = z
92
92
  version: z.string().optional(),
93
93
  peerDependencies: z.record(z.string(), z.string()).optional(),
94
94
  credentialKeyPatterns: z.unknown().optional(),
95
+ /** Human title, when the package name is not one ("@vellumai/imessage"). */
96
+ displayName: z.string().min(1).optional(),
97
+ /** Standard npm field, reused as the one-line description clients show. */
98
+ description: z.string().min(1).optional(),
99
+ /** Lucide icon name without the `lucide-` prefix, matching `ChannelInfo`. */
100
+ icon: z.string().min(1).optional(),
95
101
  })
96
102
  .passthrough();
97
103
 
@@ -466,6 +472,47 @@ export async function loadExternalPlugin(
466
472
  * surface the failure through their own channel (e.g. the schedule
467
473
  * reconciler's per-day deduped notification).
468
474
  */
475
+ /**
476
+ * How a plugin presents itself: its title, its one line, its glyph.
477
+ *
478
+ * Every field is optional and none of them gate anything. A surface that
479
+ * shows a plugin falls back rather than hiding it, so a missing `icon`
480
+ * costs an icon and not the row.
481
+ */
482
+ export interface PluginPresentation {
483
+ displayName?: string;
484
+ description?: string;
485
+ icon?: string;
486
+ }
487
+
488
+ /**
489
+ * Read a plugin's presentation fields from its manifest.
490
+ *
491
+ * Separate from {@link parsePluginManifest} because the two answer different
492
+ * questions: that one decides whether a plugin is loadable, this one only
493
+ * dresses it for display. An unreadable or invalid manifest yields `undefined`
494
+ * here rather than an error, since a caller showing a plugin has already
495
+ * established it exists.
496
+ */
497
+ export async function parsePluginPresentation(
498
+ pluginDir: string,
499
+ ): Promise<PluginPresentation | undefined> {
500
+ let rawPkg: unknown;
501
+ try {
502
+ rawPkg = JSON.parse(
503
+ await readFile(join(pluginDir, "package.json"), "utf8"),
504
+ );
505
+ } catch {
506
+ return undefined;
507
+ }
508
+ const parsed = PluginPackageJsonSchema.safeParse(rawPkg);
509
+ if (!parsed.success) {
510
+ return undefined;
511
+ }
512
+ const { displayName, description, icon } = parsed.data;
513
+ return { displayName, description, icon };
514
+ }
515
+
469
516
  export async function parsePluginManifest(
470
517
  pluginDir: string,
471
518
  opts: { quiet?: boolean } = {},