@vellumai/assistant 0.12.5 → 0.12.6-dev.202609292116.41f7caa

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 (295) hide show
  1. package/.env.example +2 -0
  2. package/ARCHITECTURE.md +26 -3
  3. package/docs/automatic-done.md +32 -0
  4. package/docs/desktop-browser-cli.md +3 -2
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  6. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/mcp-oauth.test.ts +53 -0
  7. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/mcp-oauth.ts +60 -0
  9. package/node_modules/@vellumai/environments/src/shell.test.ts +13 -1
  10. package/node_modules/@vellumai/environments/src/shell.ts +22 -1
  11. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  12. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/mcp-oauth.test.ts +53 -0
  13. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  14. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/mcp-oauth.ts +60 -0
  15. package/node_modules/@vellumai/gateway-client/src/__tests__/status-contracts.test.ts +39 -0
  16. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +25 -0
  17. package/node_modules/@vellumai/service-contracts/package.json +2 -1
  18. package/node_modules/@vellumai/service-contracts/src/__tests__/mcp-oauth.test.ts +53 -0
  19. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  20. package/node_modules/@vellumai/service-contracts/src/mcp-oauth.ts +60 -0
  21. package/openapi.yaml +270 -10
  22. package/package.json +1 -1
  23. package/src/__tests__/agent-loop.test.ts +76 -0
  24. package/src/__tests__/anthropic-provider.test.ts +2 -0
  25. package/src/__tests__/byok-default-profile-ensure.test.ts +7 -0
  26. package/src/__tests__/config-get-vision-flag.test.ts +1 -1
  27. package/src/__tests__/config-loader-validation-reset-notice.test.ts +2 -2
  28. package/src/__tests__/conversation-agent-loop-overflow.test.ts +160 -154
  29. package/src/__tests__/conversation-evictor.test.ts +54 -0
  30. package/src/__tests__/conversation-pairing.test.ts +112 -0
  31. package/src/__tests__/document-append-idempotency.test.ts +13 -0
  32. package/src/__tests__/document-create-dedupe.test.ts +13 -0
  33. package/src/__tests__/document-find-replace.test.ts +13 -0
  34. package/src/__tests__/document-revision-history.test.ts +390 -0
  35. package/src/__tests__/document-revision.test.ts +425 -0
  36. package/src/__tests__/document-tool-security.test.ts +13 -0
  37. package/src/__tests__/document-update-default-surface.test.ts +13 -0
  38. package/src/__tests__/emit-signal-routing-intent.test.ts +270 -0
  39. package/src/__tests__/gateway-flag-listener.test.ts +59 -6
  40. package/src/__tests__/headless-browser-navigate.test.ts +20 -30
  41. package/src/__tests__/host-cu-proxy.test.ts +79 -0
  42. package/src/__tests__/host-cu-routes-targeted.test.ts +14 -5
  43. package/src/__tests__/host-shell-tool.test.ts +6 -2
  44. package/src/__tests__/identity-routes.test.ts +4 -3
  45. package/src/__tests__/list-messages-client-message-id.test.ts +43 -0
  46. package/src/__tests__/llm-catalog-parity.test.ts +4 -4
  47. package/src/__tests__/llm-schema.test.ts +1 -1
  48. package/src/__tests__/notification-platform-adapter.test.ts +83 -1
  49. package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
  50. package/src/__tests__/openai-provider.test.ts +38 -11
  51. package/src/__tests__/pricing.test.ts +11 -0
  52. package/src/__tests__/public-ingress-urls.test.ts +26 -0
  53. package/src/__tests__/retry-thinking-tool-choice.test.ts +47 -0
  54. package/src/__tests__/skill-execute-input.test.ts +11 -0
  55. package/src/__tests__/tool-approval-handler.test.ts +4 -0
  56. package/src/__tests__/tool-preview-lifecycle.test.ts +26 -0
  57. package/src/__tests__/usage-attribution.test.ts +31 -0
  58. package/src/__tests__/workspace-migration-133-collapse-provider-connections.test.ts +3 -3
  59. package/src/__tests__/workspace-migration-160-repair-retired-fireworks-model-ids.test.ts +262 -0
  60. package/src/__tests__/workspace-migration-161-repair-retired-fireworks-glm-5p2-model-id.test.ts +233 -0
  61. package/src/acp/agent-process.ts +6 -1
  62. package/src/acp/client-handler.ts +6 -1
  63. package/src/agent/loop-unparseable-tool-retry.test.ts +333 -0
  64. package/src/agent/loop.ts +161 -17
  65. package/src/api/events/document-editor-show.ts +5 -0
  66. package/src/api/events/document-editor-update.ts +10 -0
  67. package/src/api/events/tool-result.ts +1 -0
  68. package/src/calls/__tests__/call-controller.test.ts +273 -1
  69. package/src/calls/__tests__/media-stream-server-integration.test.ts +40 -0
  70. package/src/calls/__tests__/media-stream-stt-session.test.ts +33 -0
  71. package/src/calls/__tests__/voice-session-bridge.test.ts +54 -0
  72. package/src/calls/call-constants.ts +10 -0
  73. package/src/calls/call-controller.ts +359 -25
  74. package/src/calls/channel-admission-reader.ts +15 -9
  75. package/src/calls/media-stream-server.ts +18 -0
  76. package/src/calls/media-stream-stt-session.ts +30 -9
  77. package/src/calls/twilio-provider.ts +14 -31
  78. package/src/calls/voice-session-bridge.ts +18 -1
  79. package/src/cli/commands/browser.ts +1 -2
  80. package/src/cli/lib/__tests__/plugin-artifact.test.ts +23 -0
  81. package/src/cli/lib/__tests__/plugin-catalog-visibility.test.ts +2 -0
  82. package/src/cli/lib/__tests__/plugin-details.test.ts +16 -0
  83. package/src/cli/lib/bundled-marketplace.json +54 -2
  84. package/src/cli/lib/inspect-plugin.ts +15 -15
  85. package/src/cli/lib/list-installed-plugins.ts +29 -28
  86. package/src/cli/lib/plugin-artifact.ts +74 -63
  87. package/src/cli/lib/plugin-catalog-visibility.ts +1 -0
  88. package/src/cli/lib/plugin-details.ts +27 -42
  89. package/src/cli/lib/plugin-marketplace.ts +6 -8
  90. package/src/cli/lib/plugin-surfaces.ts +8 -7
  91. package/src/cli.ts +1 -3
  92. package/src/config/__tests__/chat-settings.test.ts +121 -0
  93. package/src/config/assistant-feature-flags.ts +28 -23
  94. package/src/config/bundled-skills/computer-use/SKILL.md +20 -0
  95. package/src/config/bundled-skills/computer-use/TOOLS.json +11 -11
  96. package/src/config/chat-settings.ts +100 -0
  97. package/src/config/default-profile-catalog.ts +1 -1
  98. package/src/config/feature-flag-cache.ts +7 -25
  99. package/src/config/feature-flag-registry.json +9 -9
  100. package/src/config/loader.ts +5 -1
  101. package/src/config/profile-text-generation.ts +25 -21
  102. package/src/config/schemas/conversations.ts +14 -0
  103. package/src/config/schemas/llm.ts +1 -0
  104. package/src/config/schemas/notifications.ts +8 -1
  105. package/src/contacts/gateway-channel-read.ts +1 -1
  106. package/src/conversations/__tests__/auto-archive-activity.test.ts +136 -0
  107. package/src/conversations/__tests__/auto-archive.test.ts +304 -0
  108. package/src/conversations/auto-archive-activity.ts +61 -0
  109. package/src/conversations/auto-archive.ts +201 -0
  110. package/src/daemon/computer-use-observation.test.ts +81 -0
  111. package/src/daemon/computer-use-observation.ts +23 -0
  112. package/src/daemon/conversation-agent-loop-handlers.ts +18 -2
  113. package/src/daemon/conversation-agent-loop.ts +14 -6
  114. package/src/daemon/conversation-evictor.ts +50 -16
  115. package/src/daemon/conversation-messaging.ts +4 -0
  116. package/src/daemon/conversation-process.ts +15 -0
  117. package/src/daemon/conversation-queue-manager.ts +2 -0
  118. package/src/daemon/conversation.ts +2 -0
  119. package/src/daemon/doordash-steps.ts +1 -3
  120. package/src/daemon/handlers/__tests__/config-a2a-accept.test.ts +43 -1
  121. package/src/daemon/handlers/__tests__/config-channels.test.ts +1 -1
  122. package/src/daemon/handlers/config-a2a.ts +75 -2
  123. package/src/daemon/handlers/shared.ts +13 -2
  124. package/src/daemon/host-cu-proxy.ts +34 -3
  125. package/src/daemon/lifecycle.ts +15 -2
  126. package/src/daemon/process-message.ts +5 -0
  127. package/src/daemon/shutdown-handlers.ts +3 -0
  128. package/src/daemon/turn-finalization.ts +4 -0
  129. package/src/desktop/desktop-browser-endpoint.ts +11 -2
  130. package/src/desktop/desktop-chrome-policy.test.ts +1 -0
  131. package/src/desktop/desktop-chrome-policy.ts +7 -1
  132. package/src/desktop/desktop-computer-use.test.ts +14 -4
  133. package/src/desktop/desktop-session-manager.ts +5 -3
  134. package/src/documents/document-revisions-store.ts +153 -0
  135. package/src/documents/document-store.ts +471 -133
  136. package/src/followups/followup-store.ts +0 -4
  137. package/src/i18n/__tests__/i18n.test.ts +25 -0
  138. package/src/i18n/assistant-text.ts +18 -0
  139. package/src/i18n/index.ts +1 -0
  140. package/src/i18n/messages.ts +11 -0
  141. package/src/inbound/__tests__/oauth-callback-url.test.ts +25 -2
  142. package/src/inbound/oauth-callback-url.ts +30 -1
  143. package/src/inbound/public-ingress-urls.ts +12 -2
  144. package/src/ipc/gateway-client.test.ts +40 -0
  145. package/src/ipc/gateway-client.ts +12 -29
  146. package/src/ipc/gateway-flag-listener.ts +7 -10
  147. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +71 -0
  148. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +83 -0
  149. package/src/live-voice/__tests__/protocol.test.ts +77 -0
  150. package/src/live-voice/__tests__/share-targets.test.ts +163 -0
  151. package/src/live-voice/live-voice-session.ts +23 -6
  152. package/src/live-voice/protocol.ts +22 -0
  153. package/src/live-voice/share-targets.ts +225 -0
  154. package/src/mcp/__tests__/mcp-oauth-client-registration.test.ts +144 -3
  155. package/src/mcp/__tests__/mcp-oauth-provider.test.ts +2 -0
  156. package/src/mcp/client.ts +1 -19
  157. package/src/mcp/mcp-oauth-provider.ts +66 -49
  158. package/src/media/openrouter-image-service.ts +39 -22
  159. package/src/monitoring/__tests__/oom-inheritance-sweep.test.ts +86 -0
  160. package/src/monitoring/__tests__/oom-kill-reporter.test.ts +374 -0
  161. package/src/monitoring/control.ts +2 -1
  162. package/src/monitoring/oom-inheritance-sweep.ts +96 -0
  163. package/src/monitoring/oom-kill-reporter.ts +469 -0
  164. package/src/monitoring/resource-sampler.ts +30 -0
  165. package/src/notifications/README.md +53 -4
  166. package/src/notifications/__tests__/assistant-initiated-thread.test.ts +254 -0
  167. package/src/notifications/__tests__/broadcaster.test.ts +558 -6
  168. package/src/notifications/__tests__/completion-policy.test.ts +37 -0
  169. package/src/notifications/__tests__/from-me-alert-integration.test.ts +192 -0
  170. package/src/notifications/adapters/platform.ts +14 -0
  171. package/src/notifications/assistant-initiated-thread.ts +101 -0
  172. package/src/notifications/broadcaster.ts +94 -16
  173. package/src/notifications/chat-reply-policy.ts +11 -0
  174. package/src/notifications/completion-policy.ts +12 -2
  175. package/src/notifications/conversation-pairing.ts +15 -70
  176. package/src/notifications/emit-signal.ts +34 -3
  177. package/src/notifications/types.ts +2 -0
  178. package/src/oauth/connect-orchestrator.ts +1 -2
  179. package/src/persistence/__tests__/conversation-auto-archive.test.ts +330 -0
  180. package/src/persistence/conversation-auto-archive.ts +170 -0
  181. package/src/persistence/conversation-crud.ts +2 -1
  182. package/src/persistence/conversation-queries.ts +15 -8
  183. package/src/persistence/embeddings/embed.test.ts +96 -0
  184. package/src/persistence/embeddings/embed.ts +20 -2
  185. package/src/persistence/embeddings/embedding-gemini.test.ts +39 -3
  186. package/src/persistence/embeddings/embedding-gemini.ts +39 -1
  187. package/src/persistence/embeddings/embedding-local.ts +6 -0
  188. package/src/persistence/embeddings/qdrant-manager.ts +5 -0
  189. package/src/persistence/job-utils.ts +10 -0
  190. package/src/persistence/migrations/384-add-document-revision.test.ts +83 -0
  191. package/src/persistence/migrations/384-add-document-revision.ts +21 -0
  192. package/src/persistence/migrations/385-create-document-revisions.test.ts +67 -0
  193. package/src/persistence/migrations/385-create-document-revisions.ts +28 -0
  194. package/src/persistence/migrations/386-conversations-last-reopened-at.ts +10 -0
  195. package/src/persistence/migrations/__tests__/386-conversations-last-reopened-at.test.ts +32 -0
  196. package/src/persistence/schema/conversations.ts +1 -0
  197. package/src/persistence/schema/documents.ts +20 -0
  198. package/src/persistence/steps.ts +6 -0
  199. package/src/platform/managed-speech.ts +42 -46
  200. package/src/plugin-api/vision-support.test.ts +21 -21
  201. package/src/plugins/defaults/empty-response/hooks/post-model-call.ts +11 -6
  202. package/src/providers/__tests__/dispatch-connection-routing.test.ts +1 -1
  203. package/src/providers/__tests__/search-provider-catalog.test.ts +8 -0
  204. package/src/providers/__tests__/unparseable-tool-args.test.ts +25 -0
  205. package/src/providers/anthropic/client.ts +1 -1
  206. package/src/providers/fireworks/client.test.ts +168 -0
  207. package/src/providers/fireworks/client.ts +24 -0
  208. package/src/providers/inference/adapter-factory.ts +6 -0
  209. package/src/providers/ionet/client.test.ts +143 -0
  210. package/src/providers/ionet/client.ts +37 -0
  211. package/src/providers/model-catalog.ts +141 -84
  212. package/src/providers/model-intents.ts +4 -4
  213. package/src/providers/openai/chat-completions-provider.ts +33 -2
  214. package/src/providers/retry.ts +56 -4
  215. package/src/providers/search-provider-catalog.ts +10 -0
  216. package/src/providers/types.ts +10 -2
  217. package/src/providers/unparseable-tool-args.ts +29 -5
  218. package/src/providers/usage-tracking.ts +5 -3
  219. package/src/runtime/assistant-event-hub.ts +23 -0
  220. package/src/runtime/auth/__tests__/guard-tests.test.ts +0 -7
  221. package/src/runtime/auth/__tests__/route-policy.test.ts +1 -1
  222. package/src/runtime/channel-reply-delivery.ts +2 -0
  223. package/src/runtime/channel-retry-sweep.ts +1 -2
  224. package/src/runtime/http-types.ts +3 -0
  225. package/src/runtime/migrations/__tests__/vbundle-streaming-validator.test.ts +35 -10
  226. package/src/runtime/migrations/__tests__/vbundle-validator-v1-schema.test.ts +48 -0
  227. package/src/runtime/migrations/vbundle-builder.ts +19 -3
  228. package/src/runtime/migrations/vbundle-streaming-importer.ts +14 -19
  229. package/src/runtime/migrations/vbundle-streaming-validator.ts +14 -7
  230. package/src/runtime/migrations/vbundle-validator.ts +54 -1
  231. package/src/runtime/pending-interactions.ts +20 -2
  232. package/src/runtime/routes/__tests__/contact-routes.test.ts +5 -3
  233. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +36 -0
  234. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +375 -2
  235. package/src/runtime/routes/__tests__/inference-send-routes.test.ts +1 -1
  236. package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +12 -0
  237. package/src/runtime/routes/contact-routes.ts +2 -2
  238. package/src/runtime/routes/conversation-list-routes.ts +10 -2
  239. package/src/runtime/routes/conversation-query-routes.ts +48 -10
  240. package/src/runtime/routes/conversation-routes.ts +7 -0
  241. package/src/runtime/routes/documents-routes.ts +185 -47
  242. package/src/runtime/routes/events-routes.ts +1 -3
  243. package/src/runtime/routes/host-app-control-routes.ts +1 -0
  244. package/src/runtime/routes/host-bash-routes.ts +1 -0
  245. package/src/runtime/routes/host-browser-routes.ts +15 -0
  246. package/src/runtime/routes/host-cu-routes.ts +4 -1
  247. package/src/runtime/routes/host-file-routes.ts +1 -0
  248. package/src/runtime/routes/host-proxy-result-binding.test.ts +83 -0
  249. package/src/runtime/routes/host-proxy-result-binding.ts +25 -0
  250. package/src/runtime/routes/host-transfer-routes.ts +1 -0
  251. package/src/runtime/routes/identity-routes.ts +3 -3
  252. package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -0
  253. package/src/runtime/routes/integrations/a2a.ts +4 -1
  254. package/src/runtime/routes/migration-routes.ts +3 -1
  255. package/src/runtime/routes/notification-routes.ts +1 -2
  256. package/src/runtime/routes/ui-snapshot-routes.test.ts +1 -0
  257. package/src/runtime/routes/user-route-dispatcher.ts +5 -1
  258. package/src/runtime/verification-templates.ts +1 -2
  259. package/src/sequence/analytics.ts +0 -4
  260. package/src/sequence/engine.ts +1 -11
  261. package/src/sequence/guardrails.ts +1 -6
  262. package/src/sequence/importer.ts +0 -8
  263. package/src/sequence/store.ts +0 -10
  264. package/src/signals/user-message.ts +0 -6
  265. package/src/skills/catalog-install.ts +52 -23
  266. package/src/tools/browser/browser-execution.ts +20 -23
  267. package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +1 -3
  268. package/src/tools/document/document-tool.ts +13 -11
  269. package/src/tools/executor.ts +1 -2
  270. package/src/tools/host-terminal/host-shell.ts +4 -2
  271. package/src/tools/network/__tests__/web-search.test.ts +105 -0
  272. package/src/tools/network/web-fetch.ts +2 -4
  273. package/src/tools/network/web-search.ts +239 -12
  274. package/src/tools/shared/filesystem/format-diff.ts +1 -2
  275. package/src/tools/shared/shell-output.test.ts +42 -0
  276. package/src/tools/shared/shell-output.ts +27 -4
  277. package/src/tools/skills/execute.ts +10 -7
  278. package/src/tools/terminal/shell.ts +3 -3
  279. package/src/tools/tool-approval-handler.ts +9 -1
  280. package/src/tools/ui-surface/visual-validation.ts +1 -2
  281. package/src/usage/attribution.ts +18 -1
  282. package/src/util/__tests__/oom-priority.test.ts +55 -0
  283. package/src/util/__tests__/worker-memory.test.ts +16 -0
  284. package/src/util/debounce.ts +0 -4
  285. package/src/util/oom-priority.ts +79 -0
  286. package/src/util/platform.ts +0 -2
  287. package/src/util/plugin-manifest.ts +5 -7
  288. package/src/util/pricing.ts +25 -0
  289. package/src/util/worker-memory.ts +9 -3
  290. package/src/util/worker-process.ts +8 -0
  291. package/src/workspace/byok-default-profile-ensure.ts +11 -0
  292. package/src/workspace/hatched-date.ts +11 -4
  293. package/src/workspace/migrations/160-repair-retired-fireworks-model-ids.ts +209 -0
  294. package/src/workspace/migrations/161-repair-retired-fireworks-glm-5p2-model-id.ts +197 -0
  295. package/src/workspace/migrations/registry.ts +4 -0
@@ -7,8 +7,16 @@
7
7
  */
8
8
  import { randomUUID } from "node:crypto";
9
9
 
10
+ import { getSqlite } from "../persistence/db-connection.js";
10
11
  import { rawAll, rawGet, rawRun } from "../persistence/raw-query.js";
11
12
  import { getLogger } from "../util/logger.js";
13
+ import {
14
+ type DocumentRevisionAuthor,
15
+ type DocumentSnapshotSource,
16
+ getDocumentRevision,
17
+ recordDocumentSnapshot,
18
+ shouldSnapshotBeforeUserSave,
19
+ } from "./document-revisions-store.js";
12
20
 
13
21
  const log = getLogger("document-store");
14
22
 
@@ -25,6 +33,8 @@ export interface DocumentRecord {
25
33
  wordCount: number;
26
34
  createdAt: number;
27
35
  updatedAt: number;
36
+ /** Bumped by one on every write. */
37
+ revision: number;
28
38
  }
29
39
 
30
40
  /**
@@ -68,32 +78,40 @@ interface DocumentRow {
68
78
  word_count: number;
69
79
  created_at: number;
70
80
  updated_at: number;
81
+ revision: number;
71
82
  }
72
83
 
73
84
  type DocumentListRow = Omit<DocumentRow, "content">;
74
85
 
86
+ /** How many times a read-modify-write retries after losing a revision race. */
87
+ const MAX_WRITE_ATTEMPTS = 5;
88
+
75
89
  function escapeSqlLikePattern(value: string): string {
76
90
  return value.replace(/[\\%_]/g, "\\$&");
77
91
  }
78
92
 
79
- function mapRowToRecord(row: DocumentRow): DocumentRecord {
93
+ function mapRowToSummary(row: DocumentListRow): DocumentSummary {
80
94
  return {
81
95
  surfaceId: row.surface_id,
82
96
  conversationId: row.conversation_id,
83
97
  title: row.title,
84
- content: row.content,
85
98
  wordCount: row.word_count,
86
99
  createdAt: row.created_at,
87
100
  updatedAt: row.updated_at,
101
+ revision: row.revision,
88
102
  };
89
103
  }
90
104
 
105
+ function mapRowToRecord(row: DocumentRow): DocumentRecord {
106
+ return { ...mapRowToSummary(row), content: row.content };
107
+ }
108
+
91
109
  /** Look up a single document by surface ID. Returns `null` when not found. */
92
110
  export function getDocumentById(surfaceId: string): DocumentRecord | null {
93
111
  try {
94
112
  const row = rawGet<DocumentRow>(
95
113
  "documents:getDocumentById",
96
- /*sql*/ `SELECT surface_id, conversation_id, title, content, word_count, created_at, updated_at
114
+ /*sql*/ `SELECT surface_id, conversation_id, title, content, word_count, created_at, updated_at, revision
97
115
  FROM documents
98
116
  WHERE surface_id = ?`,
99
117
  surfaceId,
@@ -151,7 +169,7 @@ export function getDocumentsForConversation(
151
169
  "documents:getDocumentsForConversation",
152
170
  /*sql*/ `
153
171
  SELECT d.surface_id, dc.conversation_id AS conversation_id,
154
- d.title, d.word_count, d.created_at, d.updated_at
172
+ d.title, d.word_count, d.created_at, d.updated_at, d.revision
155
173
  FROM documents d
156
174
  INNER JOIN document_conversations dc ON d.surface_id = dc.surface_id
157
175
  WHERE dc.conversation_id = ?
@@ -164,20 +182,36 @@ export function getDocumentsForConversation(
164
182
  { conversationId, count: rows.length },
165
183
  "Listed documents for conversation",
166
184
  );
167
- return rows.map((row) => ({
168
- surfaceId: row.surface_id,
169
- conversationId: row.conversation_id,
170
- title: row.title,
171
- wordCount: row.word_count,
172
- createdAt: row.created_at,
173
- updatedAt: row.updated_at,
174
- }));
185
+ return rows.map(mapRowToSummary);
175
186
  } catch (error) {
176
187
  log.error({ err: error, conversationId }, "List error");
177
188
  return [];
178
189
  }
179
190
  }
180
191
 
192
+ /**
193
+ * List every document, most recently updated first.
194
+ * Returns an empty array on error.
195
+ */
196
+ export function listAllDocuments(): DocumentSummary[] {
197
+ try {
198
+ const rows = rawAll<DocumentListRow>(
199
+ "documents:listAllDocuments",
200
+ /*sql*/ `
201
+ SELECT surface_id, conversation_id, title, word_count, created_at, updated_at, revision
202
+ FROM documents
203
+ ORDER BY updated_at DESC
204
+ `,
205
+ );
206
+
207
+ log.info({ count: rows.length }, "Listed documents");
208
+ return rows.map(mapRowToSummary);
209
+ } catch (error) {
210
+ log.error({ err: error }, "List error");
211
+ return [];
212
+ }
213
+ }
214
+
181
215
  /**
182
216
  * Search documents by title substring (case-insensitive).
183
217
  * When `conversationId` is supplied, only documents associated with that
@@ -195,7 +229,7 @@ export function searchDocumentsByTitle(
195
229
  "documents:searchByTitle:scoped",
196
230
  /*sql*/ `
197
231
  SELECT d.surface_id, dc.conversation_id AS conversation_id,
198
- d.title, d.word_count, d.created_at, d.updated_at
232
+ d.title, d.word_count, d.created_at, d.updated_at, d.revision
199
233
  FROM documents d
200
234
  INNER JOIN document_conversations dc ON d.surface_id = dc.surface_id
201
235
  WHERE dc.conversation_id = ?
@@ -209,7 +243,7 @@ export function searchDocumentsByTitle(
209
243
  : rawAll<DocumentListRow>(
210
244
  "documents:searchByTitle:all",
211
245
  /*sql*/ `
212
- SELECT surface_id, conversation_id, title, word_count, created_at, updated_at
246
+ SELECT surface_id, conversation_id, title, word_count, created_at, updated_at, revision
213
247
  FROM documents
214
248
  WHERE title COLLATE NOCASE LIKE ? ESCAPE '\\'
215
249
  ORDER BY updated_at DESC
@@ -222,14 +256,7 @@ export function searchDocumentsByTitle(
222
256
  { query, conversationId: options.conversationId, count: rows.length },
223
257
  "Searched documents by title",
224
258
  );
225
- return rows.map((row) => ({
226
- surfaceId: row.surface_id,
227
- conversationId: row.conversation_id,
228
- title: row.title,
229
- wordCount: row.word_count,
230
- createdAt: row.created_at,
231
- updatedAt: row.updated_at,
232
- }));
259
+ return rows.map(mapRowToSummary);
233
260
  } catch (error) {
234
261
  log.error({ err: error, query }, "Search error");
235
262
  return [];
@@ -404,24 +431,93 @@ export function findInDocument(
404
431
  // Document persistence
405
432
  // ---------------------------------------------------------------------------
406
433
 
407
- export function saveDocument(params: {
434
+ /** The document state a conditional save lost to. */
435
+ export interface DocumentRevisionConflict {
436
+ revision: number;
437
+ title: string;
438
+ content: string;
439
+ }
440
+
441
+ export type SaveDocumentResult =
442
+ | { success: true; surfaceId: string; revision: number }
443
+ | { success: false; error: string; conflict?: DocumentRevisionConflict };
444
+
445
+ function readDocumentState(surfaceId: string): DocumentSnapshotSource | null {
446
+ const row = rawGet<{
447
+ revision: number;
448
+ title: string;
449
+ content: string;
450
+ word_count: number;
451
+ }>(
452
+ "documents:readDocumentState",
453
+ /*sql*/ `SELECT revision, title, content, word_count FROM documents WHERE surface_id = ?`,
454
+ surfaceId,
455
+ );
456
+ return row
457
+ ? {
458
+ surfaceId,
459
+ revision: row.revision,
460
+ title: row.title,
461
+ content: row.content,
462
+ wordCount: row.word_count,
463
+ }
464
+ : null;
465
+ }
466
+
467
+ /**
468
+ * Snapshot the state a user save is about to replace, when the save will land
469
+ * and changes something and {@link shouldSnapshotBeforeUserSave} allows it.
470
+ * Runs inside the save's transaction.
471
+ */
472
+ function snapshotBeforeUserSave(params: {
473
+ surfaceId: string;
474
+ title: string;
475
+ content: string;
476
+ baseRevision?: number;
477
+ }): void {
478
+ const current = readDocumentState(params.surfaceId);
479
+ if (
480
+ !current ||
481
+ (params.baseRevision !== undefined &&
482
+ current.revision !== params.baseRevision) ||
483
+ (current.title === params.title && current.content === params.content)
484
+ ) {
485
+ return;
486
+ }
487
+ if (shouldSnapshotBeforeUserSave(params.surfaceId)) {
488
+ recordDocumentSnapshot(current, "user");
489
+ }
490
+ }
491
+
492
+ /**
493
+ * Write a document's title and body, creating the row when it does not exist.
494
+ *
495
+ * Without `baseRevision` the write is unconditional. With it, an existing row
496
+ * is only overwritten when its revision still equals `baseRevision`; otherwise
497
+ * nothing is written and the result carries the row's current state. A row
498
+ * that does not exist yet is created either way.
499
+ */
500
+ function writeDocument(params: {
408
501
  surfaceId: string;
409
502
  conversationId: string;
410
503
  title: string;
411
504
  content: string;
412
505
  wordCount: number;
413
- }): { success: true; surfaceId: string } | { success: false; error: string } {
414
- try {
415
- const now = Date.now();
416
- rawRun(
506
+ baseRevision?: number;
507
+ }): { revision: number } | { conflict: DocumentRevisionConflict } {
508
+ const now = Date.now();
509
+ if (params.baseRevision === undefined) {
510
+ const row = rawGet<{ revision: number }>(
417
511
  "documents:saveDocument",
418
- `INSERT INTO documents (surface_id, conversation_id, title, content, word_count, created_at, updated_at)
419
- VALUES (?, ?, ?, ?, ?, ?, ?)
512
+ /*sql*/ `INSERT INTO documents (surface_id, conversation_id, title, content, word_count, created_at, updated_at, revision)
513
+ VALUES (?, ?, ?, ?, ?, ?, ?, 1)
420
514
  ON CONFLICT(surface_id) DO UPDATE SET
421
515
  title = excluded.title,
422
516
  content = excluded.content,
423
517
  word_count = excluded.word_count,
424
- updated_at = excluded.updated_at`,
518
+ updated_at = excluded.updated_at,
519
+ revision = documents.revision + 1
520
+ RETURNING revision`,
425
521
  params.surfaceId,
426
522
  params.conversationId,
427
523
  params.title,
@@ -430,14 +526,102 @@ export function saveDocument(params: {
430
526
  now,
431
527
  now,
432
528
  );
529
+ return { revision: row!.revision };
530
+ }
531
+
532
+ const updated = rawGet<{ revision: number }>(
533
+ "documents:saveDocument:conditional",
534
+ /*sql*/ `UPDATE documents
535
+ SET title = ?, content = ?, word_count = ?, updated_at = ?, revision = revision + 1
536
+ WHERE surface_id = ? AND revision = ?
537
+ RETURNING revision`,
538
+ params.title,
539
+ params.content,
540
+ params.wordCount,
541
+ now,
542
+ params.surfaceId,
543
+ params.baseRevision,
544
+ );
545
+ if (updated) {
546
+ return updated;
547
+ }
548
+ const inserted = rawGet<{ revision: number }>(
549
+ "documents:saveDocument:insertIfMissing",
550
+ /*sql*/ `INSERT INTO documents (surface_id, conversation_id, title, content, word_count, created_at, updated_at, revision)
551
+ VALUES (?, ?, ?, ?, ?, ?, ?, 1)
552
+ ON CONFLICT(surface_id) DO NOTHING
553
+ RETURNING revision`,
554
+ params.surfaceId,
555
+ params.conversationId,
556
+ params.title,
557
+ params.content,
558
+ params.wordCount,
559
+ now,
560
+ now,
561
+ );
562
+ if (inserted) {
563
+ return inserted;
564
+ }
565
+ const current = readDocumentState(params.surfaceId);
566
+ if (!current) {
567
+ throw new Error("Document disappeared during a conditional save");
568
+ }
569
+ return {
570
+ conflict: {
571
+ revision: current.revision,
572
+ title: current.title,
573
+ content: current.content,
574
+ },
575
+ };
576
+ }
577
+
578
+ /**
579
+ * Create a document, or overwrite one as a user save from the editor. An
580
+ * overwrite may first snapshot the replaced state into the document's history
581
+ * (see {@link snapshotBeforeUserSave}).
582
+ */
583
+ export function saveDocument(params: {
584
+ surfaceId: string;
585
+ conversationId: string;
586
+ title: string;
587
+ content: string;
588
+ wordCount: number;
589
+ baseRevision?: number;
590
+ }): SaveDocumentResult {
591
+ try {
592
+ const written = getSqlite()
593
+ .transaction(() => {
594
+ snapshotBeforeUserSave(params);
595
+ return writeDocument(params);
596
+ })
597
+ .immediate();
598
+ if ("conflict" in written) {
599
+ log.info(
600
+ {
601
+ surfaceId: params.surfaceId,
602
+ baseRevision: params.baseRevision,
603
+ currentRevision: written.conflict.revision,
604
+ },
605
+ "Rejected document save against a stale revision",
606
+ );
607
+ return {
608
+ success: false,
609
+ error: "Document changed since the base revision",
610
+ conflict: written.conflict,
611
+ };
612
+ }
433
613
  log.info(
434
- { surfaceId: params.surfaceId, title: params.title },
614
+ {
615
+ surfaceId: params.surfaceId,
616
+ title: params.title,
617
+ revision: written.revision,
618
+ },
435
619
  "Saved document",
436
620
  );
437
621
 
438
622
  // Best-effort: associate the document with the conversation.
439
623
  // Failures (e.g. migration not yet applied, table missing) must not
440
- // cause the save response to report failure — the document itself is
624
+ // cause the save response to report failure: the document itself is
441
625
  // already persisted at this point.
442
626
  try {
443
627
  addDocumentConversation(params.surfaceId, params.conversationId);
@@ -448,7 +632,11 @@ export function saveDocument(params: {
448
632
  );
449
633
  }
450
634
 
451
- return { success: true, surfaceId: params.surfaceId };
635
+ return {
636
+ success: true,
637
+ surfaceId: params.surfaceId,
638
+ revision: written.revision,
639
+ };
452
640
  } catch (error) {
453
641
  log.error({ err: error, surfaceId: params.surfaceId }, "Save error");
454
642
  return {
@@ -458,6 +646,143 @@ export function saveDocument(params: {
458
646
  }
459
647
  }
460
648
 
649
+ /** What a {@link mutateDocumentContent} callback decides for one attempt. */
650
+ export type ContentMutation<T> =
651
+ | { content: string; value: T }
652
+ | { value: T; content?: undefined };
653
+
654
+ /** Thrown inside a write transaction to roll it back after losing a race. */
655
+ class LostRevisionRace extends Error {}
656
+
657
+ /**
658
+ * Read-modify-write a document's body without losing a concurrent write.
659
+ *
660
+ * `mutate` receives the current body and returns the new one, or no `content`
661
+ * to leave the row untouched; a body equal to the current one is not written
662
+ * either. The state being replaced is snapshotted into the document's history
663
+ * as `author`'s edit, in the same transaction as the write. The write is a
664
+ * compare-and-swap on `revision`: when another write lands between the read
665
+ * and the write, the transaction rolls back and `mutate` runs again against
666
+ * the fresh body, up to {@link MAX_WRITE_ATTEMPTS} times.
667
+ *
668
+ * Returns `null` when the document does not exist, otherwise the callback's
669
+ * value, the body now stored, and the revision after the call.
670
+ */
671
+ export function mutateDocumentContent<T>(
672
+ surfaceId: string,
673
+ author: DocumentRevisionAuthor,
674
+ mutate: (current: {
675
+ content: string;
676
+ revision: number;
677
+ }) => ContentMutation<T>,
678
+ ): { value: T; content: string; revision: number } | null {
679
+ for (let attempt = 0; attempt < MAX_WRITE_ATTEMPTS; attempt++) {
680
+ const current = readDocumentState(surfaceId);
681
+ if (!current) {
682
+ return null;
683
+ }
684
+ const mutation = mutate(current);
685
+ if (
686
+ mutation.content === undefined ||
687
+ mutation.content === current.content
688
+ ) {
689
+ return {
690
+ value: mutation.value,
691
+ content: current.content,
692
+ revision: current.revision,
693
+ };
694
+ }
695
+ const newContent = mutation.content;
696
+ try {
697
+ const written = getSqlite()
698
+ .transaction(() => {
699
+ recordDocumentSnapshot(current, author);
700
+ const row = rawGet<{ revision: number }>(
701
+ "documents:mutateDocumentContent:update",
702
+ /*sql*/ `UPDATE documents
703
+ SET content = ?, word_count = ?, updated_at = ?, revision = revision + 1
704
+ WHERE surface_id = ? AND revision = ?
705
+ RETURNING revision`,
706
+ newContent,
707
+ countWords(newContent),
708
+ Date.now(),
709
+ surfaceId,
710
+ current.revision,
711
+ );
712
+ if (!row) {
713
+ throw new LostRevisionRace();
714
+ }
715
+ return row;
716
+ })
717
+ .immediate();
718
+ return {
719
+ value: mutation.value,
720
+ content: newContent,
721
+ revision: written.revision,
722
+ };
723
+ } catch (error) {
724
+ if (!(error instanceof LostRevisionRace)) {
725
+ throw error;
726
+ }
727
+ }
728
+ log.info(
729
+ { surfaceId, attempt, baseRevision: current.revision },
730
+ "Document changed during a read-modify-write; retrying",
731
+ );
732
+ }
733
+ throw new Error("Document kept changing during the write; try again");
734
+ }
735
+
736
+ /** Outcome of {@link restoreDocumentRevision}. */
737
+ export type RestoreDocumentRevisionResult =
738
+ | { success: true; revision: number; title: string; content: string }
739
+ | { success: false; notFound: "document" | "revision" };
740
+
741
+ /**
742
+ * Put a snapshot's title and body back as a new revision of the document.
743
+ * The current state is snapshotted first, so the restore is itself undoable.
744
+ */
745
+ export function restoreDocumentRevision(
746
+ surfaceId: string,
747
+ revision: number,
748
+ ): RestoreDocumentRevisionResult {
749
+ return getSqlite()
750
+ .transaction((): RestoreDocumentRevisionResult => {
751
+ const current = readDocumentState(surfaceId);
752
+ if (!current) {
753
+ return { success: false, notFound: "document" };
754
+ }
755
+ const target = getDocumentRevision(surfaceId, revision);
756
+ if (!target) {
757
+ return { success: false, notFound: "revision" };
758
+ }
759
+ recordDocumentSnapshot(current, "user");
760
+ const row = rawGet<{ revision: number }>(
761
+ "documents:restoreDocumentRevision",
762
+ /*sql*/ `UPDATE documents
763
+ SET title = ?, content = ?, word_count = ?, updated_at = ?, revision = revision + 1
764
+ WHERE surface_id = ?
765
+ RETURNING revision`,
766
+ target.title,
767
+ target.content,
768
+ target.wordCount,
769
+ Date.now(),
770
+ surfaceId,
771
+ );
772
+ log.info(
773
+ { surfaceId, restoredRevision: revision, revision: row!.revision },
774
+ "Restored document revision",
775
+ );
776
+ return {
777
+ success: true,
778
+ revision: row!.revision,
779
+ title: target.title,
780
+ content: target.content,
781
+ };
782
+ })
783
+ .immediate();
784
+ }
785
+
461
786
  export const DEFAULT_DOCUMENT_TITLE = "Untitled Document";
462
787
 
463
788
  /**
@@ -468,7 +793,7 @@ export function createDocument(params: {
468
793
  conversationId: string;
469
794
  title?: string;
470
795
  content?: string;
471
- }): ReturnType<typeof saveDocument> {
796
+ }): SaveDocumentResult {
472
797
  const content = params.content ?? "";
473
798
  return saveDocument({
474
799
  surfaceId: `doc-${randomUUID()}`,
@@ -490,11 +815,79 @@ export interface ReplaceInDocumentOptions {
490
815
  }
491
816
 
492
817
  export type ReplaceInDocumentResult =
493
- | { success: true; replacements_made: number; content_changed: boolean }
818
+ | {
819
+ success: true;
820
+ replacements_made: number;
821
+ content_changed: boolean;
822
+ /** The body now stored. */
823
+ content: string;
824
+ /** The revision after the call. */
825
+ revision: number;
826
+ }
494
827
  | { success: false; error: string };
495
828
 
496
829
  /**
497
- * Find and replace text within a document — like sed.
830
+ * Apply a find-and-replace to `content`. Returns the unchanged input and a
831
+ * zero count when nothing matches or `maxReplacements` is not positive.
832
+ */
833
+ function replaceText(
834
+ content: string,
835
+ find: string,
836
+ replace: string,
837
+ options: ReplaceInDocumentOptions,
838
+ ): { content: string; replacementsMade: number } {
839
+ const flags = "g" + (options.caseSensitive === true ? "" : "i");
840
+ const pattern = options.regex
841
+ ? new RegExp(find, flags)
842
+ : new RegExp(RegExp.escape(find), flags);
843
+
844
+ const totalMatches = [...content.matchAll(pattern)].length;
845
+ if (
846
+ totalMatches === 0 ||
847
+ (options.maxReplacements != null && options.maxReplacements <= 0)
848
+ ) {
849
+ return { content, replacementsMade: 0 };
850
+ }
851
+
852
+ if (
853
+ options.maxReplacements == null ||
854
+ options.maxReplacements >= totalMatches
855
+ ) {
856
+ return {
857
+ content: content.replace(pattern, replace),
858
+ replacementsMade: totalMatches,
859
+ };
860
+ }
861
+
862
+ // Iterative replacement up to maxReplacements using manual exec loop
863
+ // so backreferences in the replacement string work correctly.
864
+ const limit = options.maxReplacements;
865
+ let count = 0;
866
+ let result = "";
867
+ let lastIndex = 0;
868
+ let m: RegExpExecArray | null;
869
+ while ((m = pattern.exec(content)) !== null) {
870
+ if (count >= limit) {
871
+ break;
872
+ }
873
+ result += content.slice(lastIndex, m.index);
874
+ const singleMatchPattern = new RegExp(
875
+ pattern.source,
876
+ pattern.flags.replace("g", ""),
877
+ );
878
+ result += m[0].replace(singleMatchPattern, replace);
879
+ lastIndex = m.index + m[0].length;
880
+ count++;
881
+ if (m[0].length === 0) {
882
+ pattern.lastIndex++;
883
+ }
884
+ }
885
+ result += content.slice(lastIndex);
886
+ return { content: result, replacementsMade: count };
887
+ }
888
+
889
+ /**
890
+ * Find and replace text within a document, like sed, as an assistant edit.
498
891
  * Supports literal text and regex patterns with optional backreferences.
499
892
  */
500
893
  export function replaceInDocument(
@@ -504,80 +897,27 @@ export function replaceInDocument(
504
897
  options: ReplaceInDocumentOptions = {},
505
898
  ): ReplaceInDocumentResult {
506
899
  try {
507
- const row = rawGet<{ content: string }>(
508
- "documents:replaceInDocument:getContent",
509
- /*sql*/ `SELECT content FROM documents WHERE surface_id = ?`,
510
- surfaceId,
511
- );
512
- if (!row) {
900
+ const outcome = mutateDocumentContent(surfaceId, "assistant", (current) => {
901
+ const replaced = replaceText(current.content, find, replace, options);
902
+ return replaced.replacementsMade === 0
903
+ ? { value: 0 }
904
+ : { content: replaced.content, value: replaced.replacementsMade };
905
+ });
906
+ if (!outcome) {
513
907
  return { success: false, error: "Document not found" };
514
908
  }
515
-
516
- const flags = "g" + (options.caseSensitive === true ? "" : "i");
517
- const pattern = options.regex
518
- ? new RegExp(find, flags)
519
- : new RegExp(RegExp.escape(find), flags);
520
-
521
- const totalMatches = [...row.content.matchAll(pattern)].length;
522
- if (
523
- totalMatches === 0 ||
524
- (options.maxReplacements != null && options.maxReplacements <= 0)
525
- ) {
526
- return { success: true, replacements_made: 0, content_changed: false };
527
- }
528
-
529
- let newContent: string;
530
- let replacementsMade: number;
531
-
532
- if (
533
- options.maxReplacements != null &&
534
- options.maxReplacements < totalMatches
535
- ) {
536
- // Iterative replacement up to maxReplacements using manual exec loop
537
- // so backreferences in the replacement string work correctly.
538
- const limit = options.maxReplacements;
539
- let count = 0;
540
- let result = "";
541
- let lastIndex = 0;
542
- let m: RegExpExecArray | null;
543
- while ((m = pattern.exec(row.content)) !== null) {
544
- if (count >= limit) {
545
- break;
546
- }
547
- result += row.content.slice(lastIndex, m.index);
548
- const singleMatchPattern = new RegExp(
549
- pattern.source,
550
- pattern.flags.replace("g", ""),
551
- );
552
- result += m[0].replace(singleMatchPattern, replace);
553
- lastIndex = m.index + m[0].length;
554
- count++;
555
- if (m[0].length === 0) {
556
- pattern.lastIndex++;
557
- }
558
- }
559
- result += row.content.slice(lastIndex);
560
- newContent = result;
561
- replacementsMade = count;
562
- } else {
563
- newContent = row.content.replace(pattern, replace);
564
- replacementsMade = totalMatches;
909
+ if (outcome.value > 0) {
910
+ log.info(
911
+ { surfaceId, replacementsMade: outcome.value },
912
+ "Replaced text in document",
913
+ );
565
914
  }
566
-
567
- const wordCount = countWords(newContent);
568
- rawRun(
569
- "documents:replaceInDocument:update",
570
- /*sql*/ `UPDATE documents SET content = ?, word_count = ?, updated_at = ? WHERE surface_id = ?`,
571
- newContent,
572
- wordCount,
573
- Date.now(),
574
- surfaceId,
575
- );
576
- log.info({ surfaceId, replacementsMade }, "Replaced text in document");
577
915
  return {
578
916
  success: true,
579
- replacements_made: replacementsMade,
580
- content_changed: true,
917
+ replacements_made: outcome.value,
918
+ content_changed: outcome.value > 0,
919
+ content: outcome.content,
920
+ revision: outcome.revision,
581
921
  };
582
922
  } catch (error) {
583
923
  log.error({ err: error, surfaceId }, "Replace-in-document error");
@@ -699,57 +1039,55 @@ export interface DocumentContentUpdated {
699
1039
  appliedMarkdown: string;
700
1040
  /** True when a duplicated leading run was dropped from an append. */
701
1041
  duplicateLeadingContentSkipped: boolean;
1042
+ /** The revision after the write. */
1043
+ revision: number;
702
1044
  }
703
1045
 
704
- /** Update persisted document content (append or replace). */
1046
+ /** Update persisted document content (append or replace) as an assistant edit. */
705
1047
  export function updateDocumentContent(
706
1048
  surfaceId: string,
707
1049
  markdown: string,
708
1050
  mode: string,
709
1051
  ): DocumentContentUpdated | { success: false; error: string } {
710
1052
  try {
711
- const existing = rawGet<{ content: string }>(
712
- "documents:updateDocumentContent:get",
713
- /*sql*/ `SELECT content FROM documents WHERE surface_id = ?`,
714
- surfaceId,
715
- );
716
- if (!existing) {
1053
+ const appending = mode === "append";
1054
+ const outcome = mutateDocumentContent(surfaceId, "assistant", (current) => {
1055
+ const appliedMarkdown = appending
1056
+ ? stripDuplicateLeadingBlocks(current.content, markdown)
1057
+ : markdown;
1058
+ const sep =
1059
+ appending && current.content.length > 0 && appliedMarkdown.length > 0
1060
+ ? "\n\n"
1061
+ : "";
1062
+ return {
1063
+ content: appending
1064
+ ? current.content + sep + appliedMarkdown
1065
+ : appliedMarkdown,
1066
+ value: appliedMarkdown,
1067
+ };
1068
+ });
1069
+ if (!outcome) {
717
1070
  log.info({ surfaceId }, "No persisted document to update");
718
1071
  return { success: false, error: "Document not found" };
719
1072
  }
720
- const appending = mode === "append";
721
- const appliedMarkdown = appending
722
- ? stripDuplicateLeadingBlocks(existing.content, markdown)
723
- : markdown;
1073
+ const appliedMarkdown = outcome.value;
724
1074
  const duplicateLeadingContentSkipped =
725
1075
  appending && appliedMarkdown !== markdown;
726
- const sep =
727
- appending && existing.content.length > 0 && appliedMarkdown.length > 0
728
- ? "\n\n"
729
- : "";
730
- const newContent = appending
731
- ? existing.content + sep + appliedMarkdown
732
- : appliedMarkdown;
733
- const wordCount = countWords(newContent);
734
- rawRun(
735
- "documents:updateDocumentContent:update",
736
- /*sql*/ `UPDATE documents SET content = ?, word_count = ?, updated_at = ? WHERE surface_id = ?`,
737
- newContent,
738
- wordCount,
739
- Date.now(),
740
- surfaceId,
741
- );
742
1076
  if (duplicateLeadingContentSkipped) {
743
1077
  log.info(
744
1078
  { surfaceId, skippedChars: markdown.length - appliedMarkdown.length },
745
1079
  "Skipped append content duplicating the document tail",
746
1080
  );
747
1081
  }
748
- log.info({ surfaceId, mode }, "Updated document content");
1082
+ log.info(
1083
+ { surfaceId, mode, revision: outcome.revision },
1084
+ "Updated document content",
1085
+ );
749
1086
  return {
750
1087
  success: true,
751
1088
  appliedMarkdown,
752
1089
  duplicateLeadingContentSkipped,
1090
+ revision: outcome.revision,
753
1091
  };
754
1092
  } catch (error) {
755
1093
  log.error({ err: error, surfaceId }, "Document content update error");