@vellumai/assistant 0.6.5 → 0.6.6

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 (443) hide show
  1. package/AGENTS.md +9 -1
  2. package/ARCHITECTURE.md +15 -17
  3. package/Dockerfile +6 -4
  4. package/__tests__/permissions/gateway-threshold-reader.test.ts +283 -0
  5. package/docs/architecture/integrations.md +32 -39
  6. package/docs/architecture/memory.md +25 -30
  7. package/docs/architecture/security.md +7 -6
  8. package/docs/browser-use-architecture-phase2.md +63 -20
  9. package/docs/plugins.md +761 -0
  10. package/examples/plugins/echo/README.md +132 -0
  11. package/examples/plugins/echo/package.json +17 -0
  12. package/examples/plugins/echo/register.ts +187 -0
  13. package/node_modules/@vellumai/egress-proxy/src/types.ts +19 -0
  14. package/openapi.yaml +212 -68
  15. package/package.json +1 -1
  16. package/src/__tests__/app-compiler.test.ts +57 -0
  17. package/src/__tests__/approval-cascade.test.ts +7 -2
  18. package/src/__tests__/auto-analysis-end-to-end.test.ts +1 -0
  19. package/src/__tests__/avatar-generator.test.ts +4 -2
  20. package/src/__tests__/bundled-asset.test.ts +6 -6
  21. package/src/__tests__/catalog-cache.test.ts +69 -0
  22. package/src/__tests__/checker.test.ts +459 -171
  23. package/src/__tests__/circuit-breaker-pipeline.test.ts +406 -0
  24. package/src/__tests__/compaction-events.test.ts +501 -0
  25. package/src/__tests__/compaction-pipeline.test.ts +210 -0
  26. package/src/__tests__/compaction-strip-metadata-clear.test.ts +181 -0
  27. package/src/__tests__/compaction-timeout-recovery.test.ts +262 -0
  28. package/src/__tests__/config-model-image-provider.test.ts +110 -0
  29. package/src/__tests__/config-schema.test.ts +22 -9
  30. package/src/__tests__/config-watcher-cleanup-throttle.test.ts +0 -4
  31. package/src/__tests__/contacts-tools.test.ts +26 -0
  32. package/src/__tests__/context-overflow-policy.test.ts +7 -7
  33. package/src/__tests__/context-window-manager.test.ts +355 -4
  34. package/src/__tests__/conversation-abort-tool-results.test.ts +4 -1
  35. package/src/__tests__/conversation-agent-loop-overflow.test.ts +26 -30
  36. package/src/__tests__/conversation-agent-loop.test.ts +30 -141
  37. package/src/__tests__/conversation-confirmation-signals.test.ts +6 -1
  38. package/src/__tests__/conversation-history-web-search.test.ts +1 -0
  39. package/src/__tests__/conversation-init.benchmark.test.ts +2 -16
  40. package/src/__tests__/conversation-pairing.test.ts +174 -10
  41. package/src/__tests__/conversation-pre-run-repair.test.ts +4 -1
  42. package/src/__tests__/conversation-process-callsite.test.ts +3 -0
  43. package/src/__tests__/conversation-provider-retry-repair.test.ts +16 -7
  44. package/src/__tests__/conversation-queue.test.ts +29 -14
  45. package/src/__tests__/conversation-routes-disk-view.test.ts +7 -6
  46. package/src/__tests__/conversation-runtime-assembly.test.ts +155 -110
  47. package/src/__tests__/conversation-runtime-workspace.test.ts +23 -38
  48. package/src/__tests__/conversation-seed-composer.test.ts +2 -2
  49. package/src/__tests__/conversation-slash-queue.test.ts +7 -2
  50. package/src/__tests__/conversation-slash-unknown.test.ts +25 -2
  51. package/src/__tests__/conversation-speed-override.test.ts +6 -1
  52. package/src/__tests__/conversation-title-service.test.ts +116 -0
  53. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +41 -2
  54. package/src/__tests__/conversation-usage.test.ts +1 -1
  55. package/src/__tests__/conversation-workspace-cache-state.test.ts +4 -1
  56. package/src/__tests__/conversation-workspace-injection.test.ts +3 -0
  57. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +4 -1
  58. package/src/__tests__/credential-health-service.test.ts +78 -9
  59. package/src/__tests__/credential-security-invariants.test.ts +2 -2
  60. package/src/__tests__/db-schedule-syntax-migration.test.ts +1 -0
  61. package/src/__tests__/empty-response-pipeline.test.ts +305 -0
  62. package/src/__tests__/extension-id-sync-guard.test.ts +3 -3
  63. package/src/__tests__/first-greeting.test.ts +247 -5
  64. package/src/__tests__/headless-browser-mode.test.ts +57 -0
  65. package/src/__tests__/history-repair-pipeline.test.ts +399 -0
  66. package/src/__tests__/host-browser-e2e-cloud.test.ts +307 -0
  67. package/src/__tests__/host-browser-e2e-self-hosted.test.ts +3 -3
  68. package/src/__tests__/host-proxy-interface.test.ts +36 -2
  69. package/src/__tests__/image-credentials.test.ts +137 -0
  70. package/src/__tests__/image-service-dispatcher.test.ts +186 -0
  71. package/src/__tests__/injector-chain.test.ts +526 -0
  72. package/src/__tests__/intent-routing.test.ts +0 -26
  73. package/src/__tests__/llm-call-pipeline.test.ts +285 -0
  74. package/src/__tests__/llm-schema.test.ts +1 -1
  75. package/src/__tests__/media-generate-image.test.ts +119 -13
  76. package/src/__tests__/memory-retrieval-pipeline.test.ts +401 -0
  77. package/src/__tests__/memory-upsert-concurrency.test.ts +1 -0
  78. package/src/__tests__/migration-import-from-url.test.ts +5 -68
  79. package/src/__tests__/model-intents.test.ts +4 -2
  80. package/src/__tests__/notification-broadcaster.test.ts +3 -3
  81. package/src/__tests__/notification-decision-strategy.test.ts +0 -11
  82. package/src/__tests__/notification-schedule-notify-dedup.test.ts +108 -0
  83. package/src/__tests__/oauth-apps-routes.test.ts +1 -1
  84. package/src/__tests__/oauth-cli.test.ts +14 -12
  85. package/src/__tests__/oauth-connect-orchestrator.test.ts +4 -13
  86. package/src/__tests__/oauth-provider-serializer.test.ts +6 -4
  87. package/src/__tests__/oauth-provider-visibility.test.ts +3 -5
  88. package/src/__tests__/oauth-providers-routes.test.ts +3 -2
  89. package/src/__tests__/oauth-store.test.ts +41 -76
  90. package/src/__tests__/onboarding-template-contract.test.ts +16 -64
  91. package/src/__tests__/openai-image-service.test.ts +368 -0
  92. package/src/__tests__/overflow-reduce-pipeline.test.ts +676 -0
  93. package/src/__tests__/permission-checker-host-gate.test.ts +0 -24
  94. package/src/__tests__/persist-onboarding-artifacts.test.ts +266 -0
  95. package/src/__tests__/persistence-pipeline.test.ts +377 -0
  96. package/src/__tests__/pipeline-runner.test.ts +565 -0
  97. package/src/__tests__/platform.test.ts +5 -2
  98. package/src/__tests__/plugin-bootstrap.test.ts +483 -0
  99. package/src/__tests__/plugin-registry.test.ts +273 -0
  100. package/src/__tests__/plugin-route-contribution.test.ts +288 -0
  101. package/src/__tests__/plugin-skill-contribution.test.ts +367 -0
  102. package/src/__tests__/plugin-tool-contribution.test.ts +286 -0
  103. package/src/__tests__/plugin-types.test.ts +320 -0
  104. package/src/__tests__/pricing.test.ts +44 -12
  105. package/src/__tests__/proxy-approval-callback.test.ts +69 -8
  106. package/src/__tests__/reaction-persistence.test.ts +1 -0
  107. package/src/__tests__/regenerate-fire-and-forget-trace.test.ts +1 -0
  108. package/src/__tests__/registry.test.ts +0 -2
  109. package/src/__tests__/schedule-routes.test.ts +131 -1
  110. package/src/__tests__/scheduler-recurrence.test.ts +14 -70
  111. package/src/__tests__/scheduler-reuse-conversation.test.ts +10 -50
  112. package/src/__tests__/secret-detection-handler.test.ts +0 -10
  113. package/src/__tests__/shell-identity.test.ts +0 -134
  114. package/src/__tests__/suggestion-routes.test.ts +103 -4
  115. package/src/__tests__/task-memory-cleanup.test.ts +1 -0
  116. package/src/__tests__/task-scheduler.test.ts +3 -15
  117. package/src/__tests__/test-preload.ts +11 -0
  118. package/src/__tests__/title-generate-pipeline.test.ts +224 -0
  119. package/src/__tests__/token-estimate-pipeline.test.ts +431 -0
  120. package/src/__tests__/tool-error-pipeline.test.ts +244 -0
  121. package/src/__tests__/tool-execute-pipeline.test.ts +431 -0
  122. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +0 -6
  123. package/src/__tests__/tool-executor-shell-integration.test.ts +7 -10
  124. package/src/__tests__/tool-executor.test.ts +141 -0
  125. package/src/__tests__/tool-result-truncate-pipeline.test.ts +356 -0
  126. package/src/__tests__/tool-result-truncation.test.ts +0 -110
  127. package/src/__tests__/user-plugin-loader.test.ts +191 -0
  128. package/src/__tests__/workspace-migration-046-seed-conversation-starters-callsite.test.ts +185 -0
  129. package/src/__tests__/workspace-migration-049-release-notes-default-sonnet.test.ts +100 -0
  130. package/src/__tests__/workspace-migration-050-seed-main-agent-opus-callsite.test.ts +171 -0
  131. package/src/__tests__/workspace-migration-051-seed-conversation-summarization-callsite.test.ts +252 -0
  132. package/src/__tests__/workspace-migration-remove-hooks.test.ts +99 -0
  133. package/src/__tests__/workspace-policy.test.ts +21 -3
  134. package/src/agent/loop.ts +340 -102
  135. package/src/approvals/__tests__/guardian-feed-event.test.ts +304 -0
  136. package/src/approvals/guardian-request-resolvers.ts +80 -0
  137. package/src/backup/__tests__/backup-worker.test.ts +2 -13
  138. package/src/backup/backup-worker.ts +3 -15
  139. package/src/bundler/app-compiler.ts +84 -1
  140. package/src/calls/call-state.ts +2 -2
  141. package/src/channels/__tests__/types.test.ts +3 -3
  142. package/src/channels/types.ts +6 -4
  143. package/src/cli/__tests__/notifications.test.ts +87 -211
  144. package/src/cli/commands/__tests__/backup.test.ts +1 -1
  145. package/src/cli/commands/__tests__/image-generation.test.ts +255 -35
  146. package/src/cli/commands/__tests__/inference-send.test.ts +12 -0
  147. package/src/cli/commands/__tests__/tts-synthesize.test.ts +12 -0
  148. package/src/cli/commands/backup.ts +2 -2
  149. package/src/cli/commands/clients.ts +138 -0
  150. package/src/cli/commands/completions.ts +2 -9
  151. package/src/cli/commands/conversations.ts +55 -7
  152. package/src/cli/commands/image-generation.ts +33 -34
  153. package/src/cli/commands/notifications.ts +68 -103
  154. package/src/cli/commands/oauth/__tests__/providers-register.test.ts +1 -1
  155. package/src/cli/commands/oauth/__tests__/providers-update.test.ts +1 -1
  156. package/src/cli/commands/oauth/connect.ts +2 -2
  157. package/src/cli/commands/oauth/providers.ts +176 -8
  158. package/src/cli/commands/oauth/status.ts +46 -36
  159. package/src/cli/commands/skills.ts +3 -4
  160. package/src/cli/program.ts +25 -29
  161. package/src/config/__tests__/backup-schema.test.ts +7 -2
  162. package/src/config/bundled-skills/app-builder/SKILL.md +2 -2
  163. package/src/config/bundled-skills/app-builder/references/WIDGETS.md +10 -10
  164. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +66 -87
  165. package/src/config/bundled-skills/contacts/tools/contact-search.ts +28 -51
  166. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +22 -40
  167. package/src/config/bundled-skills/image-studio/SKILL.md +2 -1
  168. package/src/config/bundled-skills/image-studio/TOOLS.json +2 -1
  169. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +23 -39
  170. package/src/config/bundled-skills/messaging/SKILL.md +3 -3
  171. package/src/config/bundled-skills/messaging/tools/__tests__/messaging-feed-events.test.ts +207 -0
  172. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +12 -0
  173. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +58 -0
  174. package/src/config/bundled-skills/schedule/SKILL.md +8 -3
  175. package/src/config/bundled-skills/schedule/TOOLS.json +15 -7
  176. package/src/config/bundled-skills/schedule/references/SCRIPT_MODE_PATTERNS.md +59 -0
  177. package/src/config/bundled-tool-registry.ts +0 -15
  178. package/src/config/feature-flag-registry.json +17 -1
  179. package/src/config/schema.ts +19 -0
  180. package/src/config/schemas/backup.ts +1 -1
  181. package/src/config/schemas/conversations.ts +16 -0
  182. package/src/config/schemas/llm.ts +2 -3
  183. package/src/config/schemas/security.ts +6 -6
  184. package/src/config/schemas/tts.ts +11 -0
  185. package/src/config/skill-state.ts +6 -2
  186. package/src/config/skills.ts +94 -5
  187. package/src/context/__tests__/compact-prompt.test.ts +27 -9
  188. package/src/context/prompts/compact.md +26 -12
  189. package/src/context/tool-result-truncation.ts +3 -63
  190. package/src/context/window-manager.ts +190 -16
  191. package/src/credential-health/credential-health-service.ts +19 -6
  192. package/src/daemon/__tests__/conversation-feed-event.test.ts +317 -0
  193. package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +4 -12
  194. package/src/daemon/__tests__/conversation-tool-setup.test.ts +14 -15
  195. package/src/daemon/config-watcher.ts +0 -2
  196. package/src/daemon/context-overflow-policy.ts +4 -13
  197. package/src/daemon/conversation-agent-loop-handlers.ts +83 -22
  198. package/src/daemon/conversation-agent-loop.ts +984 -683
  199. package/src/daemon/conversation-history.ts +10 -19
  200. package/src/daemon/conversation-lifecycle.ts +37 -19
  201. package/src/daemon/conversation-notifiers.ts +2 -110
  202. package/src/daemon/conversation-process.ts +14 -7
  203. package/src/daemon/conversation-runtime-assembly.ts +532 -411
  204. package/src/daemon/conversation-tool-setup.ts +41 -4
  205. package/src/daemon/conversation.ts +80 -35
  206. package/src/daemon/external-plugins-bootstrap.ts +478 -0
  207. package/src/daemon/first-greeting.ts +191 -14
  208. package/src/daemon/handlers/config-model.ts +11 -0
  209. package/src/daemon/handlers/skills.ts +5 -1
  210. package/src/daemon/lifecycle.ts +33 -68
  211. package/src/daemon/message-types/computer-use.ts +2 -34
  212. package/src/daemon/message-types/conversations.ts +49 -0
  213. package/src/daemon/message-types/messages.ts +12 -0
  214. package/src/daemon/server.ts +5 -3
  215. package/src/daemon/shutdown-handlers.ts +2 -12
  216. package/src/daemon/tool-side-effects.ts +14 -56
  217. package/src/heartbeat/__tests__/heartbeat-feed-event.test.ts +160 -0
  218. package/src/heartbeat/heartbeat-service.ts +24 -1
  219. package/src/home/__tests__/feed-population-integration.test.ts +312 -0
  220. package/src/home/emit-feed-event.ts +7 -0
  221. package/src/home/feed-types.ts +41 -2
  222. package/src/home/rewrite-command-preview.ts +66 -0
  223. package/src/ipc/__tests__/socket-path.test.ts +11 -50
  224. package/src/ipc/cli-client.ts +1 -1
  225. package/src/ipc/cli-server.ts +3 -3
  226. package/src/ipc/gateway-client.ts +4 -1
  227. package/src/ipc/routes/browser-context.ts +2 -0
  228. package/src/ipc/routes/browser.ts +1 -0
  229. package/src/ipc/routes/get-contact.ts +16 -0
  230. package/src/ipc/routes/index.ts +14 -0
  231. package/src/ipc/routes/list-clients.ts +31 -0
  232. package/src/ipc/routes/merge-contacts.ts +17 -0
  233. package/src/ipc/routes/notification.ts +133 -0
  234. package/src/ipc/routes/rename-conversation.ts +59 -0
  235. package/src/ipc/routes/search-contacts.ts +19 -0
  236. package/src/ipc/routes/upsert-contact.ts +25 -0
  237. package/src/ipc/socket-path.ts +14 -38
  238. package/src/media/app-icon-generator.ts +23 -46
  239. package/src/media/avatar-router.ts +26 -41
  240. package/src/media/gemini-image-service.ts +8 -41
  241. package/src/media/image-credentials.ts +73 -0
  242. package/src/media/image-service.ts +85 -0
  243. package/src/media/openai-image-service.ts +131 -0
  244. package/src/media/types.ts +46 -0
  245. package/src/memory/conversation-crud.ts +48 -18
  246. package/src/memory/conversation-queries.ts +57 -4
  247. package/src/memory/conversation-title-service.ts +25 -0
  248. package/src/memory/db-init.ts +8 -0
  249. package/src/memory/embedding-gemini.test.ts +41 -2
  250. package/src/memory/embedding-gemini.ts +6 -1
  251. package/src/memory/graph/bootstrap.test.ts +282 -0
  252. package/src/memory/graph/bootstrap.ts +8 -5
  253. package/src/memory/graph/extraction.ts +10 -2
  254. package/src/memory/graph/graph-search.test.ts +1 -0
  255. package/src/memory/graph/inspect.ts +2 -2
  256. package/src/memory/graph/retriever.ts +10 -3
  257. package/src/memory/migrations/041-approval-prompt-ts-tracker.ts +26 -0
  258. package/src/memory/migrations/149-oauth-tables.ts +1 -0
  259. package/src/memory/migrations/223-schedule-script-column.ts +11 -0
  260. package/src/memory/migrations/224-oauth-providers-managed-service-is-paid.ts +24 -0
  261. package/src/memory/migrations/225-oauth-providers-available-scopes.ts +13 -0
  262. package/src/memory/migrations/index.ts +4 -0
  263. package/src/memory/pkb/pkb-index.test.ts +1 -0
  264. package/src/memory/pkb/pkb-reconcile.test.ts +1 -0
  265. package/src/memory/pkb/pkb-search.test.ts +65 -4
  266. package/src/memory/pkb/pkb-search.ts +40 -18
  267. package/src/memory/qdrant-client.test.ts +60 -0
  268. package/src/memory/qdrant-client.ts +25 -0
  269. package/src/memory/schema/infrastructure.ts +1 -0
  270. package/src/memory/schema/oauth.ts +4 -1
  271. package/src/messaging/providers/slack/render-transcript.test.ts +77 -29
  272. package/src/messaging/providers/slack/render-transcript.ts +58 -0
  273. package/src/notifications/conversation-pairing.ts +78 -19
  274. package/src/notifications/copy-composer.ts +0 -5
  275. package/src/notifications/emit-signal.ts +1 -1
  276. package/src/notifications/signal.ts +1 -2
  277. package/src/oauth/AGENTS.md +1 -1
  278. package/src/oauth/__tests__/identity-verifier.test.ts +2 -1
  279. package/src/oauth/connect-orchestrator.ts +8 -34
  280. package/src/oauth/connect-types.ts +6 -10
  281. package/src/oauth/manual-token-connection.ts +23 -0
  282. package/src/oauth/oauth-store.ts +30 -14
  283. package/src/oauth/provider-serializer.ts +6 -1
  284. package/src/oauth/seed-providers.ts +56 -108
  285. package/src/outbound-proxy/http-forwarder.ts +9 -0
  286. package/src/permissions/approval-policy.test.ts +293 -18
  287. package/src/permissions/approval-policy.ts +110 -58
  288. package/src/permissions/arg-parser.test.ts +161 -0
  289. package/src/permissions/arg-parser.ts +141 -0
  290. package/src/permissions/bash-risk-classifier.test.ts +414 -2
  291. package/src/permissions/bash-risk-classifier.ts +303 -60
  292. package/src/permissions/checker.ts +157 -29
  293. package/src/permissions/command-registry.test.ts +239 -0
  294. package/src/permissions/command-registry.ts +234 -54
  295. package/src/permissions/defaults.ts +5 -4
  296. package/src/permissions/gateway-threshold-reader.ts +196 -0
  297. package/src/permissions/prompter.ts +4 -0
  298. package/src/permissions/risk-types.ts +61 -4
  299. package/src/permissions/schedule-risk-classifier.test.ts +129 -0
  300. package/src/permissions/schedule-risk-classifier.ts +85 -0
  301. package/src/permissions/shell-identity.ts +2 -42
  302. package/src/permissions/types.ts +2 -0
  303. package/src/permissions/workspace-policy.ts +8 -3
  304. package/src/plugins/defaults/circuit-breaker.ts +146 -0
  305. package/src/plugins/defaults/compaction.ts +145 -0
  306. package/src/plugins/defaults/empty-response.ts +126 -0
  307. package/src/plugins/defaults/history-repair.ts +85 -0
  308. package/src/plugins/defaults/index.ts +116 -0
  309. package/src/plugins/defaults/injectors.ts +491 -0
  310. package/src/plugins/defaults/llm-call.ts +82 -0
  311. package/src/plugins/defaults/memory-retrieval.ts +226 -0
  312. package/src/plugins/defaults/overflow-reduce.ts +181 -0
  313. package/src/plugins/defaults/persistence.ts +129 -0
  314. package/src/plugins/defaults/title-generate.ts +95 -0
  315. package/src/plugins/defaults/token-estimate.ts +104 -0
  316. package/src/plugins/defaults/tool-error.ts +126 -0
  317. package/src/plugins/defaults/tool-execute.ts +89 -0
  318. package/src/plugins/defaults/tool-result-truncate.ts +88 -0
  319. package/src/plugins/pipeline.ts +316 -0
  320. package/src/plugins/plugin-skill-contributions.ts +292 -0
  321. package/src/plugins/registry.ts +241 -0
  322. package/src/plugins/types.ts +1134 -0
  323. package/src/plugins/user-loader.ts +177 -0
  324. package/src/prompts/templates/BOOTSTRAP.md +27 -77
  325. package/src/providers/model-catalog.ts +52 -29
  326. package/src/providers/model-intents.ts +1 -1
  327. package/src/providers/openrouter/client.ts +5 -1
  328. package/src/providers/speech-to-text/deepgram-realtime.test.ts +61 -0
  329. package/src/providers/speech-to-text/deepgram-realtime.ts +57 -0
  330. package/src/providers/speech-to-text/xai-realtime.test.ts +72 -4
  331. package/src/providers/speech-to-text/xai-realtime.ts +39 -14
  332. package/src/runtime/AGENTS.md +25 -16
  333. package/src/runtime/__tests__/browser-extension-pair-routes.test.ts +3 -3
  334. package/src/runtime/__tests__/client-registry.test.ts +293 -0
  335. package/src/runtime/client-registry.ts +261 -0
  336. package/src/runtime/http-server.ts +77 -8
  337. package/src/runtime/http-types.ts +0 -2
  338. package/src/runtime/migrations/vbundle-builder.ts +1 -22
  339. package/src/runtime/routes/approval-prompt-ts-tracker.ts +51 -31
  340. package/src/runtime/routes/approval-routes.ts +17 -0
  341. package/src/runtime/routes/browser-extension-pair-routes.ts +27 -8
  342. package/src/runtime/routes/conversation-routes.ts +223 -116
  343. package/src/runtime/routes/inbound-message-handler.ts +88 -13
  344. package/src/runtime/routes/memory-item-routes.test.ts +1 -0
  345. package/src/runtime/routes/migration-routes.ts +0 -3
  346. package/src/runtime/routes/playground/__tests__/force-compact.test.ts +284 -0
  347. package/src/runtime/routes/playground/__tests__/guard.test.ts +80 -0
  348. package/src/runtime/routes/playground/__tests__/inject-failures.test.ts +294 -0
  349. package/src/runtime/routes/playground/__tests__/reset-circuit.test.ts +271 -0
  350. package/src/runtime/routes/playground/__tests__/seed-conversation.test.ts +202 -0
  351. package/src/runtime/routes/playground/__tests__/seeded-conversations.test.ts +309 -0
  352. package/src/runtime/routes/playground/__tests__/state.test.ts +224 -0
  353. package/src/runtime/routes/playground/conversation-not-found.ts +29 -0
  354. package/src/runtime/routes/playground/deps.ts +56 -0
  355. package/src/runtime/routes/playground/force-compact.ts +73 -0
  356. package/src/runtime/routes/playground/guard.ts +37 -0
  357. package/src/runtime/routes/playground/index.ts +28 -0
  358. package/src/runtime/routes/playground/inject-failures.ts +159 -0
  359. package/src/runtime/routes/playground/reset-circuit.ts +115 -0
  360. package/src/runtime/routes/playground/seed-conversation.ts +139 -0
  361. package/src/runtime/routes/playground/seeded-conversations.ts +78 -0
  362. package/src/runtime/routes/playground/state.ts +78 -0
  363. package/src/runtime/routes/schedule-routes.ts +89 -8
  364. package/src/runtime/skill-route-registry.ts +75 -15
  365. package/src/schedule/run-script.ts +68 -0
  366. package/src/schedule/schedule-store.ts +7 -1
  367. package/src/schedule/scheduler.ts +48 -8
  368. package/src/skills/catalog-cache.ts +12 -5
  369. package/src/tools/browser/__tests__/browser-status.test.ts +189 -0
  370. package/src/tools/browser/browser-execution.ts +88 -19
  371. package/src/tools/browser/cdp-client/__tests__/extension-cdp-client.test.ts +230 -0
  372. package/src/tools/browser/cdp-client/__tests__/factory.test.ts +146 -3
  373. package/src/tools/browser/cdp-client/extension-cdp-client.ts +54 -3
  374. package/src/tools/browser/cdp-client/factory.ts +15 -4
  375. package/src/tools/executor.ts +126 -74
  376. package/src/tools/network/script-proxy/session-manager.ts +37 -1
  377. package/src/tools/permission-checker.ts +98 -49
  378. package/src/tools/policy-context.ts +4 -0
  379. package/src/tools/registry.ts +140 -3
  380. package/src/tools/schedule/create.ts +23 -8
  381. package/src/tools/schedule/update.ts +3 -1
  382. package/src/tools/secret-detection-handler.ts +0 -51
  383. package/src/tools/system/avatar-generator.ts +6 -2
  384. package/src/tools/types.ts +28 -2
  385. package/src/util/platform.ts +7 -2
  386. package/src/util/pricing.ts +26 -3
  387. package/src/workspace/migrations/006-services-config.ts +2 -4
  388. package/src/workspace/migrations/022-move-hooks-to-workspace.ts +2 -3
  389. package/src/workspace/migrations/041-backfill-google-gmail-settings-scope.ts +3 -4
  390. package/src/workspace/migrations/046-seed-conversation-starters-callsite.ts +108 -0
  391. package/src/workspace/migrations/047-remove-watch-callsites.ts +54 -0
  392. package/src/workspace/migrations/048-remove-workspace-hooks.ts +81 -0
  393. package/src/workspace/migrations/049-release-notes-default-sonnet.ts +80 -0
  394. package/src/workspace/migrations/050-seed-main-agent-opus-callsite.ts +86 -0
  395. package/src/workspace/migrations/051-seed-conversation-summarization-callsite.ts +128 -0
  396. package/src/workspace/migrations/registry.ts +12 -0
  397. package/tsconfig.json +1 -1
  398. package/hook-templates/debug-prompt-logger/hook.json +0 -7
  399. package/hook-templates/debug-prompt-logger/run.sh +0 -66
  400. package/src/__tests__/compaction-circuit-breaker.test.ts +0 -336
  401. package/src/__tests__/context-overflow-approval.test.ts +0 -156
  402. package/src/__tests__/hooks-blocking.test.ts +0 -178
  403. package/src/__tests__/hooks-cli.test.ts +0 -182
  404. package/src/__tests__/hooks-config.test.ts +0 -108
  405. package/src/__tests__/hooks-discovery.test.ts +0 -211
  406. package/src/__tests__/hooks-integration.test.ts +0 -196
  407. package/src/__tests__/hooks-manager.test.ts +0 -226
  408. package/src/__tests__/hooks-runner.test.ts +0 -175
  409. package/src/__tests__/hooks-settings.test.ts +0 -160
  410. package/src/__tests__/hooks-templates.test.ts +0 -169
  411. package/src/__tests__/hooks-ts-runner.test.ts +0 -170
  412. package/src/__tests__/hooks-watch.test.ts +0 -112
  413. package/src/__tests__/notification-schedule-dedup.test.ts +0 -213
  414. package/src/__tests__/oauth-scope-policy.test.ts +0 -180
  415. package/src/__tests__/send-notification-tool.test.ts +0 -83
  416. package/src/cli/commands/shotgun.ts +0 -266
  417. package/src/config/bundled-skills/conversations/SKILL.md +0 -20
  418. package/src/config/bundled-skills/conversations/TOOLS.json +0 -23
  419. package/src/config/bundled-skills/conversations/tools/rename-conversation.ts +0 -88
  420. package/src/config/bundled-skills/heartbeat/SKILL.md +0 -43
  421. package/src/config/bundled-skills/notifications/SKILL.md +0 -40
  422. package/src/config/bundled-skills/notifications/TOOLS.json +0 -80
  423. package/src/config/bundled-skills/notifications/tools/send-notification.ts +0 -152
  424. package/src/config/bundled-skills/notifications/tools/shared.ts +0 -13
  425. package/src/config/bundled-skills/screen-watch/SKILL.md +0 -27
  426. package/src/config/bundled-skills/screen-watch/TOOLS.json +0 -35
  427. package/src/config/bundled-skills/screen-watch/tools/start-screen-watch.ts +0 -12
  428. package/src/config/bundled-skills/skills-catalog/SKILL.md +0 -84
  429. package/src/daemon/context-overflow-approval.ts +0 -52
  430. package/src/daemon/watch-handler.ts +0 -399
  431. package/src/hooks/cli.ts +0 -253
  432. package/src/hooks/config.ts +0 -100
  433. package/src/hooks/discovery.ts +0 -135
  434. package/src/hooks/manager.ts +0 -179
  435. package/src/hooks/runner.ts +0 -117
  436. package/src/hooks/templates.ts +0 -77
  437. package/src/hooks/types.ts +0 -75
  438. package/src/oauth/scope-policy.ts +0 -89
  439. package/src/runtime/gateway-internal-client.ts +0 -94
  440. package/src/runtime/routes/watch-routes.ts +0 -156
  441. package/src/signals/shotgun.ts +0 -203
  442. package/src/tools/watch/screen-watch.ts +0 -144
  443. package/src/tools/watch/watch-state.ts +0 -142
@@ -18,15 +18,22 @@ import {
18
18
  countMemoryPrefixBlocks,
19
19
  extractMemoryPrefixBlocks,
20
20
  } from "../memory/graph/conversation-graph-memory.js";
21
- import { searchPkbFiles } from "../memory/pkb/pkb-search.js";
22
21
  import type { QdrantSparseVector } from "../memory/qdrant-client.js";
23
22
  import { readSlackMetadata } from "../messaging/providers/slack/message-metadata.js";
24
23
  import {
25
24
  extractTagLineTexts,
25
+ isReactionTagLine,
26
26
  type RenderableSlackMessage,
27
27
  renderSlackTranscript,
28
28
  } from "../messaging/providers/slack/render-transcript.js";
29
29
  import { isPermissionControlsV2Enabled } from "../permissions/v2-consent-policy.js";
30
+ import { getInjectors } from "../plugins/registry.js";
31
+ import type {
32
+ InjectionBlock,
33
+ InjectionPlacement,
34
+ TurnContext,
35
+ TurnInjectionInputs,
36
+ } from "../plugins/types.js";
30
37
  import type { ContentBlock, Message } from "../providers/types.js";
31
38
  import {
32
39
  type ActorTrustContext,
@@ -36,17 +43,10 @@ import {
36
43
  import { channelStatusToMemberStatus } from "../runtime/routes/inbound-stages/acl-enforcement.js";
37
44
  import type { SubagentState } from "../subagent/types.js";
38
45
  import { TERMINAL_STATUSES } from "../subagent/types.js";
39
- import { getLogger } from "../util/logger.js";
40
46
  import { getWorkspaceDir, getWorkspacePromptPath } from "../util/platform.js";
41
47
  import { stripCommentLines } from "../util/strip-comment-lines.js";
42
48
  import { filterMessagesForUntrustedActor } from "./conversation-lifecycle.js";
43
- import {
44
- getInContextPkbPaths,
45
- type PkbContextConversation,
46
- } from "./pkb-context-tracker.js";
47
- import { buildPkbReminder } from "./pkb-reminder-builder.js";
48
-
49
- const pkbReminderLog = getLogger("pkb-reminder");
49
+ import { type PkbContextConversation } from "./pkb-context-tracker.js";
50
50
 
51
51
  /**
52
52
  * Describes the capabilities of the channel through which the user is
@@ -522,16 +522,11 @@ export function buildSubagentStatusBlock(
522
522
  return lines.join("\n");
523
523
  }
524
524
 
525
- /** Append a subagent status block to the last user message. */
526
- export function injectSubagentStatus(
527
- message: Message,
528
- statusBlock: string,
529
- ): Message {
530
- return {
531
- ...message,
532
- content: [...message.content, { type: "text" as const, text: statusBlock }],
533
- };
534
- }
525
+ // The `<active_subagents>` block is emitted by the `subagent-status` default
526
+ // injector (`plugins/defaults/injectors.ts`) as an `append-user-tail`
527
+ // placement. Use {@link applyRuntimeInjections} with
528
+ // `options.subagentStatusBlock` set, or drive the injector chain directly
529
+ // via `collectInjectorBlocks`.
535
530
 
536
531
  /**
537
532
  * Append voice call-control protocol instructions to the last user
@@ -575,42 +570,10 @@ export function readNowScratchpad(): string | null {
575
570
  }
576
571
 
577
572
  /**
578
- * Insert NOW.md scratchpad content into the user message, after any
579
- * injected context blocks (e.g. memory_context) but before the user's
580
- * original content. This keeps the user's actual message as the last
581
- * thing the model reads.
573
+ * The `<NOW.md>` block is emitted by the `now-md` default injector
574
+ * (`plugins/defaults/injectors.ts`) as an `after-memory-prefix` placement.
575
+ * Use {@link applyRuntimeInjections} with `options.nowScratchpad` set.
582
576
  */
583
- export function injectNowScratchpad(
584
- message: Message,
585
- content: string,
586
- ): Message {
587
- const scratchpadBlock = {
588
- type: "text" as const,
589
- text: `<NOW.md Always keep this up to date; keep under 10 lines>\n${content}\n</NOW.md>`,
590
- };
591
-
592
- // Find insertion point: skip any leading injected-context text blocks
593
- // (e.g. memory_context) so the scratchpad lands between injected context
594
- // and the user's original content.
595
- let insertIdx = 0;
596
- for (let i = 0; i < message.content.length; i++) {
597
- const block = message.content[i];
598
- if (block.type === "text" && block.text.startsWith("<memory_context")) {
599
- insertIdx = i + 1;
600
- } else {
601
- break;
602
- }
603
- }
604
-
605
- return {
606
- ...message,
607
- content: [
608
- ...message.content.slice(0, insertIdx),
609
- scratchpadBlock,
610
- ...message.content.slice(insertIdx),
611
- ],
612
- };
613
- }
614
577
 
615
578
  /** Strip `<NOW.md>` blocks injected by `injectNowScratchpad`. */
616
579
  export function stripNowScratchpad(messages: Message[]): Message[] {
@@ -638,16 +601,6 @@ const AUTOINJECT_FILENAME = "_autoinject.md";
638
601
  /** Max buffer.md lines injected into prompts — keeps context bounded even when filing is off. */
639
602
  const MAX_BUFFER_LINES = 50;
640
603
 
641
- /** Minimum hybrid-search score for a PKB path to surface as an injection hint. */
642
- const PKB_HINT_THRESHOLD = 0.5;
643
-
644
- /**
645
- * Stricter hint threshold for PKB entries under `archive/`. Archive files are
646
- * date-indexed dumps of older notes — they match loosely and are rarely the
647
- * most relevant read, so require a higher bar before recommending them.
648
- */
649
- const PKB_HINT_ARCHIVE_THRESHOLD = 0.7;
650
-
651
604
  /**
652
605
  * Read `_autoinject.md` from the PKB directory and return the list of
653
606
  * filenames to inject.
@@ -728,49 +681,10 @@ export function readPkbContext(): string | null {
728
681
  return parts.length > 0 ? parts.join("\n\n") : null;
729
682
  }
730
683
 
731
- /**
732
- * Insert PKB context into the user message, after any injected memory
733
- * blocks but before NOW.md and the user's original content.
734
- */
735
- export function injectPkbContext(message: Message, content: string): Message {
736
- // Escape closing tags that could break out of the XML wrapper
737
- const escaped = content.replace(
738
- /<\/knowledge_base\s*>/gi,
739
- "&lt;/knowledge_base&gt;",
740
- );
741
- const pkbBlock = {
742
- type: "text" as const,
743
- text: `<knowledge_base>\n${escaped}\n</knowledge_base>`,
744
- };
745
-
746
- // Find insertion point: skip any leading memory/image blocks
747
- let insertIdx = 0;
748
- for (let i = 0; i < message.content.length; i++) {
749
- const block = message.content[i];
750
- if (
751
- block.type === "text" &&
752
- (block.text.startsWith("<memory") ||
753
- block.text.startsWith("</memory_image>") ||
754
- block.text.startsWith("<memory_context"))
755
- ) {
756
- insertIdx = i + 1;
757
- } else if (block.type === "image") {
758
- // Memory images precede the memory text block
759
- insertIdx = i + 1;
760
- } else {
761
- break;
762
- }
763
- }
764
-
765
- return {
766
- ...message,
767
- content: [
768
- ...message.content.slice(0, insertIdx),
769
- pkbBlock,
770
- ...message.content.slice(insertIdx),
771
- ],
772
- };
773
- }
684
+ // The `<knowledge_base>` block is emitted by the `pkb-context` default
685
+ // injector (`plugins/defaults/injectors.ts`) as an `after-memory-prefix`
686
+ // placement, splicing immediately after any leading memory-prefix blocks.
687
+ // Use {@link applyRuntimeInjections} with `options.pkbContext` set.
774
688
 
775
689
  /** Strip `<knowledge_base>` blocks injected by `injectPkbContext`. */
776
690
  export function stripPkbContext(messages: Message[]): Message[] {
@@ -1146,18 +1060,10 @@ export function stripChannelCapabilityContext(messages: Message[]): Message[] {
1146
1060
  return stripUserTextBlocksByPrefix(messages, ["<channel_capabilities>"]);
1147
1061
  }
1148
1062
 
1149
- /**
1150
- * Prepend workspace top-level directory context to a user message.
1151
- */
1152
- export function injectWorkspaceTopLevelContext(
1153
- message: Message,
1154
- contextText: string,
1155
- ): Message {
1156
- return {
1157
- ...message,
1158
- content: [{ type: "text", text: contextText }, ...message.content],
1159
- };
1160
- }
1063
+ // The workspace top-level context block is emitted by the
1064
+ // `workspace-context` default injector (`plugins/defaults/injectors.ts`)
1065
+ // as a `prepend-user-tail` placement. Use {@link applyRuntimeInjections}
1066
+ // with `options.workspaceTopLevelContext` set.
1161
1067
 
1162
1068
  /**
1163
1069
  * Strip `<active_workspace>` (and legacy `<active_dynamic_page>`) blocks
@@ -1549,21 +1455,33 @@ function buildActiveThreadBlockFromRenderable(
1549
1455
  if (members.length === 0) return null;
1550
1456
 
1551
1457
  // The active-thread block is flattened to plain text below, which discards
1552
- // `Message.role`. Force a role-derived sender label on any member whose
1553
- // `rowToRenderable` emitted `null` (assistant rows, user rows without a
1554
- // real Slack displayName) so speaker attribution survives the flattening.
1555
- const labeledMembers = members.map((m) =>
1556
- m.senderLabel
1557
- ? m
1558
- : {
1559
- ...m,
1560
- senderLabel: m.role === "assistant" ? "@assistant" : "@user",
1561
- },
1562
- );
1458
+ // `Message.role`. Assistant rows are relabeled in the post-render step:
1459
+ // `renderSlackTranscript` emits assistant content with no tag-line wrapper
1460
+ // (to prevent the model mimicking `[MM/DD/YY HH:MM]:` prefixes in outbound
1461
+ // replies), so we prepend an explicit `@assistant:` label to the flattened
1462
+ // line. Unnamed user rows (no real Slack displayName) get a `@user`
1463
+ // senderLabel here so their tag line carries attribution through the
1464
+ // renderer. Labeled user rows and assistant rows pass through unchanged.
1465
+ const labeledMembers = members.map((m) => {
1466
+ if (m.role === "assistant") return m;
1467
+ if (m.senderLabel !== null) return m;
1468
+ return { ...m, senderLabel: "@user" };
1469
+ });
1563
1470
 
1564
1471
  const rendered = renderSlackTranscript(labeledMembers);
1565
1472
  if (rendered.length === 0) return null;
1566
- const lines = extractTagLineTexts(rendered).join("\n");
1473
+ // Reaction / overflow-trailer lines already embed `@assistant` inline, so
1474
+ // `isReactionTagLine` is used to skip those and avoid double-attribution
1475
+ // (`@assistant: [... @assistant reacted ...]`). Regular content and the
1476
+ // `[deleted]` sentinel get the prefix so attribution survives flattening.
1477
+ const lines = rendered
1478
+ .map((msg) => {
1479
+ const text = extractTagLineTexts([msg])[0] ?? "";
1480
+ return msg.role === "assistant" && !isReactionTagLine(text)
1481
+ ? `@assistant: ${text}`
1482
+ : text;
1483
+ })
1484
+ .join("\n");
1567
1485
  return `<active_thread>\n${lines}\n</active_thread>`;
1568
1486
  }
1569
1487
 
@@ -1715,13 +1633,32 @@ export function findLastInjectedNowContent(messages: Message[]): string | null {
1715
1633
  export type InjectionMode = "full" | "minimal";
1716
1634
 
1717
1635
  /**
1718
- * Per-turn injection bytes captured for later persistence to message
1719
- * metadata. Empty in this PR later PRs capture `<turn_context>` and
1720
- * `<system_reminder>` bodies so they survive daemon restarts.
1636
+ * Per-turn injection bytes captured so `loadFromDb` can rehydrate historical
1637
+ * user messages byte-for-byte after a daemon restart or conversation
1638
+ * eviction. Persisting the exact injected text onto message metadata keeps
1639
+ * Anthropic's prefix cache anchored to msg[0] instead of invalidating every
1640
+ * turn on reload. Any field left `undefined` means that block was not
1641
+ * injected on this turn.
1721
1642
  */
1722
1643
  export interface RuntimeInjectionBlocks {
1723
1644
  unifiedTurnContext?: string;
1724
1645
  pkbSystemReminder?: string;
1646
+ workspaceBlock?: string;
1647
+ nowScratchpadBlock?: string;
1648
+ pkbContextBlock?: string;
1649
+ /**
1650
+ * Composed output of every plugin-registered {@link Injector}, concatenated
1651
+ * in ascending `order`. Empty string when every injector opted out (returned
1652
+ * `null`). Today the default injectors (`default-injectors` plugin)
1653
+ * placeholder-return `null`, so this is only non-empty when a third-party
1654
+ * plugin registers an injector that emits content.
1655
+ *
1656
+ * Populated by {@link composeInjectorChain} during
1657
+ * {@link applyRuntimeInjections}. Distinct from the other `blocks` fields
1658
+ * because those track specific hardcoded injections today; this field is
1659
+ * the extensibility seam for {@link Injector} plugins.
1660
+ */
1661
+ injectorChainBlock?: string;
1725
1662
  }
1726
1663
 
1727
1664
  export interface RuntimeInjectionResult {
@@ -1730,118 +1667,449 @@ export interface RuntimeInjectionResult {
1730
1667
  }
1731
1668
 
1732
1669
  /**
1733
- * Apply a chain of user-message injections to `runMessages`.
1670
+ * Run every registered {@link Injector}'s `produce()` in ascending `order`
1671
+ * and return every non-null block the chain produced.
1672
+ *
1673
+ * Injectors returning `null` are omitted from the result. The returned array
1674
+ * preserves ascending-`order` sort so downstream callers (notably
1675
+ * {@link applyRuntimeInjections}) can group blocks by `placement` and apply
1676
+ * them declaratively without losing per-injector ordering within each slot.
1677
+ */
1678
+ export async function collectInjectorBlocks(
1679
+ ctx: TurnContext,
1680
+ ): Promise<InjectionBlock[]> {
1681
+ const injectors = getInjectors();
1682
+ if (injectors.length === 0) return [];
1683
+ const out: InjectionBlock[] = [];
1684
+ for (const injector of injectors) {
1685
+ const block = await injector.produce(ctx);
1686
+ if (block) out.push(block);
1687
+ }
1688
+ return out;
1689
+ }
1690
+
1691
+ /**
1692
+ * Run every registered {@link Injector}'s `produce()` in ascending
1693
+ * `order`, concatenate the non-null results into a single block of text,
1694
+ * and return it.
1695
+ *
1696
+ * Separator: blank line between blocks. Injectors returning `null` are
1697
+ * skipped entirely (no leading/trailing blank lines). When no injector
1698
+ * contributes, the function returns an empty string.
1734
1699
  *
1735
- * Each injection is optional pass `null`/`undefined` to skip it.
1736
- * Returns the final message array ready for the provider, along with a
1737
- * `blocks` object reserved for captured injection bytes (currently empty).
1700
+ * Used by tests that assert the concatenation contract and by callers that
1701
+ * want a single informational string view of the chain. The canonical
1702
+ * integration point is {@link applyRuntimeInjections}, which uses
1703
+ * {@link collectInjectorBlocks} + placement-aware application to splice
1704
+ * each block into the per-turn message array.
1705
+ */
1706
+ export async function composeInjectorChain(ctx: TurnContext): Promise<string> {
1707
+ const blocks = await collectInjectorBlocks(ctx);
1708
+ const pieces: string[] = [];
1709
+ for (const block of blocks) {
1710
+ if (block.text.length > 0) pieces.push(block.text);
1711
+ }
1712
+ return pieces.join("\n\n");
1713
+ }
1714
+
1715
+ /**
1716
+ * Default block placement. Kept in sync with {@link InjectionBlock} so
1717
+ * blocks produced without an explicit `placement` (e.g. third-party
1718
+ * injectors written against the pre-G2.1 API) behave predictably.
1719
+ */
1720
+ const DEFAULT_PLACEMENT: InjectionPlacement = "append-user-tail";
1721
+
1722
+ /**
1723
+ * Count leading memory-prefix blocks on a user message's `content`.
1724
+ *
1725
+ * Delegates to {@link countMemoryPrefixBlocks} from
1726
+ * `memory/graph/conversation-graph-memory.js` — the same state-machine the
1727
+ * pre-migration PKB-reminder branch used to find its splice point. The
1728
+ * pre-migration `injectPkbContext` and `injectNowScratchpad` helpers used
1729
+ * slightly simpler rules inline; reusing the canonical counter here
1730
+ * collapses the three near-identical splice rules into one source of truth
1731
+ * so the ordering of PKB-context / PKB-reminder / NOW blocks relative to
1732
+ * any memory prefix is stable and testable. For the common case (just
1733
+ * `<memory __injected>` text, no images), the output is byte-identical to
1734
+ * the pre-migration helpers.
1735
+ */
1736
+ function countMemoryPrefixBlocksOnContent(content: ContentBlock[]): number {
1737
+ return countMemoryPrefixBlocks(content);
1738
+ }
1739
+
1740
+ /**
1741
+ * Apply one injector block to a `runMessages` array according to its
1742
+ * declared {@link InjectionPlacement}.
1743
+ *
1744
+ * Preserves the byte-for-byte positional semantics of the pre-migration
1745
+ * `inject*` helpers:
1746
+ * - `"prepend-user-tail"` — prepend to the tail user message's content.
1747
+ * - `"append-user-tail"` — append to the tail user message's content.
1748
+ * - `"after-memory-prefix"` — splice immediately after any leading memory
1749
+ * prefix blocks (mirrors `injectPkbContext` / `injectNowScratchpad`).
1750
+ * - `"replace-run-messages"` — replace `runMessages` wholesale with
1751
+ * `block.messagesOverride`.
1752
+ *
1753
+ * Blocks with empty `text` on non-replace placements are no-ops (the
1754
+ * pre-migration branches also short-circuited on empty strings).
1755
+ */
1756
+ function applyInjectionBlock(
1757
+ runMessages: Message[],
1758
+ block: InjectionBlock,
1759
+ ): Message[] {
1760
+ const placement = block.placement ?? DEFAULT_PLACEMENT;
1761
+
1762
+ if (placement === "replace-run-messages") {
1763
+ if (!block.messagesOverride) return runMessages;
1764
+ return block.messagesOverride;
1765
+ }
1766
+
1767
+ if (block.text.length === 0) return runMessages;
1768
+
1769
+ const userTail = runMessages[runMessages.length - 1];
1770
+ if (!userTail || userTail.role !== "user") return runMessages;
1771
+
1772
+ const textBlock = { type: "text" as const, text: block.text };
1773
+
1774
+ switch (placement) {
1775
+ case "prepend-user-tail":
1776
+ return [
1777
+ ...runMessages.slice(0, -1),
1778
+ { ...userTail, content: [textBlock, ...userTail.content] },
1779
+ ];
1780
+ case "append-user-tail":
1781
+ return [
1782
+ ...runMessages.slice(0, -1),
1783
+ { ...userTail, content: [...userTail.content, textBlock] },
1784
+ ];
1785
+ case "after-memory-prefix": {
1786
+ const memoryPrefixCount = countMemoryPrefixBlocksOnContent(
1787
+ userTail.content,
1788
+ );
1789
+ return [
1790
+ ...runMessages.slice(0, -1),
1791
+ {
1792
+ ...userTail,
1793
+ content: [
1794
+ ...userTail.content.slice(0, memoryPrefixCount),
1795
+ textBlock,
1796
+ ...userTail.content.slice(memoryPrefixCount),
1797
+ ],
1798
+ },
1799
+ ];
1800
+ }
1801
+ }
1802
+ }
1803
+
1804
+ /**
1805
+ * Per-turn options accepted by {@link applyRuntimeInjections}.
1806
+ *
1807
+ * Most fields flow through to the per-injector {@link TurnInjectionInputs}
1808
+ * bag attached to the {@link TurnContext} the caller provides (or to an
1809
+ * ephemeral {@link TurnContext} synthesized for test call sites). A small
1810
+ * number of fields drive hardcoded branches that live outside the injector
1811
+ * chain — `activeSurface`, `channelCapabilities`, `channelCommandContext`,
1812
+ * `voiceCallControlPrompt`, `transportHints`, and `isNonInteractive` —
1813
+ * because they are orchestrator-owned content that never made sense as
1814
+ * plugin-overridable default injectors.
1815
+ */
1816
+ export interface RuntimeInjectionOptions {
1817
+ /**
1818
+ * Active dashboard-surface context (read from `<active_workspace>`). Kept
1819
+ * on the options bag rather than an injector because it is a
1820
+ * channel-capability concern that has never been gated as a default
1821
+ * injector.
1822
+ */
1823
+ activeSurface?: ActiveSurfaceContext | null;
1824
+ workspaceTopLevelContext?: string | null;
1825
+ channelCapabilities?: ChannelCapabilities | null;
1826
+ channelCommandContext?: ChannelCommandContext | null;
1827
+ unifiedTurnContext?: string | null;
1828
+ voiceCallControlPrompt?: string | null;
1829
+ pkbContext?: string | null;
1830
+ pkbActive?: boolean;
1831
+ /**
1832
+ * Dense query vector surfaced from the graph memory retriever.
1833
+ * When present together with `pkbActive`, used to run `searchPkbFiles`
1834
+ * to surface relevance hints in the PKB system reminder. When missing,
1835
+ * the reminder falls back to the flat static text.
1836
+ */
1837
+ pkbQueryVector?: number[];
1838
+ /** Optional sparse vector accompanying `pkbQueryVector`. */
1839
+ pkbSparseVector?: QdrantSparseVector;
1840
+ /** Memory scope id used to filter PKB search results. */
1841
+ pkbScopeId?: string;
1842
+ /**
1843
+ * The live conversation (or a minimal shape containing `messages`) used
1844
+ * to compute which PKB paths are already "in context" and therefore
1845
+ * suppressed from hint suggestions.
1846
+ */
1847
+ pkbConversation?: PkbContextConversation;
1848
+ /** Auto-injected PKB filenames (resolved relative to `pkbRoot`). */
1849
+ pkbAutoInjectList?: string[];
1850
+ /** Absolute path to the PKB directory (e.g. `<workspace>/pkb`). */
1851
+ pkbRoot?: string;
1852
+ /**
1853
+ * Working directory against which relative `file_read` tool paths
1854
+ * resolve, used to detect workspace-relative reads like
1855
+ * `pkb/threads.md`. Falls back to `pkbRoot` when omitted.
1856
+ */
1857
+ pkbWorkingDir?: string;
1858
+ nowScratchpad?: string | null;
1859
+ subagentStatusBlock?: string | null;
1860
+ isNonInteractive?: boolean;
1861
+ transportHints?: string[] | null;
1862
+ /**
1863
+ * Pre-rendered Slack chronological transcript that replaces the
1864
+ * default `runMessages` history for any Slack conversation (channels
1865
+ * and DMs alike).
1866
+ *
1867
+ * When `channelCapabilities` describes a Slack conversation and this
1868
+ * array is non-empty, the `slack-messages` default injector emits a
1869
+ * `replace-run-messages` block that swaps `runMessages` with this
1870
+ * transcript. Channel renders include sibling-thread tags; DM renders
1871
+ * are flat (DMs have no threads). The `transportHints` pipeline is
1872
+ * skipped for any Slack conversation so the persisted view isn't
1873
+ * duplicated by gateway-side hints.
1874
+ *
1875
+ * Callers build this via `loadSlackChronologicalMessages` (or the
1876
+ * underlying `assembleSlackChronologicalMessages`) before invoking
1877
+ * this function so the assembly path stays free of direct DB calls
1878
+ * and remains easy to test.
1879
+ */
1880
+ slackChronologicalMessages?: Message[] | null;
1881
+ /**
1882
+ * Pre-rendered `<active_thread>` focus block listing the messages of
1883
+ * the thread the current inbound user message belongs to.
1884
+ *
1885
+ * Appended to the FINAL user message ONLY when `channelCapabilities`
1886
+ * describes a Slack non-DM channel. The block is non-persisted: history
1887
+ * rebuilds re-derive it from storage on each turn, and
1888
+ * `RUNTIME_INJECTION_PREFIXES` strips any `<active_thread>` blocks from
1889
+ * prior turns so they do not accumulate.
1890
+ *
1891
+ * Callers build this via `loadSlackActiveThreadFocusBlock` (or the
1892
+ * underlying `assembleSlackActiveThreadFocusBlock`). Pass `null` /
1893
+ * `undefined` when the inbound is a top-level (non-thread) post.
1894
+ */
1895
+ slackActiveThreadFocusBlock?: string | null;
1896
+ mode?: InjectionMode;
1897
+ /**
1898
+ * Per-turn {@link TurnContext} forwarded to plugin-registered
1899
+ * {@link Injector}s via {@link collectInjectorBlocks}. When omitted,
1900
+ * `applyRuntimeInjections` synthesizes an ephemeral context (with a
1901
+ * fallback `trust` classification) so the default-injector chain still
1902
+ * runs — call sites that build the options bag without holding a full
1903
+ * `TurnContext` get the same chain output.
1904
+ *
1905
+ * When provided, the caller's `trust`, `conversationId`, `turnIndex`,
1906
+ * etc. are preserved; the function layers its per-turn
1907
+ * {@link TurnInjectionInputs} onto a shallow clone so the caller's
1908
+ * `TurnContext` is not mutated.
1909
+ */
1910
+ turnContext?: TurnContext;
1911
+ }
1912
+
1913
+ /**
1914
+ * Build the {@link TurnInjectionInputs} bag from the options bag.
1915
+ *
1916
+ * Exposed so callers that already hold a {@link TurnContext} can layer the
1917
+ * same per-turn inputs onto it before handing control to
1918
+ * {@link collectInjectorBlocks} directly — useful for tests and for the
1919
+ * overflow-reducer reinject path.
1920
+ */
1921
+ export function buildTurnInjectionInputs(
1922
+ options: RuntimeInjectionOptions,
1923
+ ): TurnInjectionInputs {
1924
+ return {
1925
+ mode: options.mode,
1926
+ workspaceTopLevelContext: options.workspaceTopLevelContext,
1927
+ unifiedTurnContext: options.unifiedTurnContext,
1928
+ pkbContext: options.pkbContext,
1929
+ pkbActive: options.pkbActive,
1930
+ pkbQueryVector: options.pkbQueryVector,
1931
+ pkbSparseVector: options.pkbSparseVector,
1932
+ pkbScopeId: options.pkbScopeId,
1933
+ pkbConversation: options.pkbConversation,
1934
+ pkbAutoInjectList: options.pkbAutoInjectList,
1935
+ pkbRoot: options.pkbRoot,
1936
+ pkbWorkingDir: options.pkbWorkingDir,
1937
+ nowScratchpad: options.nowScratchpad,
1938
+ subagentStatusBlock: options.subagentStatusBlock,
1939
+ channelCapabilities: options.channelCapabilities,
1940
+ slackChronologicalMessages: options.slackChronologicalMessages,
1941
+ slackActiveThreadFocusBlock: options.slackActiveThreadFocusBlock,
1942
+ activeSurface: options.activeSurface,
1943
+ channelCommandContext: options.channelCommandContext,
1944
+ voiceCallControlPrompt: options.voiceCallControlPrompt,
1945
+ transportHints: options.transportHints,
1946
+ isNonInteractive: options.isNonInteractive,
1947
+ };
1948
+ }
1949
+
1950
+ /** Minimal synthetic TurnContext used when the caller omits one. */
1951
+ function synthesizeFallbackTurnContext(
1952
+ inputs: TurnInjectionInputs,
1953
+ ): TurnContext {
1954
+ return {
1955
+ requestId: "runtime-assembly-fallback",
1956
+ conversationId: "runtime-assembly-fallback",
1957
+ turnIndex: 0,
1958
+ trust: {
1959
+ sourceChannel: inputs.channelCapabilities?.channel
1960
+ ? (inputs.channelCapabilities.channel as TrustContext["sourceChannel"])
1961
+ : "vellum",
1962
+ trustClass: "unknown",
1963
+ },
1964
+ injectionInputs: inputs,
1965
+ };
1966
+ }
1967
+
1968
+ /**
1969
+ * Apply the runtime-injection chain to `runMessages`.
1970
+ *
1971
+ * The canonical per-turn assembly pipeline for every provider call:
1972
+ *
1973
+ * 1. Build the per-turn {@link TurnInjectionInputs} bag from `options`.
1974
+ * 2. Layer it onto a {@link TurnContext} — either the one the caller
1975
+ * supplies via `options.turnContext` (preserving its `requestId`,
1976
+ * trust, and other fields) or an ephemeral fallback synthesized here.
1977
+ * 3. Drive the default + third-party {@link Injector} chain via
1978
+ * {@link collectInjectorBlocks}.
1979
+ * 4. Apply the chain's `"replace-run-messages"` block (Slack chronological
1980
+ * transcript) first so subsequent branches operate on the replaced
1981
+ * tail. When replacement fires, re-prepend any memory-prefix blocks
1982
+ * that `graphMemory.prepareMemory` had attached to the original tail —
1983
+ * the Slack transcript is rendered fresh from persisted rows and
1984
+ * carries no memory prefix of its own.
1985
+ * 5. Apply the chain's `"after-memory-prefix"` blocks in ascending
1986
+ * `order`. This runs BEFORE step 6's hardcoded prepends so the
1987
+ * memory-prefix counter sees only the memory blocks on the tail —
1988
+ * any `<channel_capabilities>` / `<channel_command_context>` /
1989
+ * `<transport_hints>` prepended first would push the count to zero
1990
+ * and force PKB / NOW to splice at the top of the tail. Within the
1991
+ * after-memory block, each successive splice lands at the memory
1992
+ * boundary, pushing earlier splices further from memory — so
1993
+ * higher-`order` blocks end up closer to the memory prefix.
1994
+ * 6. Run the remaining hardcoded branches (`isNonInteractive`,
1995
+ * `voiceCallControlPrompt`, `activeSurface`, `channelCapabilities`,
1996
+ * `channelCommandContext`, `transportHints`) in their historical order.
1997
+ * 7. Finally, apply the chain's remaining blocks by placement:
1998
+ * `"append-user-tail"` in ascending `order`, then `"prepend-user-tail"`
1999
+ * in descending `order` so the lowest-`order` prepend lands topmost in
2000
+ * the user tail content.
2001
+ *
2002
+ * Returns the final message array plus a `blocks` object holding the exact
2003
+ * injected text for each captured block — callers persist those bytes to
2004
+ * message metadata for later byte-exact rehydration.
1738
2005
  */
1739
2006
  export async function applyRuntimeInjections(
1740
2007
  runMessages: Message[],
1741
- options: {
1742
- activeSurface?: ActiveSurfaceContext | null;
1743
- workspaceTopLevelContext?: string | null;
1744
- channelCapabilities?: ChannelCapabilities | null;
1745
- channelCommandContext?: ChannelCommandContext | null;
1746
- unifiedTurnContext?: string | null;
1747
- voiceCallControlPrompt?: string | null;
1748
- pkbContext?: string | null;
1749
- pkbActive?: boolean;
1750
- /**
1751
- * Dense query vector surfaced from the graph memory retriever (PR 3).
1752
- * When present together with `pkbActive`, used to run `searchPkbFiles`
1753
- * to surface relevance hints in the PKB system reminder. When missing,
1754
- * the reminder falls back to the flat static text.
1755
- */
1756
- pkbQueryVector?: number[];
1757
- /** Optional sparse vector accompanying `pkbQueryVector`. */
1758
- pkbSparseVector?: QdrantSparseVector;
1759
- /** Memory scope id used to filter PKB search results. */
1760
- pkbScopeId?: string;
1761
- /**
1762
- * The live conversation (or a minimal shape containing `messages`) used
1763
- * to compute which PKB paths are already "in context" and therefore
1764
- * suppressed from hint suggestions.
1765
- */
1766
- pkbConversation?: PkbContextConversation;
1767
- /** Auto-injected PKB filenames (resolved relative to `pkbRoot`). */
1768
- pkbAutoInjectList?: string[];
1769
- /** Absolute path to the PKB directory (e.g. `<workspace>/pkb`). */
1770
- pkbRoot?: string;
1771
- /**
1772
- * Working directory against which relative `file_read` tool paths
1773
- * resolve, used to detect workspace-relative reads like
1774
- * `pkb/threads.md`. Falls back to `pkbRoot` when omitted.
1775
- */
1776
- pkbWorkingDir?: string;
1777
- nowScratchpad?: string | null;
1778
- subagentStatusBlock?: string | null;
1779
- isNonInteractive?: boolean;
1780
- transportHints?: string[] | null;
1781
- /**
1782
- * Pre-rendered Slack chronological transcript that replaces the
1783
- * default `runMessages` history for any Slack conversation (channels
1784
- * and DMs alike).
1785
- *
1786
- * When `channelCapabilities` describes a Slack conversation and this
1787
- * array is non-empty, it overrides `runMessages` so the model sees one
1788
- * chronologically-ordered transcript built from the stored Slack
1789
- * metadata. Channel renders include sibling-thread tags; DM renders
1790
- * are flat (DMs have no threads). The `transportHints` pipeline is
1791
- * skipped for any Slack conversation so the persisted view isn't
1792
- * duplicated by gateway-side hints.
1793
- *
1794
- * Callers build this via `loadSlackChronologicalMessages` (or the
1795
- * underlying `assembleSlackChronologicalMessages`) before invoking
1796
- * this function so the assembly path stays free of direct DB calls
1797
- * and remains easy to test.
1798
- */
1799
- slackChronologicalMessages?: Message[] | null;
1800
- /**
1801
- * Pre-rendered `<active_thread>` focus block listing the messages of
1802
- * the thread the current inbound user message belongs to.
1803
- *
1804
- * Appended (tail-block) to the FINAL user message ONLY when
1805
- * `channelCapabilities` describes a Slack non-DM channel. The block is
1806
- * non-persisted: history rebuilds re-derive it from storage on each
1807
- * turn, and `RUNTIME_INJECTION_PREFIXES` strips any `<active_thread>`
1808
- * blocks from prior turns so they do not accumulate.
1809
- *
1810
- * Callers build this via `loadSlackActiveThreadFocusBlock` (or the
1811
- * underlying `assembleSlackActiveThreadFocusBlock`). Pass `null` /
1812
- * `undefined` when the inbound is a top-level (non-thread) post.
1813
- */
1814
- slackActiveThreadFocusBlock?: string | null;
1815
- mode?: InjectionMode;
1816
- },
2008
+ options: RuntimeInjectionOptions,
1817
2009
  ): Promise<RuntimeInjectionResult> {
1818
2010
  const mode = options.mode ?? "full";
1819
- let pkbSystemReminderCaptured: string | undefined;
1820
- const slackChannel = isSlackChannelConversation(options.channelCapabilities);
1821
- // Slack DMs and channels both assemble context from persisted message
1822
- // rows, so suppress hint injection for any Slack conversation. Other
1823
- // channels (telegram, email, etc.) keep the generic hint pipeline.
1824
2011
  const slackConversation = options.channelCapabilities?.channel === "slack";
2012
+
2013
+ // Build the per-injector inputs and attach them to the caller's
2014
+ // TurnContext (without mutating it). When the caller didn't supply one,
2015
+ // synthesize a minimal fallback so the chain still runs — test call sites
2016
+ // that drive injection via `options` without constructing a full context
2017
+ // continue to work.
2018
+ const injectionInputs = buildTurnInjectionInputs(options);
2019
+ const turnCtx: TurnContext = options.turnContext
2020
+ ? { ...options.turnContext, injectionInputs }
2021
+ : synthesizeFallbackTurnContext(injectionInputs);
2022
+
2023
+ const chainBlocks = await collectInjectorBlocks(turnCtx);
2024
+
2025
+ // Split the chain output by placement so the downstream assembly can
2026
+ // process each slot with the correct ordering rule.
2027
+ const prepends: InjectionBlock[] = [];
2028
+ const appends: InjectionBlock[] = [];
2029
+ const afterMemory: InjectionBlock[] = [];
2030
+ let replaceBlock: InjectionBlock | null = null;
2031
+ for (const block of chainBlocks) {
2032
+ switch (block.placement ?? "append-user-tail") {
2033
+ case "replace-run-messages":
2034
+ // Later replace-run-messages blocks would overwrite earlier ones;
2035
+ // the default chain only registers one (the Slack transcript).
2036
+ replaceBlock = block;
2037
+ break;
2038
+ case "after-memory-prefix":
2039
+ afterMemory.push(block);
2040
+ break;
2041
+ case "prepend-user-tail":
2042
+ prepends.push(block);
2043
+ break;
2044
+ case "append-user-tail":
2045
+ appends.push(block);
2046
+ break;
2047
+ }
2048
+ }
2049
+
2050
+ // Track captured text for metadata persistence. Each field corresponds
2051
+ // to a specific default-injector block id so the loop below can pick up
2052
+ // the right capture without re-rendering.
2053
+ //
2054
+ // The capture is gated on the tail actually being a user message — if it
2055
+ // isn't, `applyInjectionBlock` no-ops the block and no content is actually
2056
+ // injected, so the persisted metadata must be undefined (matches
2057
+ // pre-migration behaviour where the `inject*` helpers short-circuited the
2058
+ // same way).
1825
2059
  let turnContextCaptured: string | undefined;
2060
+ let workspaceCaptured: string | undefined;
2061
+ let nowScratchpadCaptured: string | undefined;
2062
+ let pkbContextCaptured: string | undefined;
2063
+ let pkbSystemReminderCaptured: string | undefined;
2064
+ const initialTail = runMessages[runMessages.length - 1];
2065
+ const initialTailIsUser = !!initialTail && initialTail.role === "user";
2066
+ if (initialTailIsUser) {
2067
+ for (const block of chainBlocks) {
2068
+ switch (block.id) {
2069
+ case "unified-turn-context":
2070
+ turnContextCaptured = block.text;
2071
+ break;
2072
+ case "workspace-context":
2073
+ workspaceCaptured = block.text;
2074
+ break;
2075
+ case "now-md":
2076
+ nowScratchpadCaptured = block.text;
2077
+ break;
2078
+ case "pkb-context":
2079
+ pkbContextCaptured = block.text;
2080
+ break;
2081
+ case "pkb-reminder":
2082
+ pkbSystemReminderCaptured = block.text;
2083
+ break;
2084
+ }
2085
+ }
2086
+ }
2087
+
2088
+ // Compose the block text into a single informational string for
2089
+ // `injectorChainBlock`. Matches the pre-migration behaviour where the
2090
+ // field captured the composed view of every third-party injector on
2091
+ // the turn. We include default injectors here too so downstream
2092
+ // observers see the full set.
2093
+ const injectorChainPieces: string[] = [];
2094
+ for (const block of chainBlocks) {
2095
+ if (block.text.length > 0) injectorChainPieces.push(block.text);
2096
+ }
2097
+ const injectorChainBlock =
2098
+ injectorChainPieces.length > 0
2099
+ ? injectorChainPieces.join("\n\n")
2100
+ : undefined;
2101
+
1826
2102
  let result = runMessages;
1827
- // Slack channels AND DMs both override `runMessages` with a pre-rendered
1828
- // chronological transcript built from persisted message rows. The shared
1829
- // assembler (`assembleSlackChronologicalMessages`) renders thread tags
1830
- // for channels and a flat sequence for DMs, so the same branch handles
1831
- // both. The active-thread focus block below stays gated on `slackChannel`
1832
- // since DMs do not have threads.
1833
- if (
1834
- slackConversation &&
1835
- options.slackChronologicalMessages &&
1836
- options.slackChronologicalMessages.length > 0
1837
- ) {
2103
+
2104
+ // ── Step 1: Slack chronological replacement (chain "replace" block) ──
2105
+ if (replaceBlock && replaceBlock.messagesOverride) {
1838
2106
  // `graphMemory.prepareMemory` prepends a `<memory __injected>` block
1839
2107
  // (and any memory-image groups) to the last user message before
1840
2108
  // runtime assembly runs. The Slack transcript is freshly rendered
1841
2109
  // from persisted rows and has no such prefix, so swap it in and then
1842
2110
  // re-prepend the captured prefix onto the new tail user message.
1843
2111
  const carriedMemoryBlocks = extractMemoryPrefixBlocks(runMessages);
1844
- result = options.slackChronologicalMessages;
2112
+ result = replaceBlock.messagesOverride;
1845
2113
  if (carriedMemoryBlocks.length > 0) {
1846
2114
  const slackTail = result[result.length - 1];
1847
2115
  if (slackTail && slackTail.role === "user") {
@@ -1856,6 +2124,25 @@ export async function applyRuntimeInjections(
1856
2124
  }
1857
2125
  }
1858
2126
 
2127
+ // ── Step 2: after-memory-prefix chain blocks ──
2128
+ // These splice relative to the memory-prefix count on the tail content,
2129
+ // so they must run BEFORE the hardcoded prepends in step 3. Otherwise
2130
+ // any prepended `<channel_capabilities>` / `<channel_command_context>` /
2131
+ // `<transport_hints>` (none of which are memory-prefix blocks) would
2132
+ // drop the count to 0 and PKB / NOW would splice at the very top of
2133
+ // the tail instead of immediately after memory.
2134
+ //
2135
+ // Ascending `order`: each splice lands at the memory-prefix boundary,
2136
+ // pushing any previously-spliced block one slot further from memory.
2137
+ // So higher-`order` blocks end up closer to the memory prefix.
2138
+ for (const block of afterMemory) {
2139
+ result = applyInjectionBlock(result, block);
2140
+ }
2141
+
2142
+ // ── Step 3: hardcoded branches that stayed outside the injector chain ──
2143
+ // These run in the same historical order as before G2.1 so their
2144
+ // interleaving with any prior tail content stays stable.
2145
+
1859
2146
  // For non-interactive conversations (scheduled jobs, work items), instruct the
1860
2147
  // model to never ask for clarification — there is no human present to answer.
1861
2148
  if (options.isNonInteractive) {
@@ -1887,117 +2174,6 @@ export async function applyRuntimeInjections(
1887
2174
  }
1888
2175
  }
1889
2176
 
1890
- if (mode === "full" && options.pkbContext) {
1891
- const userTail = result[result.length - 1];
1892
- if (userTail && userTail.role === "user") {
1893
- result = [
1894
- ...result.slice(0, -1),
1895
- injectPkbContext(userTail, options.pkbContext),
1896
- ];
1897
- }
1898
- }
1899
-
1900
- // PKB behavioral nudge — injected on every turn when PKB is active so
1901
- // the model keeps reading topic files and calling `remember`. When a
1902
- // query vector is available from the graph memory retriever, run a
1903
- // hybrid PKB search to surface up to three relevance hints; fall back
1904
- // to the flat static reminder on empty results or any error.
1905
- if (mode === "full" && options.pkbActive) {
1906
- const userTail = result[result.length - 1];
1907
- if (userTail && userTail.role === "user") {
1908
- let hints: string[] = [];
1909
- const queryVector = options.pkbQueryVector;
1910
- if (
1911
- queryVector &&
1912
- queryVector.length > 0 &&
1913
- options.pkbScopeId &&
1914
- options.pkbConversation &&
1915
- options.pkbRoot
1916
- ) {
1917
- try {
1918
- const results = await searchPkbFiles(
1919
- queryVector,
1920
- options.pkbSparseVector,
1921
- 8,
1922
- [options.pkbScopeId],
1923
- );
1924
- const workingDir = options.pkbWorkingDir ?? options.pkbRoot;
1925
- const inContext = getInContextPkbPaths(
1926
- options.pkbConversation,
1927
- options.pkbAutoInjectList ?? [],
1928
- options.pkbRoot,
1929
- workingDir,
1930
- );
1931
- const pkbRoot = options.pkbRoot;
1932
- // Gate on `denseScore` (cosine, [0, 1]) so the quality bar is stable
1933
- // regardless of whether sparse was provided. Rank by `hybridScore`
1934
- // (RRF) when available — that captures the sparse signal for
1935
- // re-ordering eligible hits. hybridScore and denseScore live on
1936
- // different scales, so items with hybridScore are ordered together
1937
- // and placed ahead of items that only have denseScore.
1938
- hints = results
1939
- .filter((r) => {
1940
- const abs = resolve(pkbRoot, r.path);
1941
- if (inContext.has(abs)) return false;
1942
- const threshold = r.path
1943
- .replace(/\\/g, "/")
1944
- .startsWith("archive/")
1945
- ? PKB_HINT_ARCHIVE_THRESHOLD
1946
- : PKB_HINT_THRESHOLD;
1947
- return r.denseScore >= threshold;
1948
- })
1949
- .sort((a, b) => {
1950
- const aHasHybrid = a.hybridScore !== undefined;
1951
- const bHasHybrid = b.hybridScore !== undefined;
1952
- if (aHasHybrid && !bHasHybrid) return -1;
1953
- if (!aHasHybrid && bHasHybrid) return 1;
1954
- if (aHasHybrid && bHasHybrid) {
1955
- return b.hybridScore! - a.hybridScore!;
1956
- }
1957
- return b.denseScore - a.denseScore;
1958
- })
1959
- .slice(0, 3)
1960
- .map((r) => r.path);
1961
- } catch (err) {
1962
- pkbReminderLog.warn(
1963
- { err: err instanceof Error ? err.message : String(err) },
1964
- "PKB hint search failed — falling back to flat reminder",
1965
- );
1966
- hints = [];
1967
- }
1968
- }
1969
-
1970
- const reminder = buildPkbReminder(hints);
1971
- pkbSystemReminderCaptured = reminder;
1972
- // Splice the reminder in right after the memory prefix blocks so it
1973
- // lands above the user's typed text, producing the tail shape
1974
- // `[<turn_context>, <memory __injected>, <system_reminder>, ...your_text, ...later_appends]`
1975
- // after `unifiedTurnContext` later prepends `<turn_context>` at index 0.
1976
- const memoryPrefixCount = countMemoryPrefixBlocks(userTail.content);
1977
- result = [
1978
- ...result.slice(0, -1),
1979
- {
1980
- ...userTail,
1981
- content: [
1982
- ...userTail.content.slice(0, memoryPrefixCount),
1983
- { type: "text" as const, text: reminder },
1984
- ...userTail.content.slice(memoryPrefixCount),
1985
- ],
1986
- },
1987
- ];
1988
- }
1989
- }
1990
-
1991
- if (mode === "full" && options.nowScratchpad) {
1992
- const userTail = result[result.length - 1];
1993
- if (userTail && userTail.role === "user") {
1994
- result = [
1995
- ...result.slice(0, -1),
1996
- injectNowScratchpad(userTail, options.nowScratchpad),
1997
- ];
1998
- }
1999
- }
2000
-
2001
2177
  if (mode === "full" && options.activeSurface) {
2002
2178
  const userTail = result[result.length - 1];
2003
2179
  if (userTail && userTail.role === "user") {
@@ -2028,33 +2204,6 @@ export async function applyRuntimeInjections(
2028
2204
  }
2029
2205
  }
2030
2206
 
2031
- if (mode === "full" && options.subagentStatusBlock) {
2032
- const userTail = result[result.length - 1];
2033
- if (userTail && userTail.role === "user") {
2034
- result = [
2035
- ...result.slice(0, -1),
2036
- injectSubagentStatus(userTail, options.subagentStatusBlock),
2037
- ];
2038
- }
2039
- }
2040
-
2041
- if (options.unifiedTurnContext) {
2042
- const userTail = result[result.length - 1];
2043
- if (userTail && userTail.role === "user") {
2044
- turnContextCaptured = options.unifiedTurnContext;
2045
- result = [
2046
- ...result.slice(0, -1),
2047
- {
2048
- ...userTail,
2049
- content: [
2050
- { type: "text" as const, text: options.unifiedTurnContext },
2051
- ...userTail.content,
2052
- ],
2053
- },
2054
- ];
2055
- }
2056
- }
2057
-
2058
2207
  // Slack conversations (both channels and DMs) build their own
2059
2208
  // chronological transcript from persisted messages and intentionally do
2060
2209
  // not receive the per-turn `<transport_hints>` block — the rendered
@@ -2076,50 +2225,18 @@ export async function applyRuntimeInjections(
2076
2225
  }
2077
2226
  }
2078
2227
 
2079
- // Slack active-thread focus block: when the inbound user message lives
2080
- // inside a thread, append a non-persisted `<active_thread>` tail block
2081
- // listing that thread's parent + replies so the model can orient even
2082
- // when the channel-wide chronological transcript is long and
2083
- // interleaved. Stripped on subsequent rebuilds via the
2084
- // `RUNTIME_INJECTION_PREFIXES` list so focus blocks never accumulate.
2085
- if (
2086
- mode === "full" &&
2087
- slackChannel &&
2088
- typeof options.slackActiveThreadFocusBlock === "string" &&
2089
- options.slackActiveThreadFocusBlock.length > 0
2090
- ) {
2091
- const userTail = result[result.length - 1];
2092
- if (userTail && userTail.role === "user") {
2093
- result = [
2094
- ...result.slice(0, -1),
2095
- {
2096
- ...userTail,
2097
- content: [
2098
- ...userTail.content,
2099
- {
2100
- type: "text" as const,
2101
- text: options.slackActiveThreadFocusBlock,
2102
- },
2103
- ],
2104
- },
2105
- ];
2106
- }
2228
+ // ── Step 4: apply remaining chain blocks by placement ──
2229
+ // append-user-tail: ascending `order` so lower-order blocks come first
2230
+ // in the append sequence.
2231
+ for (const block of appends) {
2232
+ result = applyInjectionBlock(result, block);
2107
2233
  }
2108
2234
 
2109
- // Workspace top-level context is injected last so it appears first
2110
- // (prepended) in the user message content, keeping cache breakpoints
2111
- // anchored to the trailing blocks.
2112
- if (mode === "full" && options.workspaceTopLevelContext) {
2113
- const userTail = result[result.length - 1];
2114
- if (userTail && userTail.role === "user") {
2115
- result = [
2116
- ...result.slice(0, -1),
2117
- injectWorkspaceTopLevelContext(
2118
- userTail,
2119
- options.workspaceTopLevelContext,
2120
- ),
2121
- ];
2122
- }
2235
+ // prepend-user-tail: descending `order` so the lowest-order block lands
2236
+ // topmost in the tail content (each successive prepend pushes the
2237
+ // previous one further down).
2238
+ for (let i = prepends.length - 1; i >= 0; i--) {
2239
+ result = applyInjectionBlock(result, prepends[i]);
2123
2240
  }
2124
2241
 
2125
2242
  return {
@@ -2127,6 +2244,10 @@ export async function applyRuntimeInjections(
2127
2244
  blocks: {
2128
2245
  unifiedTurnContext: turnContextCaptured,
2129
2246
  pkbSystemReminder: pkbSystemReminderCaptured,
2247
+ workspaceBlock: workspaceCaptured,
2248
+ nowScratchpadBlock: nowScratchpadCaptured,
2249
+ pkbContextBlock: pkbContextCaptured,
2250
+ injectorChainBlock,
2130
2251
  },
2131
2252
  };
2132
2253
  }