@vellumai/assistant 0.11.1 → 0.11.2-staging.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (318) hide show
  1. package/Dockerfile +1 -3
  2. package/README.md +1 -1
  3. package/eslint.config.mjs +28 -8
  4. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
  6. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  8. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
  9. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
  10. package/node_modules/@vellumai/service-contracts/package.json +1 -0
  11. package/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
  12. package/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
  13. package/node_modules/@vellumai/slack-text/src/index.test.ts +48 -0
  14. package/node_modules/@vellumai/slack-text/src/index.ts +25 -6
  15. package/openapi.yaml +292 -5
  16. package/package.json +1 -1
  17. package/src/__tests__/agent-loop-resume-interrupted.test.ts +223 -0
  18. package/src/__tests__/byok-default-profile-ensure.test.ts +43 -0
  19. package/src/__tests__/cli-logger-boundary-guard.test.ts +87 -0
  20. package/src/__tests__/compaction-events.test.ts +139 -2
  21. package/src/__tests__/config-loader-backfill.test.ts +35 -3
  22. package/src/__tests__/config-schema.test.ts +1 -0
  23. package/src/__tests__/conversation-agent-loop-fatal-cleanup.test.ts +30 -0
  24. package/src/__tests__/conversation-agent-loop.test.ts +180 -7
  25. package/src/__tests__/conversation-error.test.ts +211 -4
  26. package/src/__tests__/conversation-options-turn-scoped-transport.test.ts +64 -0
  27. package/src/__tests__/conversation-queue.test.ts +126 -0
  28. package/src/__tests__/conversation-retry-route.test.ts +46 -7
  29. package/src/__tests__/conversation-slash.test.ts +4 -2
  30. package/src/__tests__/conversation-summarize-route.test.ts +7 -1
  31. package/src/__tests__/conversation-summarize-up-to.test.ts +71 -5
  32. package/src/__tests__/document-create-dedupe.test.ts +2 -1
  33. package/src/__tests__/document-find-replace.test.ts +2 -1
  34. package/src/__tests__/document-tool-security.test.ts +2 -1
  35. package/src/__tests__/document-update-default-surface.test.ts +2 -1
  36. package/src/__tests__/document-workspace-file.test.ts +467 -0
  37. package/src/__tests__/emit-signal-routing-intent.test.ts +290 -49
  38. package/src/__tests__/guardian-card-withdrawal.test.ts +91 -3
  39. package/src/__tests__/http-user-message-parity.test.ts +66 -0
  40. package/src/__tests__/list-messages-attachments.test.ts +87 -0
  41. package/src/__tests__/list-messages-provider-error.test.ts +143 -0
  42. package/src/__tests__/list-messages-system-card.test.ts +100 -0
  43. package/src/__tests__/llm-resolver.test.ts +19 -9
  44. package/src/__tests__/managed-profile-guard.test.ts +23 -0
  45. package/src/__tests__/notification-platform-adapter.test.ts +130 -2
  46. package/src/__tests__/notification-telegram-adapter.test.ts +6 -0
  47. package/src/__tests__/notification-vellum-adapter.test.ts +45 -0
  48. package/src/__tests__/plugin-api-model-profiles.test.ts +10 -1
  49. package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
  50. package/src/__tests__/provider-error-scenarios.test.ts +140 -0
  51. package/src/__tests__/provider-send-message-override-profile.test.ts +95 -0
  52. package/src/__tests__/run-conversation-turn-persistence.test.ts +66 -3
  53. package/src/__tests__/scripted-turn-metadata-persistence.test.ts +209 -0
  54. package/src/__tests__/skills.test.ts +27 -0
  55. package/src/__tests__/slack-channels-routes.test.ts +0 -2
  56. package/src/__tests__/slack-share-routes.test.ts +0 -3
  57. package/src/__tests__/slack-users-routes.test.ts +0 -2
  58. package/src/__tests__/subagent-tools.test.ts +11 -0
  59. package/src/__tests__/tool-preview-lifecycle.test.ts +58 -0
  60. package/src/__tests__/tool-result-spool.test.ts +5 -4
  61. package/src/__tests__/turn-boundary-resolution.test.ts +53 -0
  62. package/src/__tests__/turn-events-store.test.ts +26 -0
  63. package/src/__tests__/ui-visual-surface.test.ts +695 -0
  64. package/src/__tests__/unified-turn-context-visible-app.test.ts +99 -0
  65. package/src/__tests__/visible-app-context.test.ts +189 -0
  66. package/src/__tests__/workspace-git-service.test.ts +173 -33
  67. package/src/__tests__/workspace-migration-137-repair-retired-fireworks-minimax-model-id.test.ts +157 -0
  68. package/src/__tests__/workspace-migration-138-backfill-home-feed-titles.test.ts +373 -0
  69. package/src/__tests__/workspace-migration-139-clear-renamed-cost-profile-label.test.ts +137 -0
  70. package/src/agent/loop.ts +36 -7
  71. package/src/api/events/context-window-usage.ts +31 -0
  72. package/src/api/events/notification-intent.ts +8 -0
  73. package/src/api/events/ui-surface-pending.ts +35 -0
  74. package/src/api/index.ts +14 -0
  75. package/src/api/responses/conversation-message.ts +25 -4
  76. package/src/api/surfaces.ts +90 -1
  77. package/src/approvals/guardian-card-withdrawal.ts +66 -31
  78. package/src/approvals/guardian-decision-primitive.ts +4 -0
  79. package/src/calls/__tests__/call-setup-router.test.ts +156 -26
  80. package/src/calls/__tests__/voice-session-bridge.test.ts +201 -12
  81. package/src/calls/call-setup-router.ts +80 -36
  82. package/src/calls/voice-session-bridge.ts +173 -26
  83. package/src/cli/commands/inference.help.ts +3 -3
  84. package/src/cli/commands/notifications.help.ts +3 -2
  85. package/src/cli/commands/platform/__tests__/callback-routes-list.test.ts +42 -128
  86. package/src/cli/commands/platform/__tests__/credits.test.ts +9 -78
  87. package/src/cli/commands/platform/__tests__/helpers.ts +90 -0
  88. package/src/cli/commands/platform/__tests__/invoices.test.ts +238 -0
  89. package/src/cli/commands/platform/__tests__/plans.test.ts +9 -88
  90. package/src/cli/commands/platform/__tests__/status.test.ts +12 -87
  91. package/src/cli/commands/platform/__tests__/subscription.test.ts +9 -86
  92. package/src/cli/commands/platform/index.help.ts +92 -0
  93. package/src/cli/commands/platform/index.ts +7 -0
  94. package/src/cli/commands/platform/invoices.ts +132 -0
  95. package/src/cli/commands/usage.help.ts +1 -1
  96. package/src/cli/lib/list-installed-plugins.ts +2 -1
  97. package/src/config/__tests__/default-profile-catalog.test.ts +7 -10
  98. package/src/config/__tests__/deployment-context-defaults.test.ts +27 -5
  99. package/src/config/assistant-feature-flags.ts +7 -2
  100. package/src/config/bundled-skills/app-builder/SKILL.md +3 -2
  101. package/src/config/bundled-skills/subagent/SKILL.md +3 -1
  102. package/src/config/bundled-skills/subagent/TOOLS.json +3 -3
  103. package/src/config/bundled-skills/visualize/SKILL.md +163 -0
  104. package/src/config/call-site-defaults.ts +3 -2
  105. package/src/config/default-profile-catalog.ts +75 -70
  106. package/src/config/default-profile-names.ts +13 -28
  107. package/src/config/env-registry.ts +1 -0
  108. package/src/config/feature-flag-registry.json +16 -0
  109. package/src/config/llm-resolver.ts +21 -5
  110. package/src/config/loader.ts +18 -11
  111. package/src/config/schemas/llm.ts +1 -9
  112. package/src/config/schemas/memory-retrospective.ts +9 -0
  113. package/src/config/schemas/monitoring.ts +28 -2
  114. package/src/config/schemas/workspace-git.ts +15 -0
  115. package/src/config/seed-inference-profiles.ts +19 -5
  116. package/src/context/post-turn-tool-result-truncation.ts +2 -2
  117. package/src/conversations/__tests__/message-consolidation.test.ts +54 -0
  118. package/src/conversations/message-consolidation.ts +17 -15
  119. package/src/daemon/__tests__/turn-tail-assistant-reply-notify.test.ts +182 -0
  120. package/src/daemon/__tests__/turn-tail-deleted-conversation.test.ts +181 -0
  121. package/src/daemon/conversation-agent-loop-handlers.ts +90 -7
  122. package/src/daemon/conversation-agent-loop.ts +107 -22
  123. package/src/daemon/conversation-error.ts +169 -48
  124. package/src/daemon/conversation-messaging.ts +87 -4
  125. package/src/daemon/conversation-process.ts +57 -45
  126. package/src/daemon/conversation-runtime-assembly.ts +57 -0
  127. package/src/daemon/conversation-store.ts +36 -1
  128. package/src/daemon/conversation-surfaces.ts +68 -6
  129. package/src/daemon/conversation-turn-finalize.ts +76 -18
  130. package/src/daemon/conversation.ts +122 -27
  131. package/src/daemon/lifecycle.ts +9 -0
  132. package/src/daemon/message-types/conversations.ts +8 -0
  133. package/src/daemon/message-types/surfaces.ts +3 -0
  134. package/src/documents/document-store.ts +247 -10
  135. package/src/home/__tests__/feed-types.test.ts +43 -8
  136. package/src/home/__tests__/feed-writer.test.ts +59 -0
  137. package/src/home/feed-types.ts +34 -7
  138. package/src/home/feed-writer.ts +15 -6
  139. package/src/live-voice/__tests__/activity-label.test.ts +95 -0
  140. package/src/live-voice/__tests__/live-activity-reporter.test.ts +86 -5
  141. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +310 -25
  142. package/src/live-voice/__tests__/live-voice-events.test.ts +4 -4
  143. package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +8 -4
  144. package/src/live-voice/__tests__/live-voice-vad.test.ts +9 -1
  145. package/src/live-voice/activity-label.ts +169 -0
  146. package/src/live-voice/live-activity-reporter.ts +35 -6
  147. package/src/live-voice/live-voice-session.ts +282 -41
  148. package/src/live-voice/protocol.ts +35 -0
  149. package/src/messaging/providers/slack/__tests__/adapter-mention-rendering.test.ts +84 -2
  150. package/src/messaging/providers/slack/adapter.ts +86 -19
  151. package/src/messaging/providers/slack/api.test.ts +85 -1
  152. package/src/messaging/providers/slack/api.ts +121 -288
  153. package/src/messaging/providers/slack/client.ts +20 -251
  154. package/src/messaging/providers/slack/send.test.ts +4 -9
  155. package/src/messaging/providers/slack/send.ts +1 -1
  156. package/src/messaging/providers/slack/types.ts +5 -0
  157. package/src/messaging/providers/slack/web-api-transport.test.ts +181 -0
  158. package/src/messaging/providers/slack/web-api-transport.ts +367 -0
  159. package/src/messaging/providers/slack/withdraw.ts +6 -14
  160. package/src/messaging/providers/telegram-bot/send.test.ts +31 -1
  161. package/src/messaging/providers/telegram-bot/send.ts +23 -2
  162. package/src/messaging/providers/telegram-bot/withdraw.test.ts +152 -0
  163. package/src/messaging/providers/telegram-bot/withdraw.ts +166 -0
  164. package/src/monitoring/__tests__/db-integrity-sample.test.ts +18 -4
  165. package/src/monitoring/__tests__/file-descriptors.test.ts +144 -0
  166. package/src/monitoring/file-descriptors.ts +262 -0
  167. package/src/monitoring/process-memory.ts +4 -11
  168. package/src/monitoring/resource-sampler.ts +5 -0
  169. package/src/monitoring/worker.ts +12 -0
  170. package/src/notifications/__tests__/assistant-reply-producer.test.ts +740 -0
  171. package/src/notifications/__tests__/broadcaster.test.ts +347 -7
  172. package/src/notifications/__tests__/copy-composer.test.ts +53 -3
  173. package/src/notifications/__tests__/decision-engine.test.ts +349 -29
  174. package/src/notifications/__tests__/deterministic-checks.test.ts +21 -0
  175. package/src/notifications/__tests__/edit-notification.test.ts +330 -0
  176. package/src/notifications/__tests__/guardian-delivery-recorder.test.ts +71 -0
  177. package/src/notifications/__tests__/home-feed-side-effect.test.ts +170 -12
  178. package/src/notifications/adapters/macos.ts +3 -0
  179. package/src/notifications/adapters/platform.ts +90 -14
  180. package/src/notifications/adapters/telegram.ts +6 -4
  181. package/src/notifications/assistant-reply-producer.ts +219 -0
  182. package/src/notifications/broadcaster.ts +523 -317
  183. package/src/notifications/copy-composer.ts +27 -6
  184. package/src/notifications/decision-engine.ts +154 -77
  185. package/src/notifications/deterministic-checks.ts +6 -7
  186. package/src/notifications/edit-notification.ts +8 -4
  187. package/src/notifications/emit-signal.ts +49 -16
  188. package/src/notifications/guardian-delivery-recorder.ts +8 -2
  189. package/src/notifications/home-feed-side-effect.ts +43 -6
  190. package/src/notifications/notification-utils.ts +39 -4
  191. package/src/notifications/signal.ts +10 -0
  192. package/src/notifications/types.ts +17 -0
  193. package/src/persistence/bookmark-crud.ts +18 -9
  194. package/src/persistence/conversation-attention-store.ts +26 -0
  195. package/src/persistence/conversation-crud.ts +79 -32
  196. package/src/persistence/conversation-queries.ts +30 -15
  197. package/src/persistence/conversation-title-service.ts +5 -170
  198. package/src/persistence/conversation-types.ts +192 -1
  199. package/src/persistence/db-init.ts +14 -1
  200. package/src/persistence/db-maintenance.ts +5 -4
  201. package/src/persistence/embeddings/__tests__/plugin-index-qdrant-init.test.ts +219 -0
  202. package/src/persistence/embeddings/__tests__/plugin-index.test.ts +22 -0
  203. package/src/persistence/embeddings/__tests__/worker-script-version.test.ts +76 -0
  204. package/src/persistence/embeddings/embedding-local.ts +2 -1
  205. package/src/persistence/embeddings/embedding-runtime-manager.ts +29 -5
  206. package/src/persistence/embeddings/plugin-index.ts +84 -12
  207. package/src/persistence/migrations/360-add-document-workspace-path.test.ts +110 -0
  208. package/src/persistence/migrations/360-add-document-workspace-path.ts +39 -0
  209. package/src/persistence/planner-statistics.ts +14 -0
  210. package/src/persistence/schema/documents.ts +26 -11
  211. package/src/persistence/steps.ts +2 -0
  212. package/src/platform/client.test.ts +172 -2
  213. package/src/platform/client.ts +178 -55
  214. package/src/plugin-api/conversation-turn.ts +13 -0
  215. package/src/plugin-api/model-profiles.test.ts +6 -2
  216. package/src/plugins/defaults/memory/__tests__/bookmark-crud.test.ts +34 -0
  217. package/src/plugins/defaults/memory/__tests__/conversation-queries.test.ts +22 -0
  218. package/src/plugins/defaults/memory/__tests__/jobs-store-enqueue-gate.test.ts +11 -0
  219. package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +199 -0
  220. package/src/plugins/defaults/memory/__tests__/memory-retrospective-enqueue.test.ts +98 -1
  221. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +105 -1
  222. package/src/plugins/defaults/memory/__tests__/memory-retrospective-sweep.test.ts +24 -2
  223. package/src/plugins/defaults/memory/__tests__/memory-retrospective-wake-chain.test.ts +524 -0
  224. package/src/plugins/defaults/memory/__tests__/memory-tier-boundary-guard.test.ts +1 -1
  225. package/src/plugins/defaults/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +9 -4
  226. package/src/plugins/defaults/memory/host-utils.ts +5 -0
  227. package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +105 -1
  228. package/src/plugins/defaults/memory/memory-retrospective-enqueue.ts +64 -5
  229. package/src/plugins/defaults/memory/memory-retrospective-job.ts +36 -1
  230. package/src/plugins/defaults/memory/memory-retrospective-sweep.ts +12 -3
  231. package/src/plugins/defaults/memory/src/__tests__/memory-v2-simulate-route.test.ts +1 -0
  232. package/src/plugins/defaults/memory/substrate/__tests__/skill-store.test.ts +195 -1
  233. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +1 -1
  234. package/src/plugins/defaults/memory/substrate/skill-store.ts +167 -29
  235. package/src/plugins/defaults/memory/v2/__tests__/injection.test.ts +131 -0
  236. package/src/plugins/defaults/memory/v2/__tests__/router.test.ts +1 -0
  237. package/src/plugins/defaults/memory/v2/activation-log-store.ts +4 -0
  238. package/src/plugins/defaults/memory/v2/injection.ts +48 -3
  239. package/src/plugins/defaults/memory/v2/rerank-local.ts +6 -2
  240. package/src/plugins/defaults/memory/v3/__tests__/pool-select.test.ts +1 -1
  241. package/src/plugins/defaults/platform-hosted/routes/reengage.ts +1 -1
  242. package/src/plugins/defaults/turn-context/injectors.ts +1 -0
  243. package/src/plugins/defaults/turn-context/unified-turn-context.ts +26 -0
  244. package/src/plugins/types.ts +17 -0
  245. package/src/prompts/templates/system-sections.ts +2 -2
  246. package/src/providers/__tests__/dispatch-connection-routing.test.ts +43 -2
  247. package/src/providers/__tests__/vellum-mismatch-routing.test.ts +8 -0
  248. package/src/providers/call-site-routing.ts +77 -14
  249. package/src/providers/connection-resolution.ts +42 -2
  250. package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +127 -1
  251. package/src/providers/inference/adapter-factory.ts +70 -12
  252. package/src/providers/model-catalog.ts +0 -11
  253. package/src/providers/model-intents.ts +11 -0
  254. package/src/providers/registry.ts +8 -0
  255. package/src/providers/retry.ts +85 -17
  256. package/src/providers/types.ts +8 -1
  257. package/src/runtime/__tests__/agent-wake.test.ts +181 -0
  258. package/src/runtime/agent-wake.ts +132 -9
  259. package/src/runtime/channel-approval-types.ts +25 -0
  260. package/src/runtime/routes/__tests__/connection-routes-vs-cli-parity.test.ts +2 -2
  261. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +11 -0
  262. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +121 -9
  263. package/src/runtime/routes/__tests__/platform-invoice-routes.test.ts +392 -0
  264. package/src/runtime/routes/__tests__/slack-channel-routes.test.ts +7 -0
  265. package/src/runtime/routes/__tests__/workspace-commit-routes.test.ts +63 -0
  266. package/src/runtime/routes/consolidation-routes.ts +1 -1
  267. package/src/runtime/routes/conversation-management-routes.ts +18 -6
  268. package/src/runtime/routes/conversation-query-routes.ts +14 -23
  269. package/src/runtime/routes/conversation-routes.ts +113 -8
  270. package/src/runtime/routes/credential-routes.ts +1 -5
  271. package/src/runtime/routes/documents-routes.ts +207 -12
  272. package/src/runtime/routes/identity-routes.ts +3 -93
  273. package/src/runtime/routes/inbound-stages/admission-policy.test.ts +31 -1
  274. package/src/runtime/routes/inbound-stages/admission-policy.ts +18 -0
  275. package/src/runtime/routes/inference-provider-connection-routes.ts +52 -3
  276. package/src/runtime/routes/notification-routes.ts +4 -1
  277. package/src/runtime/routes/platform-routes.ts +238 -3
  278. package/src/runtime/routes/playground/guard.ts +1 -2
  279. package/src/runtime/routes/slack-channel-routes.ts +2 -4
  280. package/src/runtime/routes/workspace-commit-routes.ts +49 -2
  281. package/src/runtime/routes/workspace-routes.ts +11 -12
  282. package/src/runtime/routes/workspace-utils.ts +49 -2
  283. package/src/runtime/services/conversation-serializer.ts +2 -4
  284. package/src/subagent/__tests__/consult-context-gating.test.ts +98 -0
  285. package/src/subagent/__tests__/consult-context-skills.test.ts +82 -0
  286. package/src/subagent/__tests__/consult-context.test.ts +84 -0
  287. package/src/subagent/__tests__/consult-prompt.test.ts +36 -0
  288. package/src/subagent/consult-context.ts +410 -0
  289. package/src/subagent/consult-prompt.ts +38 -5
  290. package/src/subagent/manager.ts +9 -4
  291. package/src/subagent/types.ts +8 -0
  292. package/src/telemetry/telemetry-event-sources.ts +7 -0
  293. package/src/telemetry/telemetry-wire-source.json +1 -1
  294. package/src/telemetry/telemetry-wire.generated.ts +1 -0
  295. package/src/telemetry/turn-events-store.ts +30 -1
  296. package/src/telemetry/types.ts +24 -0
  297. package/src/telemetry/usage-telemetry-reporter.test.ts +48 -0
  298. package/src/tools/browser/pinned-tabs.ts +3 -1
  299. package/src/tools/subagent/spawn.ts +23 -1
  300. package/src/tools/terminal/safe-env.ts +1 -0
  301. package/src/tools/ui-surface/definitions.ts +39 -1
  302. package/src/tools/ui-surface/surface-shape-docs.ts +38 -4
  303. package/src/tools/ui-surface/visual-validation.ts +787 -0
  304. package/src/tools/workflows/run-workflow.test.ts +1 -0
  305. package/src/util/__tests__/short-title.test.ts +229 -0
  306. package/src/util/__tests__/worker-compute.test.ts +65 -0
  307. package/src/util/cgroup-cpu.ts +93 -0
  308. package/src/util/errors.ts +27 -0
  309. package/src/util/process-tree.ts +19 -0
  310. package/src/util/short-title.ts +189 -0
  311. package/src/util/worker-compute.ts +83 -0
  312. package/src/workspace/byok-default-profile-ensure.ts +25 -15
  313. package/src/workspace/git-service.ts +79 -30
  314. package/src/workspace/migrations/137-repair-retired-fireworks-minimax-model-id.ts +131 -0
  315. package/src/workspace/migrations/138-backfill-home-feed-titles.ts +179 -0
  316. package/src/workspace/migrations/139-clear-renamed-cost-profile-label.ts +92 -0
  317. package/src/workspace/migrations/registry.ts +6 -0
  318. package/src/plugins/defaults/memory/substrate/constants.ts +0 -8
@@ -29,11 +29,7 @@ import {
29
29
  discardLastAssistantDisplayTurn,
30
30
  extractUserPromptText,
31
31
  } from "../../daemon/conversation-history.js";
32
- import {
33
- formatSummarizeUpToResult,
34
- isBackgroundEventMetadata,
35
- isEchoSuppressedUserMessage,
36
- } from "../../daemon/conversation-process.js";
32
+ import { formatSummarizeUpToResult } from "../../daemon/conversation-process.js";
37
33
  import { findConversation } from "../../daemon/conversation-registry.js";
38
34
  import {
39
35
  destroyActiveConversation,
@@ -55,6 +51,8 @@ import {
55
51
  deleteConversation,
56
52
  forkConversation as forkConversationInStore,
57
53
  getConversation,
54
+ isBackgroundEventMetadata,
55
+ isEchoSuppressedUserMessage,
58
56
  setConversationEnabledPlugins,
59
57
  setConversationSurfaced,
60
58
  unarchiveConversation,
@@ -280,7 +278,14 @@ async function handleSummarizeConversation({ body = {} }: RouteHandlerArgs) {
280
278
  conversation.emitActivityState("thinking", "context_compacting", {
281
279
  statusText: "Summarizing conversation",
282
280
  });
283
- const result = await conversation.summarizeUpToMessage(beforeMessageId);
281
+ // The context-window usage push goes out on the same broadcast path as
282
+ // the result card below: this route resolves its conversation outside
283
+ // the send path, so the instance may still hold the store's no-op
284
+ // sender and a `sendToClient` emit would reach nobody.
285
+ const result = await conversation.summarizeUpToMessage(
286
+ beforeMessageId,
287
+ broadcastMessage,
288
+ );
284
289
  // Stop aborted the in-flight summary: the compactor reports the aborted
285
290
  // provider call as a non-compacted result (it swallows the abort rather
286
291
  // than throwing), so detect cancellation via the signal. A cancelled
@@ -745,6 +750,13 @@ async function handleRetryLastAssistantTurn({
745
750
  onEvent: broadcastMessage,
746
751
  isUserMessage: true,
747
752
  isInteractive,
753
+ // The re-run carries none of the anchor's original delivery
754
+ // orchestration: a channel anchor's reply is not posted back to
755
+ // Slack/Telegram (that is owned by the inbound event's
756
+ // `finalizeEventDelivery`) and a voice anchor's is not spoken over a
757
+ // session. The regenerated reply reaches SSE subscribers only, so the
758
+ // push is the user's only copy once they leave.
759
+ replyDeliveredInAppOnly: true,
748
760
  ...(isHiddenPrompt ? { isHiddenPrompt: true } : {}),
749
761
  });
750
762
  } catch (err) {
@@ -87,12 +87,15 @@ import {
87
87
  getMessageById,
88
88
  } from "../../persistence/conversation-crud.js";
89
89
  import { getConversationByKey } from "../../persistence/conversation-key-store.js";
90
+ import {
91
+ type ConversationKind,
92
+ resolveConversationKind,
93
+ } from "../../persistence/conversation-types.js";
90
94
  import { getDb } from "../../persistence/db-connection.js";
91
95
  import { clearEmbeddingBackendCache } from "../../persistence/embeddings/embedding-backend.js";
92
96
  import { getLlmRequestLogSource } from "../../persistence/llm-request-log-source.js";
93
97
  import { type LogRow } from "../../persistence/llm-request-log-store.js";
94
98
  import { getMemoryRecallLogByMessageIds } from "../../plugins/defaults/memory/memory-recall-log-store.js";
95
- import { MEMORY_V2_CONSOLIDATION_SOURCE } from "../../plugins/defaults/memory/substrate/constants.js";
96
99
  import { getMemoryV2ActivationLogByMessageIds } from "../../plugins/defaults/memory/v2/activation-log-store.js";
97
100
  import { getMemoryV3SelectionForInspectorByMessageIds } from "../../plugins/defaults/memory/v3/selection-log-store.js";
98
101
  import { ROUTING_IDENTITY_PROVIDERS } from "../../providers/inference/auth.js";
@@ -146,6 +149,7 @@ type LlmContextRouteResult = Omit<LlmContextNormalizationResult, "summary"> & {
146
149
  };
147
150
 
148
151
  import {
152
+ CODE_OWNED_PROFILE_NAMES,
149
153
  getEffectiveProfilesForProvider,
150
154
  INVARIANT_PROFILE_NAMES,
151
155
  MANAGED_PROFILE_NAMES,
@@ -1743,6 +1747,15 @@ async function handleReplaceInferenceProfile({
1743
1747
  `Profile "${name}" is not currently available and cannot be edited.`,
1744
1748
  );
1745
1749
  }
1750
+ if (CODE_OWNED_PROFILE_NAMES.has(name)) {
1751
+ // A code-owned profile resolves from the catalog whatever the workspace
1752
+ // holds, so even a status re-enable would persist a stub that never
1753
+ // governs anything. Reject the write rather than accept a silent no-op.
1754
+ throw new BadRequestError(
1755
+ `Profile "${name}" is code-owned and cannot be edited. ` +
1756
+ `Duplicate it to a custom profile to customize.`,
1757
+ );
1758
+ }
1746
1759
  if (isManaged) {
1747
1760
  // Managed profiles are daemon-seeded and read-only — the commit guard
1748
1761
  // (`assertInvariantProfilesPreserved`) rejects every write to them
@@ -1966,28 +1979,6 @@ function handleGetMessageContent({
1966
1979
  return result;
1967
1980
  }
1968
1981
 
1969
- type ConversationKind =
1970
- | "user"
1971
- | "background"
1972
- | "background_memory_consolidation"
1973
- | "scheduled";
1974
-
1975
- function resolveConversationKind(
1976
- source: string,
1977
- conversationType: string,
1978
- ): ConversationKind {
1979
- if (source === MEMORY_V2_CONSOLIDATION_SOURCE) {
1980
- return "background_memory_consolidation";
1981
- }
1982
- if (conversationType === "background") {
1983
- return "background";
1984
- }
1985
- if (conversationType === "scheduled") {
1986
- return "scheduled";
1987
- }
1988
- return "user";
1989
- }
1990
-
1991
1982
  async function handleGetLlmContext({
1992
1983
  pathParams = {},
1993
1984
  queryParams = {},
@@ -53,7 +53,6 @@ import {
53
53
  buildModelInfoEvent,
54
54
  formatCleanResult,
55
55
  formatCompactResult,
56
- isBackgroundEventMetadata,
57
56
  isModelSlashCommand,
58
57
  } from "../../daemon/conversation-process.js";
59
58
  import { findConversation } from "../../daemon/conversation-registry.js";
@@ -113,8 +112,10 @@ import {
113
112
  getMessages,
114
113
  getMessagesPaginated,
115
114
  hasMessages,
115
+ isBackgroundEventMetadata,
116
116
  isConversationProcessing,
117
117
  isHiddenMessageMetadata,
118
+ isProviderErrorMetadata,
118
119
  isSystemCardMetadata,
119
120
  type MessageRow,
120
121
  recordConversationPersistedSeq,
@@ -212,6 +213,21 @@ interface AlignedAttachments {
212
213
  rewriteContentOrder: ContentOrderRewrite;
213
214
  }
214
215
 
216
+ /**
217
+ * Metadata-only projection of an attachment for inline `contentBlocks`
218
+ * placement. The flat `attachments` array is the payload carrier: it keeps
219
+ * `data`/`thumbnailData`, and `/v1/assistants/:id/attachments/:id/content`
220
+ * serves stored bytes on demand. Attachment blocks are positional references
221
+ * the renderer resolves against that array by id, so inlining the base64 here
222
+ * would ship every image twice in the same response.
223
+ */
224
+ function toAttachmentBlockRef(
225
+ a: RuntimeAttachmentMetadata,
226
+ ): RuntimeAttachmentMetadata {
227
+ const { data: _data, thumbnailData: _thumbnailData, ...meta } = a;
228
+ return meta;
229
+ }
230
+
215
231
  /**
216
232
  * Align DB-hydrated attachment rows with the file-block refs `renderHistoryContent`
217
233
  * captured. When a file block carries an attachment id (user-message uploads —
@@ -339,6 +355,33 @@ function isValidRiskThreshold(value: unknown): value is RiskThreshold {
339
355
  );
340
356
  }
341
357
 
358
+ /**
359
+ * Upper bound on the reported visible-app id. Sized so it can never clip an id
360
+ * the viewer can actually open: a plugin app id is `plugins~<plugin>~<app>`,
361
+ * and each of those two segments is a filesystem directory name bounded at 255
362
+ * bytes, so the longest openable id runs to ~519 characters. The cap exists
363
+ * only to bound what an arbitrary client can park on the conversation, not to
364
+ * validate the id — `resolveAppSource` decides what resolves.
365
+ */
366
+ const VISIBLE_APP_ID_MAX_LENGTH = 640;
367
+
368
+ /**
369
+ * True when the client-reported visible-app id is safe to carry as view state:
370
+ * non-empty, trimmed, bounded, and free of path separators or traversal.
371
+ * Mirrors the app store's own id validation so a malformed id is dropped at
372
+ * ingress instead of reaching a filesystem lookup.
373
+ */
374
+ function isSafeVisibleAppId(value: string): boolean {
375
+ return (
376
+ value.length > 0 &&
377
+ value.length <= VISIBLE_APP_ID_MAX_LENGTH &&
378
+ value === value.trim() &&
379
+ !value.includes("/") &&
380
+ !value.includes("\\") &&
381
+ !value.includes("..")
382
+ );
383
+ }
384
+
342
385
  /**
343
386
  * True when a message's persisted metadata explicitly flags it as hidden.
344
387
  * Used to suppress internal scaffolding messages from UI history while
@@ -890,6 +933,7 @@ export function handleListMessages({
890
933
  let backgroundEventNotification: boolean | undefined;
891
934
  let backgroundToolCompletion: ConversationMessage["backgroundToolCompletion"];
892
935
  let systemCard: boolean | undefined;
936
+ let providerError: ConversationMessage["providerError"];
893
937
  if (msg.metadata) {
894
938
  try {
895
939
  const meta = JSON.parse(msg.metadata);
@@ -901,6 +945,19 @@ export function handleListMessages({
901
945
  if (isSystemCardMetadata(meta)) {
902
946
  systemCard = true;
903
947
  }
948
+ // Daemon-persisted provider-failure notices carry the classified
949
+ // error code/category so clients can render a themed card instead
950
+ // of a persona bubble.
951
+ if (isProviderErrorMetadata(meta)) {
952
+ providerError = {
953
+ ...(typeof meta.providerErrorCode === "string"
954
+ ? { code: meta.providerErrorCode }
955
+ : {}),
956
+ ...(typeof meta.providerErrorCategory === "string"
957
+ ? { category: meta.providerErrorCategory }
958
+ : {}),
959
+ };
960
+ }
904
961
  // Every wake persists a `<background_event source="...">` trigger row
905
962
  // (see `persistWakeTriggerMessage`) that the LLM reads. Flag any such
906
963
  // row so clients hide it from the transcript like a subagent/ACP
@@ -973,6 +1030,7 @@ export function handleListMessages({
973
1030
  backgroundEventNotification,
974
1031
  backgroundToolCompletion,
975
1032
  systemCard,
1033
+ providerError,
976
1034
  slackMessage,
977
1035
  clientMessageId: msg.clientMessageId ?? undefined,
978
1036
  };
@@ -1050,9 +1108,10 @@ export function handleListMessages({
1050
1108
  const attachmentRefs = collectAttachmentRefs(m.content);
1051
1109
  const aligned = alignAttachments(attachmentRefs, msgAttachments);
1052
1110
  msgAttachments = aligned.attachments;
1053
- const attachmentBlocks = attachmentRefs.map(
1054
- (_ref, refIdx) => aligned.refIndexToAttachment.get(refIdx) ?? null,
1055
- );
1111
+ const attachmentBlocks = attachmentRefs.map((_ref, refIdx) => {
1112
+ const att = aligned.refIndexToAttachment.get(refIdx);
1113
+ return att ? toAttachmentBlockRef(att) : null;
1114
+ });
1056
1115
  const rendered = renderHistoryContent(
1057
1116
  m.content,
1058
1117
  attachmentBlocks,
@@ -1129,7 +1188,10 @@ export function handleListMessages({
1129
1188
  );
1130
1189
  for (const att of msgAttachments) {
1131
1190
  if (!existingAttachmentIds.has(att.id)) {
1132
- contentBlocks.push({ type: "attachment", attachment: att });
1191
+ contentBlocks.push({
1192
+ type: "attachment",
1193
+ attachment: toAttachmentBlockRef(att),
1194
+ });
1133
1195
  }
1134
1196
  }
1135
1197
 
@@ -1169,6 +1231,8 @@ export function handleListMessages({
1169
1231
  ...(m.backgroundToolCompletion
1170
1232
  ? { backgroundToolCompletion: m.backgroundToolCompletion }
1171
1233
  : {}),
1234
+ ...(m.systemCard ? { systemCard: true } : {}),
1235
+ ...(m.providerError ? { providerError: m.providerError } : {}),
1172
1236
  ...(m.slackMessage ? { slackMessage: m.slackMessage } : {}),
1173
1237
  };
1174
1238
  });
@@ -1387,11 +1451,16 @@ export async function handleSendMessage(
1387
1451
  // handoff to prime a proactive assistant greeting without showing the
1388
1452
  // triggering user message. Honored on the standard send path only.
1389
1453
  hidden?: boolean;
1454
+ // True when the turn was auto-sent on the user's behalf rather than typed.
1455
+ // Independent of `hidden`: the research prompt is visible AND scripted,
1456
+ // the kickoff greeting is hidden AND scripted. Absent means UNKNOWN.
1457
+ scripted?: boolean;
1390
1458
  bypassSecretCheck?: boolean;
1391
1459
  hostHomeDir?: string;
1392
1460
  hostUsername?: string;
1393
1461
  clientTimezone?: unknown;
1394
1462
  clientOs?: unknown;
1463
+ visibleAppId?: unknown;
1395
1464
  clientId?: string;
1396
1465
  clientMessageId?: string;
1397
1466
  inferenceProfile?: string | null;
@@ -1519,6 +1588,16 @@ export async function handleSendMessage(
1519
1588
  typeof body.clientOs === "string"
1520
1589
  ? (parseClientOs(body.clientOs) ?? undefined)
1521
1590
  : undefined;
1591
+ // App the client has open on screen. Purely view state: it drives the
1592
+ // per-turn `visible_app:` context line and nothing else, so an id that no
1593
+ // longer resolves (deleted app) is dropped silently during assembly rather
1594
+ // than failing the send. Traversal-shaped ids are rejected here so nothing
1595
+ // downstream has to treat the value as a path segment.
1596
+ const visibleAppId =
1597
+ typeof body.visibleAppId === "string" &&
1598
+ isSafeVisibleAppId(body.visibleAppId)
1599
+ ? body.visibleAppId
1600
+ : undefined;
1522
1601
 
1523
1602
  // Reject non-string content values (numbers, objects, etc.)
1524
1603
  if (content != null && typeof content !== "string") {
@@ -1679,12 +1758,14 @@ export async function handleSendMessage(
1679
1758
  hostUsername: body.hostUsername,
1680
1759
  ...(clientTimezone ? { clientTimezone } : {}),
1681
1760
  ...(clientOs ? { clientOs } : {}),
1761
+ ...(visibleAppId ? { visibleAppId } : {}),
1682
1762
  } satisfies HostProxyTransportMetadata)
1683
1763
  : ({
1684
1764
  channelId: sourceChannel,
1685
1765
  interfaceId: sourceInterface,
1686
1766
  ...(clientTimezone ? { clientTimezone } : {}),
1687
1767
  ...(clientOs ? { clientOs } : {}),
1768
+ ...(visibleAppId ? { visibleAppId } : {}),
1688
1769
  } satisfies NonHostProxyTransportMetadata);
1689
1770
 
1690
1771
  const conversation = await smDeps.getOrCreateConversation(
@@ -1878,8 +1959,7 @@ export async function handleSendMessage(
1878
1959
  // real first response. Gated behind the `self-intro-greeting` flag (default
1879
1960
  // off); `undefined` (flag off or no names) falls back to the canned path.
1880
1961
  const selfIntroGreetingEnabled =
1881
- isWakeUp &&
1882
- isAssistantFeatureFlagEnabled(SELF_INTRO_GREETING_FLAG, getConfig());
1962
+ isWakeUp && isAssistantFeatureFlagEnabled(SELF_INTRO_GREETING_FLAG);
1883
1963
  const selfIntro = selfIntroGreetingEnabled
1884
1964
  ? buildSelfIntroMessage(body.onboarding ?? undefined)
1885
1965
  : undefined;
@@ -2090,6 +2170,13 @@ export async function handleSendMessage(
2090
2170
  // hidden send that lands mid-turn stays hidden when drained —
2091
2171
  // the drain path persists this metadata and skips the echo.
2092
2172
  ...(body.hidden === true ? { hidden: true } : {}),
2173
+ // Same reason: the queue round-trips metadata, not persist options,
2174
+ // so a scripted send that lands mid-turn can only keep its marker
2175
+ // this way. Both booleans forwarded, since false is a real assertion
2176
+ // ("the user typed this"), not an absence.
2177
+ ...(typeof body.scripted === "boolean"
2178
+ ? { scripted: body.scripted }
2179
+ : {}),
2093
2180
  },
2094
2181
  clientMetadata,
2095
2182
  ),
@@ -2232,6 +2319,9 @@ export async function handleSendMessage(
2232
2319
  userMessageInterface: sourceInterface,
2233
2320
  assistantMessageInterface: sourceInterface,
2234
2321
  ...(body.automated === true ? { automated: true } : {}),
2322
+ ...(typeof body.scripted === "boolean"
2323
+ ? { scripted: body.scripted }
2324
+ : {}),
2235
2325
  };
2236
2326
  const persisted = await persistQueuedMessageBody(conversation, {
2237
2327
  content: rawContent,
@@ -2376,7 +2466,9 @@ export async function handleSendMessage(
2376
2466
  });
2377
2467
  publishConversationMessagesChanged(conversationId, originClientId);
2378
2468
  conversation.emitActivityState("thinking", "context_compacting");
2379
- const result = await conversation.forceCompact();
2469
+ // Same sink the result card below goes out on, so the indicator and
2470
+ // the card can never be delivered to different places.
2471
+ const result = await conversation.forceCompact(broadcastMessage);
2380
2472
  const cardId = await persistCannedAssistantCard({
2381
2473
  conversation,
2382
2474
  conversationId,
@@ -2497,6 +2589,7 @@ export async function handleSendMessage(
2497
2589
  : undefined,
2498
2590
  clientMetadata,
2499
2591
  ),
2592
+ scripted: body.scripted,
2500
2593
  clientMessageId,
2501
2594
  });
2502
2595
 
@@ -3004,6 +3097,12 @@ export const ROUTES: RouteDefinition[] = [
3004
3097
  .describe(
3005
3098
  'Client OS surface ("web" | "ios" | "macos" | "android"), reported separately from `interface`. Drives the per-turn `client_os` context only; does not affect transport/host-proxy capabilities.',
3006
3099
  ),
3100
+ visibleAppId: z
3101
+ .string()
3102
+ .optional()
3103
+ .describe(
3104
+ 'Id of the app the client currently has open on screen (app viewer or the app-editing split). Drives the per-turn `visible_app:` context line so the assistant can resolve "the app" to what the user is looking at. View state only: it never affects transport, routing, or tool gating, and is omitted whenever no app is in view.',
3105
+ ),
3007
3106
  clientMessageId: z
3008
3107
  .string()
3009
3108
  .describe(
@@ -3031,6 +3130,12 @@ export const ROUTES: RouteDefinition[] = [
3031
3130
  .describe(
3032
3131
  "When true, persist the user message but suppress it from the UI transcript (it stays in LLM-side history and still drives the turn). Used for machine signals the user never typed (proactive-greeting priming, channel-setup wizard close). Suppression covers the queued path too: a hidden send that lands mid-turn returns { queued: true, requestId } but never appears in list-messages queued snapshots, emits no echo, and does not supersede pending interactions. Honored on the standard send path only — slash-command content bypasses it.",
3033
3132
  ),
3133
+ scripted: z
3134
+ .boolean()
3135
+ .optional()
3136
+ .describe(
3137
+ "When true, this turn was auto-sent on the user's behalf rather than typed by them: onboarding research prompts, the personality rewrite message, research corrections, hidden kickoff greetings, the legacy pre-chat bootstrap. Stamped onto the persisted message and forwarded to turn telemetry, where activation metrics exclude it. Send false for a genuine typed message; OMIT the field only if the client genuinely cannot tell, since absent means UNKNOWN and a wrong false is trusted downstream. Independent of `hidden`: a turn can be visible and scripted (the research prompt) or hidden and scripted (the kickoff greeting).",
3138
+ ),
3034
3139
  onboarding: z
3035
3140
  .object({
3036
3141
  tools: z.array(z.string()),
@@ -22,7 +22,6 @@ import {
22
22
  assertAcpCredentialFormat,
23
23
  } from "../../acp/acp-credentials.js";
24
24
  import { isAssistantFeatureFlagEnabled } from "../../config/assistant-feature-flags.js";
25
- import { getConfig } from "../../config/loader.js";
26
25
  import {
27
26
  fetchManagedCatalog,
28
27
  type ManagedCredentialDescriptor,
@@ -319,10 +318,7 @@ async function handleCredentialsReveal({ body, headers }: RouteHandlerArgs) {
319
318
  }
320
319
 
321
320
  const forChat = (body as { forChat?: unknown }).forChat === true;
322
- if (
323
- forChat &&
324
- !isAssistantFeatureFlagEnabled("chat-credential-reveal", getConfig())
325
- ) {
321
+ if (forChat && !isAssistantFeatureFlagEnabled("chat-credential-reveal")) {
326
322
  throw new BadRequestError(
327
323
  "--for-chat requires the chat-credential-reveal feature flag",
328
324
  );
@@ -4,21 +4,37 @@
4
4
  * Exposes document CRUD over HTTP, sharing business logic with the
5
5
  * handlers in `daemon/handlers/documents.ts`.
6
6
  */
7
+ import { randomUUID } from "node:crypto";
8
+ import { readFileSync, statSync } from "node:fs";
9
+ import { basename, extname } from "node:path";
10
+
7
11
  import { z } from "zod";
8
12
 
9
13
  import {
10
14
  addDocumentConversation,
15
+ createFileBackedDocument,
11
16
  getDocumentById,
17
+ getDocumentByWorkspacePath,
12
18
  getDocumentsForConversation,
19
+ refreshDocumentContentFromFile,
13
20
  saveDocument,
14
21
  } from "../../documents/document-store.js";
15
22
  import { rawAll } from "../../persistence/raw-query.js";
16
23
  import { getLogger } from "../../util/logger.js";
17
24
  import { ACTOR_PRINCIPALS } from "../auth/route-policy.js";
18
25
  import { renderMarkdownToPDF } from "./document-pdf-renderer.js";
19
- import { BadRequestError, InternalError, NotFoundError } from "./errors.js";
20
- import type { RouteDefinition } from "./types.js";
26
+ import {
27
+ BadRequestError,
28
+ InternalError,
29
+ NotFoundError,
30
+ UnprocessableEntityError,
31
+ } from "./errors.js";
32
+ import type { RouteDefinition, RouteHandlerArgs } from "./types.js";
21
33
  import { RouteResponse } from "./types.js";
34
+ import {
35
+ resolveWorkspacePath,
36
+ toWorkspaceRelativePath,
37
+ } from "./workspace-utils.js";
22
38
 
23
39
  const log = getLogger("documents-routes");
24
40
 
@@ -64,6 +80,164 @@ function listAllDocuments(): Array<{
64
80
  }
65
81
  }
66
82
 
83
+ // ---------------------------------------------------------------------------
84
+ // File-backed documents
85
+ // ---------------------------------------------------------------------------
86
+
87
+ /** Extensions the document editor round-trips faithfully as markdown. */
88
+ const MARKDOWN_EXTENSIONS = new Set([".md", ".markdown"]);
89
+
90
+ /** The document payload shape shared by `GET documents/{id}` and its siblings. */
91
+ const documentPayloadSchema = z.object({
92
+ success: z.boolean(),
93
+ surfaceId: z.string(),
94
+ conversationId: z.string(),
95
+ title: z.string(),
96
+ content: z.string(),
97
+ wordCount: z.number(),
98
+ createdAt: z.number(),
99
+ updatedAt: z.number(),
100
+ workspacePath: z
101
+ .string()
102
+ .nullable()
103
+ .describe("Workspace-relative file this document is bound to, or null"),
104
+ });
105
+
106
+ /**
107
+ * Resolve a client-supplied workspace path to the markdown file it names.
108
+ *
109
+ * Uses the same resolution as the workspace file routes, so a path that
110
+ * escapes the workspace (traversal, absolute, symlinked out, dot-segment) is
111
+ * rejected here exactly as it would be there.
112
+ */
113
+ function resolveWorkspaceMarkdownFile(path: string): {
114
+ absolutePath: string;
115
+ relativePath: string;
116
+ } {
117
+ const resolved = resolveWorkspacePath(path);
118
+ if (resolved === undefined) {
119
+ throw new BadRequestError("Invalid path");
120
+ }
121
+
122
+ if (!MARKDOWN_EXTENSIONS.has(extname(resolved).toLowerCase())) {
123
+ throw new UnprocessableEntityError(
124
+ "Only markdown files (.md, .markdown) can back a document",
125
+ );
126
+ }
127
+
128
+ return {
129
+ absolutePath: resolved,
130
+ relativePath: toWorkspaceRelativePath(resolved),
131
+ };
132
+ }
133
+
134
+ /** Current text of a workspace file, or `null` when it is gone or not a file. */
135
+ function readWorkspaceFileText(absolutePath: string): string | null {
136
+ try {
137
+ if (!statSync(absolutePath).isFile()) {
138
+ return null;
139
+ }
140
+ return readFileSync(absolutePath, "utf-8");
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+
146
+ /** Load a document by surface ID for a response, or fail loudly if it vanished. */
147
+ function loadDocumentPayload(surfaceId: string): Record<string, unknown> {
148
+ const doc = getDocumentById(surfaceId);
149
+ if (!doc) {
150
+ throw new InternalError("Document could not be loaded after persisting");
151
+ }
152
+ return { success: true, ...doc };
153
+ }
154
+
155
+ /**
156
+ * Find or create the document bound to a workspace markdown file.
157
+ *
158
+ * The file is the source of truth at open time: when a bound document's stored
159
+ * content has drifted from the bytes on disk (the file was edited outside the
160
+ * editor), the row is refreshed from the file before it is returned. Writes go
161
+ * the other way — the document store writes through to the file.
162
+ */
163
+ function handleDocumentForWorkspaceFile({
164
+ body,
165
+ }: RouteHandlerArgs): Record<string, unknown> {
166
+ const { path, conversationId } = (body ?? {}) as {
167
+ path?: string;
168
+ conversationId?: string;
169
+ };
170
+
171
+ if (!path || typeof path !== "string") {
172
+ throw new BadRequestError("path is required");
173
+ }
174
+ if (!conversationId || typeof conversationId !== "string") {
175
+ throw new BadRequestError("conversationId is required");
176
+ }
177
+
178
+ const { absolutePath, relativePath } = resolveWorkspaceMarkdownFile(path);
179
+ const existing = getDocumentByWorkspacePath(relativePath);
180
+ const fileText = readWorkspaceFileText(absolutePath);
181
+
182
+ if (fileText === null) {
183
+ // A bound document whose file is gone is reported distinctly: the client
184
+ // needs to tell "this file was deleted" apart from "no such file", and the
185
+ // document must never be used to resurrect the file.
186
+ if (existing) {
187
+ throw new NotFoundError(
188
+ `The file backing this document no longer exists: ${relativePath}`,
189
+ );
190
+ }
191
+ throw new NotFoundError("File not found");
192
+ }
193
+
194
+ if (existing) {
195
+ // Opening the file from another conversation grants that conversation
196
+ // access, the same association the link route writes.
197
+ addDocumentConversation(existing.surfaceId, conversationId);
198
+
199
+ if (existing.content !== fileText) {
200
+ const refresh = refreshDocumentContentFromFile(
201
+ existing.surfaceId,
202
+ fileText,
203
+ );
204
+ if (!refresh.success) {
205
+ throw new InternalError(refresh.error);
206
+ }
207
+ log.info(
208
+ { surfaceId: existing.surfaceId, workspacePath: relativePath },
209
+ "Refreshed file-backed document from disk",
210
+ );
211
+ }
212
+ return loadDocumentPayload(existing.surfaceId);
213
+ }
214
+
215
+ const surfaceId = `doc-${randomUUID()}`;
216
+ const created = createFileBackedDocument({
217
+ surfaceId,
218
+ conversationId,
219
+ title: basename(relativePath),
220
+ content: fileText,
221
+ workspacePath: relativePath,
222
+ });
223
+
224
+ if (!created.success) {
225
+ // A concurrent request may have won the unique index on workspace_path.
226
+ const raced = getDocumentByWorkspacePath(relativePath);
227
+ if (!raced) {
228
+ throw new InternalError(created.error);
229
+ }
230
+ addDocumentConversation(raced.surfaceId, conversationId);
231
+ return loadDocumentPayload(raced.surfaceId);
232
+ }
233
+
234
+ log.info(
235
+ { surfaceId, workspacePath: relativePath, conversationId },
236
+ "Bound workspace file to a new document",
237
+ );
238
+ return loadDocumentPayload(surfaceId);
239
+ }
240
+
67
241
  // ---------------------------------------------------------------------------
68
242
  // Route definitions
69
243
  // ---------------------------------------------------------------------------
@@ -119,16 +293,7 @@ export const ROUTES: RouteDefinition[] = [
119
293
  summary: "Get a document",
120
294
  description: "Return a single document by surface ID.",
121
295
  tags: ["documents"],
122
- responseBody: z.object({
123
- success: z.boolean(),
124
- surfaceId: z.string(),
125
- conversationId: z.string(),
126
- title: z.string(),
127
- content: z.string(),
128
- wordCount: z.number(),
129
- createdAt: z.number(),
130
- updatedAt: z.number(),
131
- }),
296
+ responseBody: documentPayloadSchema,
132
297
  handler: ({ pathParams }) => {
133
298
  const doc = getDocumentById(pathParams!.id);
134
299
  if (!doc) {
@@ -201,6 +366,36 @@ export const ROUTES: RouteDefinition[] = [
201
366
  },
202
367
  },
203
368
 
369
+ {
370
+ operationId: "documentForWorkspaceFile",
371
+ endpoint: "documents/for-workspace-file",
372
+ method: "POST",
373
+ policy: {
374
+ requiredScopes: ["settings.write"],
375
+ allowedPrincipalTypes: ACTOR_PRINCIPALS,
376
+ },
377
+ summary: "Get or create the document for a workspace markdown file",
378
+ description:
379
+ "Return the document bound to a workspace markdown file, creating it on " +
380
+ "first open. The file is the source of truth at open time: stored " +
381
+ "content that has drifted from the file is refreshed from disk before " +
382
+ "the document is returned.",
383
+ tags: ["documents"],
384
+ requestBody: z.object({
385
+ path: z
386
+ .string()
387
+ .describe("Workspace-relative path to a .md/.markdown file"),
388
+ conversationId: z
389
+ .string()
390
+ .describe("Conversation the document is opened from"),
391
+ }),
392
+ responseBody: documentPayloadSchema,
393
+ additionalResponses: {
394
+ "422": { description: "Path is not a markdown file" },
395
+ },
396
+ handler: handleDocumentForWorkspaceFile,
397
+ },
398
+
204
399
  {
205
400
  operationId: "linkDocumentConversation",
206
401
  endpoint: "documents/:id/conversations",