@vellumai/assistant 0.11.5 → 0.11.6-staging.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. package/AGENTS.md +5 -1
  2. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  3. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  4. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  5. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  6. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +70 -0
  7. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +16 -1
  8. package/node_modules/@vellumai/gateway-client/src/index.ts +6 -2
  9. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +121 -61
  10. package/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  11. package/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  12. package/openapi.yaml +421 -15
  13. package/package.json +1 -1
  14. package/scripts/sync-web-search-catalog.ts +6 -0
  15. package/src/__tests__/app-pin-store.test.ts +149 -0
  16. package/src/__tests__/channel-availability-routes.test.ts +23 -1
  17. package/src/__tests__/channel-readiness-discord.test.ts +231 -0
  18. package/src/__tests__/channel-readiness-service.test.ts +126 -0
  19. package/src/__tests__/channel-readiness-slack-remote.test.ts +141 -0
  20. package/src/__tests__/channel-reply-delivery.test.ts +4 -4
  21. package/src/__tests__/client-os-metadata-persistence.test.ts +23 -10
  22. package/src/__tests__/conversation-delete-watch-timeline.test.ts +231 -0
  23. package/src/__tests__/conversation-error.test.ts +17 -0
  24. package/src/__tests__/conversation-seed-composer.test.ts +8 -0
  25. package/src/__tests__/conversation-slash-commands.test.ts +8 -0
  26. package/src/__tests__/disk-pressure-policy.test.ts +6 -0
  27. package/src/__tests__/gemini-provider.test.ts +138 -0
  28. package/src/__tests__/history-repair.test.ts +105 -3
  29. package/src/__tests__/identity-routes.test.ts +1 -0
  30. package/src/__tests__/llm-catalog-parity.test.ts +45 -0
  31. package/src/__tests__/migration-import-from-path.test.ts +349 -0
  32. package/src/__tests__/notification-telegram-adapter.test.ts +102 -0
  33. package/src/__tests__/oauth-commands-routes.test.ts +89 -0
  34. package/src/__tests__/oauth-provider-profiles.test.ts +7 -6
  35. package/src/__tests__/openai-provider.test.ts +18 -0
  36. package/src/__tests__/openai-responses-provider.test.ts +18 -0
  37. package/src/__tests__/platform-callback-registration.test.ts +184 -0
  38. package/src/__tests__/plugin-api-store-credential.test.ts +71 -3
  39. package/src/__tests__/pricing.test.ts +2 -2
  40. package/src/__tests__/public-ingress-urls.test.ts +36 -0
  41. package/src/__tests__/resolve-trust-class.test.ts +0 -48
  42. package/src/__tests__/sanitize-config-for-transfer.test.ts +28 -0
  43. package/src/__tests__/secret-routes-platform-proxy.test.ts +49 -0
  44. package/src/__tests__/settings-routes.test.ts +85 -3
  45. package/src/__tests__/web-search-catalog-parity.test.ts +8 -0
  46. package/src/agent/history-repair/history-repair.ts +45 -14
  47. package/src/agent/loop.ts +4 -1
  48. package/src/api/constants/profile-config-validation.ts +60 -0
  49. package/src/api/events/tool-result.ts +6 -1
  50. package/src/api/events/watch-retro-completed.ts +52 -0
  51. package/src/api/index.ts +11 -0
  52. package/src/apps/app-pin-reconciler.ts +92 -0
  53. package/src/apps/app-pin-store.ts +125 -0
  54. package/src/channels/gateway-channel-socket-health.ts +32 -0
  55. package/src/channels/gateway-discord-admission.ts +32 -0
  56. package/src/channels/types.ts +20 -0
  57. package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -1
  58. package/src/cli/commands/__tests__/inference-profiles.test.ts +16 -4
  59. package/src/cli/commands/__tests__/inference-providers.test.ts +67 -2
  60. package/src/cli/commands/channels/__tests__/channels.test.ts +85 -0
  61. package/src/cli/commands/channels/index.ts +45 -31
  62. package/src/cli/commands/inference-profiles.ts +56 -3
  63. package/src/cli/commands/inference-providers.ts +28 -2
  64. package/src/cli/commands/oauth/index.help.ts +7 -1
  65. package/src/cli/commands/oauth/request.test.ts +290 -0
  66. package/src/cli/commands/oauth/request.ts +57 -41
  67. package/src/cli/lib/bundled-marketplace.json +14 -1
  68. package/src/cli/lib/open-browser.test.ts +67 -0
  69. package/src/cli/lib/open-browser.ts +24 -5
  70. package/src/config/__tests__/profile-materialization.test.ts +26 -0
  71. package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +6 -0
  72. package/src/config/bundled-skills/schedule/SKILL.md +1 -1
  73. package/src/config/feature-flag-registry.json +17 -1
  74. package/src/config/profile-materialization.ts +29 -0
  75. package/src/config/sanitize-for-transfer.ts +16 -0
  76. package/src/config/schemas/llm.ts +7 -0
  77. package/src/config/schemas/services.ts +6 -0
  78. package/src/context/outbound-sanitize.ts +6 -0
  79. package/src/daemon/__tests__/lifecycle-watch-timeline-sweep.test.ts +98 -0
  80. package/src/daemon/conversation-error.ts +24 -2
  81. package/src/daemon/conversation-slash.ts +6 -15
  82. package/src/daemon/daemon-control.ts +1 -0
  83. package/src/daemon/disk-pressure-policy.ts +7 -1
  84. package/src/daemon/handlers/__tests__/config-ingress-tunnel-records.test.ts +208 -0
  85. package/src/daemon/handlers/config-ingress.ts +115 -5
  86. package/src/daemon/lifecycle.ts +26 -0
  87. package/src/daemon/message-types/web-activity.ts +3 -2
  88. package/src/daemon/trust-context.ts +0 -37
  89. package/src/inbound/__tests__/tunnel-probe.test.ts +448 -0
  90. package/src/inbound/platform-callback-registration.ts +28 -2
  91. package/src/inbound/public-ingress-urls.ts +12 -0
  92. package/src/inbound/tunnel-probe.ts +261 -0
  93. package/src/live-voice/__tests__/live-voice-connection.test.ts +25 -0
  94. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +8 -2
  95. package/src/live-voice/__tests__/live-voice-session-manager.test.ts +212 -10
  96. package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +5 -2
  97. package/src/live-voice/live-voice-connection.ts +46 -8
  98. package/src/live-voice/live-voice-manager.ts +25 -0
  99. package/src/live-voice/live-voice-session-manager.ts +318 -2
  100. package/src/live-voice/live-voice-session.ts +52 -2
  101. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +126 -68
  102. package/src/messaging/providers/channel-transport.ts +64 -47
  103. package/src/messaging/providers/discord/send.test.ts +46 -1
  104. package/src/messaging/providers/discord/send.ts +51 -0
  105. package/src/messaging/providers/discord/transport.ts +26 -3
  106. package/src/messaging/providers/index.ts +22 -47
  107. package/src/messaging/providers/slack/send.test.ts +83 -26
  108. package/src/messaging/providers/slack/send.ts +120 -51
  109. package/src/messaging/providers/slack/stream-tasks.test.ts +26 -0
  110. package/src/messaging/providers/slack/stream-tasks.ts +39 -0
  111. package/src/messaging/providers/slack/transport.ts +24 -22
  112. package/src/messaging/providers/telegram-bot/send.test.ts +109 -12
  113. package/src/messaging/providers/telegram-bot/send.ts +43 -0
  114. package/src/messaging/providers/telegram-bot/transport.ts +25 -8
  115. package/src/notifications/__tests__/assistant-reply-producer.test.ts +30 -7
  116. package/src/notifications/adapters/telegram.ts +48 -1
  117. package/src/notifications/assistant-reply-producer.ts +7 -7
  118. package/src/notifications/conversation-seed-composer.ts +7 -2
  119. package/src/oauth/byo-connection.test.ts +63 -0
  120. package/src/oauth/byo-connection.ts +16 -15
  121. package/src/oauth/connection.test.ts +111 -0
  122. package/src/oauth/connection.ts +142 -1
  123. package/src/oauth/platform-connection.test.ts +34 -0
  124. package/src/oauth/platform-connection.ts +28 -5
  125. package/src/oauth/seed-providers.ts +15 -1
  126. package/src/permissions/types.ts +3 -1
  127. package/src/persistence/conversation-crud.ts +46 -0
  128. package/src/persistence/conversation-types.ts +11 -9
  129. package/src/persistence/db-async-query.ts +2 -1
  130. package/src/persistence/db-maintenance.ts +15 -0
  131. package/src/persistence/embeddings/qdrant-manager.ts +1 -0
  132. package/src/persistence/migrations/367-create-watch-timeline-entries.ts +46 -0
  133. package/src/persistence/migrations/368-watch-timeline-screenshot-blob.ts +33 -0
  134. package/src/persistence/migrations/369-create-app-pins.ts +37 -0
  135. package/src/persistence/migrations/__tests__/367-create-watch-timeline-entries.test.ts +98 -0
  136. package/src/persistence/migrations/__tests__/368-watch-timeline-screenshot-blob.test.ts +98 -0
  137. package/src/persistence/schema/index.ts +1 -0
  138. package/src/persistence/schema/infrastructure.ts +17 -0
  139. package/src/persistence/schema/watch.ts +29 -0
  140. package/src/persistence/steps.ts +6 -0
  141. package/src/plugins/mtime-cache.ts +11 -0
  142. package/src/providers/__tests__/retry-network-error.test.ts +84 -0
  143. package/src/providers/connection-resolution.ts +23 -1
  144. package/src/providers/content-blocks.ts +9 -0
  145. package/src/providers/fetch-provider-catalog.ts +19 -0
  146. package/src/providers/gemini/client.ts +13 -5
  147. package/src/providers/inference/__tests__/endpoint-probe.test.ts +92 -0
  148. package/src/providers/inference/__tests__/profile-config-validation.test.ts +39 -0
  149. package/src/providers/inference/__tests__/profile-probe-classify.test.ts +66 -0
  150. package/src/providers/inference/adapter-factory.ts +0 -9
  151. package/src/providers/inference/credential-rotation.ts +61 -0
  152. package/src/providers/inference/endpoint-probe.ts +115 -0
  153. package/src/providers/inference/profile-probe.ts +256 -0
  154. package/src/providers/model-catalog.ts +170 -125
  155. package/src/providers/openai/__tests__/api-error-normalization.test.ts +17 -1
  156. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +42 -60
  157. package/src/providers/openai/__tests__/connection-error-wrap.test.ts +44 -0
  158. package/src/providers/openai/__tests__/orphan-tool-result-guard.test.ts +34 -2
  159. package/src/providers/openai/api-error-normalization.ts +16 -2
  160. package/src/providers/openai/chat-completions-provider.ts +75 -29
  161. package/src/providers/openai/responses-provider.ts +5 -2
  162. package/src/providers/openrouter/client.ts +0 -1
  163. package/src/providers/provider-send-message.ts +11 -0
  164. package/src/providers/retry.ts +6 -0
  165. package/src/providers/search-provider-catalog.ts +20 -0
  166. package/src/providers/vercel-ai-gateway/client.ts +0 -1
  167. package/src/runtime/AGENTS.md +1 -0
  168. package/src/runtime/__tests__/desktop-presence.test.ts +27 -4
  169. package/src/runtime/__tests__/host-observe.test.ts +302 -0
  170. package/src/runtime/channel-readiness-service.ts +214 -14
  171. package/src/runtime/channel-readiness-types.ts +49 -2
  172. package/src/runtime/channel-reply-delivery.ts +2 -2
  173. package/src/runtime/desktop-presence.ts +24 -21
  174. package/src/runtime/host-observe.ts +246 -0
  175. package/src/runtime/http-server.ts +181 -1
  176. package/src/runtime/migrations/__tests__/staged-import-path.test.ts +104 -0
  177. package/src/runtime/migrations/staged-import-path.ts +116 -0
  178. package/src/runtime/routes/__tests__/app-pin-routes.test.ts +383 -0
  179. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +80 -0
  180. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +118 -0
  181. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +20 -0
  182. package/src/runtime/routes/__tests__/ingress-status-routes.test.ts +508 -0
  183. package/src/runtime/routes/__tests__/plugins-routes.test.ts +35 -56
  184. package/src/runtime/routes/__tests__/watch-routes-guardian-cache.test.ts +139 -0
  185. package/src/runtime/routes/__tests__/watch-routes.test.ts +598 -0
  186. package/src/runtime/routes/app-management-routes.ts +140 -29
  187. package/src/runtime/routes/channel-availability-routes.ts +1 -0
  188. package/src/runtime/routes/channel-readiness-routes.ts +14 -2
  189. package/src/runtime/routes/conversation-query-routes.ts +10 -0
  190. package/src/runtime/routes/guardian-approval-interception.ts +24 -33
  191. package/src/runtime/routes/host-cu-routes.ts +18 -0
  192. package/src/runtime/routes/identity-routes.ts +2 -0
  193. package/src/runtime/routes/inbound-message-handler.ts +10 -7
  194. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +166 -308
  195. package/src/runtime/routes/inbound-stages/background-dispatch.ts +158 -335
  196. package/src/runtime/routes/index.ts +2 -0
  197. package/src/runtime/routes/inference-profiles-routes.ts +232 -31
  198. package/src/runtime/routes/inference-provider-connection-routes.ts +24 -4
  199. package/src/runtime/routes/ingress-status-routes.ts +180 -0
  200. package/src/runtime/routes/live-voice-routes.test.ts +40 -1
  201. package/src/runtime/routes/live-voice-routes.ts +34 -0
  202. package/src/runtime/routes/migration-routes.ts +218 -10
  203. package/src/runtime/routes/oauth-commands-routes.ts +23 -16
  204. package/src/runtime/routes/plugins-routes.ts +12 -28
  205. package/src/runtime/routes/question-routes.ts +6 -0
  206. package/src/runtime/routes/secret-routes.ts +7 -27
  207. package/src/runtime/routes/settings-routes.ts +9 -6
  208. package/src/runtime/routes/watch-routes.ts +807 -0
  209. package/src/runtime/slack-reply-session.test.ts +230 -121
  210. package/src/runtime/slack-reply-session.ts +113 -81
  211. package/src/runtime/{slack-task-progress.test.ts → task-progress.test.ts} +1 -28
  212. package/src/runtime/{slack-task-progress.ts → task-progress.ts} +30 -51
  213. package/src/security/__tests__/untrusted-content.test.ts +42 -0
  214. package/src/security/untrusted-content.ts +28 -9
  215. package/src/telemetry/__tests__/live-voice-funnel.test.ts +108 -0
  216. package/src/telemetry/live-voice-funnel.ts +75 -8
  217. package/src/tools/credentials/store.ts +18 -6
  218. package/src/tools/network/__tests__/firecrawl-compat.test.ts +77 -0
  219. package/src/tools/network/__tests__/web-fetch-fastcrw.test.ts +169 -0
  220. package/src/tools/network/__tests__/web-search.test.ts +97 -2
  221. package/src/tools/network/firecrawl-compat.ts +90 -0
  222. package/src/tools/network/web-fetch.ts +142 -62
  223. package/src/tools/network/web-search.ts +141 -55
  224. package/src/tools/types.ts +2 -1
  225. package/src/util/oauth-request-body.test.ts +74 -0
  226. package/src/util/oauth-request-body.ts +60 -0
  227. package/src/util/worker-process.ts +1 -0
  228. package/src/watch/__tests__/watch-retro.test.ts +665 -0
  229. package/src/watch/__tests__/watch-session-manager.test.ts +566 -0
  230. package/src/watch/__tests__/watch-timeline.test.ts +670 -0
  231. package/src/watch/watch-retro.ts +480 -0
  232. package/src/watch/watch-session-manager.ts +575 -0
  233. package/src/watch/watch-timeline.ts +848 -0
@@ -0,0 +1,848 @@
1
+ /**
2
+ * The timeline a watch session writes: what the user narrated, and what was on
3
+ * their screen while they narrated it.
4
+ *
5
+ * The timeline is its own store, not conversation history. A session produces
6
+ * hundreds of entries with no assistant turn between them, which is a shape
7
+ * message history cannot hold: providers require strict user/assistant
8
+ * alternation, so history repair merges consecutive user messages into one
9
+ * before every provider call, and every per-message bound downstream of that
10
+ * merge (screenshot retention, AX-tree compaction) then sees a single message
11
+ * and has nothing left to bound. Keeping entries in a table sidesteps that
12
+ * entirely: nothing about a timeline touches turn-shaped machinery, and the
13
+ * only text that reaches a model is the summary `renderWatchTimeline`
14
+ * composes when the retrospective asks for it.
15
+ *
16
+ * Ordering is the property the retrospective depends on and the one arrival
17
+ * order does not give for free: a narration final and the observation it
18
+ * triggered are two independent async writes. Every entry carries `atMs`, its
19
+ * offset from the start of the session, and reads order by it, so the
20
+ * retrospective gets one interleaved timeline no matter which write landed
21
+ * first.
22
+ *
23
+ * A screenshot lives in the row it belongs to, so an entry has one home and
24
+ * one lifetime and a purge is a single `DELETE`. The frames afford that:
25
+ * `attachScreenshot` is caller-gated, and the host captures at 960x540
26
+ * (`HostCuExecutor.swift`), which is tens of kilobytes of JPEG rather than the
27
+ * megabytes a full-resolution frame would be. Reads that only want the text
28
+ * select around the column, so a session's pixels reach memory only when a
29
+ * caller asks for a specific frame through {@link readWatchScreenshot}.
30
+ *
31
+ * Deletion needs no coordination beyond ordering. An append runs to completion
32
+ * in one synchronous step, so nothing can land between its existence check and
33
+ * its insert; every purge runs after the conversation rows it covers are
34
+ * already gone. An append therefore either finishes before the purge, and is
35
+ * swept by it, or starts after it and is refused by
36
+ * {@link conversationStillExists}.
37
+ *
38
+ * A purge that never ran is not permanent. Nothing cascades into this table, so
39
+ * a failed purge or a crash between the conversation delete and the purge would
40
+ * otherwise strand frames of the user's screen for good;
41
+ * {@link sweepOrphanedWatchTimelineEntries} deletes entries whose conversation
42
+ * is gone, and reclaims them on the next startup or maintenance pass.
43
+ */
44
+
45
+ import { randomUUID } from "node:crypto";
46
+
47
+ import {
48
+ count,
49
+ desc,
50
+ eq,
51
+ inArray,
52
+ notExists,
53
+ type SQL,
54
+ sql,
55
+ } from "drizzle-orm";
56
+
57
+ import { escapeAxTreeContent } from "../context/outbound-sanitize.js";
58
+ import { getDb } from "../persistence/db-connection.js";
59
+ import { conversations } from "../persistence/schema/conversations.js";
60
+ import { watchTimelineEntries } from "../persistence/schema/watch.js";
61
+ import { getLogger } from "../util/logger.js";
62
+
63
+ const log = getLogger("watch-timeline");
64
+
65
+ const NARRATION_LABEL = "narration:";
66
+ const OBSERVATION_LABEL = "screen:";
67
+
68
+ /** The format the host captures a watch screenshot in. */
69
+ export const WATCH_SCREENSHOT_MIME = "image/jpeg";
70
+
71
+ /**
72
+ * Bytes of screenshot a single entry may carry.
73
+ *
74
+ * The host captures at 960x540 (`HostCuExecutor.swift`), which lands a JPEG
75
+ * around 50-150 KB, so the cap is an order of magnitude of headroom over an
76
+ * ordinary frame and exists to bound the pathological one. A frame over it is
77
+ * dropped rather than stored, on the same terms as a frame that failed to
78
+ * decode: the entry keeps its tree and its diff, and an entry that had nothing
79
+ * else is refused.
80
+ */
81
+ const MAX_SCREENSHOT_BYTES = 2_000_000;
82
+
83
+ /** Stands in for an AX tree the render bound left out. */
84
+ const AX_TREE_OMITTED = "<ax-tree-omitted />";
85
+
86
+ /**
87
+ * Stands in for a screen the host captured but could not enumerate.
88
+ *
89
+ * The macOS host falls back to a bare screenshot whenever there is no focused
90
+ * window (`HostCuExecutor.swift`), so an observation with pixels and no tree is
91
+ * an expected shape rather than a broken one. The marker is what tells the
92
+ * retrospective it is looking at a screen it can see but not read.
93
+ */
94
+ const AX_TREE_UNAVAILABLE = "<ax-tree-unavailable />";
95
+
96
+ /** Notes that an entry's moment is also available as an image. */
97
+ const SCREENSHOT_NOTE = "a screenshot of this moment was captured.";
98
+
99
+ /** Separates one rendered entry from the next. */
100
+ const BLOCK_SEPARATOR = "\n\n";
101
+
102
+ /** Marks content the byte budget cut short. */
103
+ const TRUNCATION_MARKER = "[truncated]";
104
+
105
+ /**
106
+ * Bytes an entry needs before it is worth rendering at all. A block clipped
107
+ * below this says nothing its offset prefix does not, so the render stops
108
+ * instead and reports the loss through `truncated`.
109
+ */
110
+ const MIN_ENTRY_BYTES = 256;
111
+
112
+ /**
113
+ * Bytes an AX tree needs before it is spelled out. Below it the tree collapses
114
+ * to {@link AX_TREE_OMITTED}, which costs less than a tree clipped after its
115
+ * first few elements and reads as the deliberate omission it is.
116
+ */
117
+ const MIN_AX_TREE_BYTES = 256;
118
+
119
+ /**
120
+ * Entries rendered by default, counted back from the most recent.
121
+ *
122
+ * A session observes on a cadence the user does not set, so entry count grows
123
+ * with wall-clock time and nothing about a long session makes its earliest
124
+ * minutes more worth reading than its last. Two hundred covers a session of
125
+ * ordinary length whole, and truncates a runaway one at the end the
126
+ * retrospective is about to reason over. `truncated` on the result says when
127
+ * that happened, so a caller that wants the rest asks for it.
128
+ */
129
+ export const DEFAULT_MAX_ENTRIES = 200;
130
+
131
+ /**
132
+ * Entries whose AX tree renders in full, counted back from the most recent.
133
+ *
134
+ * The trees are the bulk of a timeline by an order of magnitude and the part
135
+ * that ages worst: an old tree describes a screen that has since changed,
136
+ * while the diff recorded next to it still says what moved. Rendering the
137
+ * latest few in full and collapsing the rest keeps a long session inside a
138
+ * sane prompt without losing when anything happened or what changed.
139
+ */
140
+ export const DEFAULT_MAX_AX_TREES = 2;
141
+
142
+ /**
143
+ * Bytes of rendered timeline text the retrospective reads by default.
144
+ *
145
+ * The count bounds above cap how many entries render, not how large they are,
146
+ * and every retained string is emitted verbatim. A single AX tree runs to the
147
+ * macOS enumerator's ceiling of 10,000 elements
148
+ * (`AccessibilityTree.swift`), and diffs and narrations carry no length limit
149
+ * of their own, so counting entries is not a bound on the prompt. This is.
150
+ *
151
+ * 120 KB is roughly 30k tokens of dense UI text, about a seventh of a
152
+ * 200k-token window: enough for a long session's shape to survive intact,
153
+ * while leaving the retrospective room for the conversation it is summarizing
154
+ * and for its own reply. Callers that want more pass `maxRenderBytes`.
155
+ */
156
+ export const DEFAULT_MAX_RENDER_BYTES = 120_000;
157
+
158
+ /**
159
+ * The screen observation a timeline entry records, structurally the result the
160
+ * host computer-use proxy already returns (`CU_RESULT_SCHEMA` in
161
+ * `packages/electron-desktop/src/host-proxy/cu-executor.ts`). Declared here
162
+ * over exactly those field names rather than invented: the observation reaches
163
+ * this module straight off the wire, and a shape of our own would be a second
164
+ * definition to keep in step with the first.
165
+ */
166
+ export interface WatchObservationInput {
167
+ readonly axTree?: string;
168
+ readonly axDiff?: string;
169
+ readonly screenshot?: string;
170
+ readonly screenshotWidthPx?: number;
171
+ readonly screenshotHeightPx?: number;
172
+ readonly screenWidthPt?: number;
173
+ readonly screenHeightPt?: number;
174
+ readonly executionError?: string;
175
+ }
176
+
177
+ export type WatchEntryKind = "narration" | "observation";
178
+
179
+ /**
180
+ * One persisted timeline row, without its screenshot. The frame is reachable
181
+ * by id through {@link readWatchScreenshot}, so reading a session does not
182
+ * hydrate its pixels.
183
+ */
184
+ export interface WatchTimelineEntry {
185
+ readonly id: string;
186
+ readonly sessionId: string;
187
+ readonly conversationId: string;
188
+ readonly atMs: number;
189
+ readonly kind: WatchEntryKind;
190
+ readonly text: string;
191
+ readonly axTree: string | null;
192
+ readonly axDiff: string | null;
193
+ /** Size of the entry's screenshot, or null when it has none. */
194
+ readonly screenshotBytes: number | null;
195
+ readonly createdAt: number;
196
+ }
197
+
198
+ export type WatchAppendResult =
199
+ | { ok: true; entryId: string }
200
+ | {
201
+ ok: false;
202
+ reason:
203
+ | "empty"
204
+ | "observation_failed"
205
+ | "conversation_missing"
206
+ | "write_failed";
207
+ };
208
+
209
+ export interface WatchTimelineRenderOptions {
210
+ /** Entries to render, counted back from the most recent. */
211
+ readonly maxEntries?: number;
212
+ /** Entries whose AX tree renders in full, counted back from the most recent. */
213
+ readonly maxAxTrees?: number;
214
+ /** Bytes of rendered text to spend, newest entry first. */
215
+ readonly maxRenderBytes?: number;
216
+ }
217
+
218
+ export interface WatchTimelineRender {
219
+ /** The rendered timeline, one entry per block, oldest first. */
220
+ readonly text: string;
221
+ /** The entries `text` was rendered from, oldest first. */
222
+ readonly entries: readonly WatchTimelineEntry[];
223
+ /** Entries the session has, including any the bounds left out. */
224
+ readonly totalEntries: number;
225
+ /**
226
+ * True when the render is partial: the count bound left earlier entries out,
227
+ * the byte budget ran out before the oldest entry, or the budget cut an
228
+ * entry's own content short.
229
+ */
230
+ readonly truncated: boolean;
231
+ /**
232
+ * Ids of the rendered entries that carry a screenshot, oldest first, ready
233
+ * for {@link readWatchScreenshot}.
234
+ */
235
+ readonly screenshotEntryIds: readonly string[];
236
+ }
237
+
238
+ /**
239
+ * Render `atMs` (milliseconds since the session started) as the `[t+MM:SS]`
240
+ * prefix every entry carries. Hours appear only once there are any, so a
241
+ * typical session reads as `[t+04:12]` rather than `[t+00:04:12]`.
242
+ */
243
+ function formatOffset(atMs: number): string {
244
+ const totalSeconds = Number.isFinite(atMs)
245
+ ? Math.max(0, Math.floor(atMs / 1000))
246
+ : 0;
247
+ const pad = (value: number) => String(value).padStart(2, "0");
248
+ const seconds = pad(totalSeconds % 60);
249
+ const minutes = pad(Math.floor(totalSeconds / 60) % 60);
250
+ const hours = Math.floor(totalSeconds / 3600);
251
+ return hours > 0
252
+ ? `[t+${pad(hours)}:${minutes}:${seconds}]`
253
+ : `[t+${minutes}:${seconds}]`;
254
+ }
255
+
256
+ function normalizeAtMs(atMs: number): number {
257
+ return Number.isFinite(atMs) ? Math.max(0, Math.floor(atMs)) : 0;
258
+ }
259
+
260
+ /**
261
+ * Whether the conversation an entry is keyed to is still in the store.
262
+ *
263
+ * This is what keeps an append from outliving a delete. Every purge runs after
264
+ * the conversation rows it covers are gone, so an append that starts once a
265
+ * purge could no longer reach it finds nothing to key itself to and is
266
+ * refused. The check is a read rather than a foreign key because a cascade
267
+ * would delete on the store's terms rather than refuse on the append's, and a
268
+ * cascade cannot refuse a row that arrives afterwards at all.
269
+ */
270
+ function conversationStillExists(conversationId: string): boolean {
271
+ return (
272
+ getDb()
273
+ .select({ id: conversations.id })
274
+ .from(conversations)
275
+ .where(eq(conversations.id, conversationId))
276
+ .get() !== undefined
277
+ );
278
+ }
279
+
280
+ /** The row an append writes, screenshot included. */
281
+ type WatchTimelineRow = typeof watchTimelineEntries.$inferInsert;
282
+
283
+ /**
284
+ * Persist one entry, refusing one a deletion has overtaken.
285
+ *
286
+ * The check and the insert are one synchronous step, so no purge can run
287
+ * between them: the entry either predates the purge that covers it or is
288
+ * turned away here.
289
+ */
290
+ function insertEntry(row: WatchTimelineRow): WatchAppendResult {
291
+ if (!conversationStillExists(row.conversationId)) {
292
+ log.debug(
293
+ { sessionId: row.sessionId, conversationId: row.conversationId },
294
+ "Dropping a watch timeline entry for a conversation that is gone",
295
+ );
296
+ return { ok: false, reason: "conversation_missing" };
297
+ }
298
+ try {
299
+ getDb().insert(watchTimelineEntries).values(row).run();
300
+ return { ok: true, entryId: row.id };
301
+ } catch (err) {
302
+ log.warn(
303
+ { err, sessionId: row.sessionId, kind: row.kind },
304
+ "Failed to persist a watch timeline entry",
305
+ );
306
+ return { ok: false, reason: "write_failed" };
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Decode an observation's screenshot into the bytes the row carries, or null
312
+ * when there is nothing worth storing.
313
+ *
314
+ * A session degrades to a timeline with fewer images rather than to no
315
+ * timeline, so a frame that decodes to nothing or overruns
316
+ * {@link MAX_SCREENSHOT_BYTES} logs and leaves the entry's screenshot null.
317
+ */
318
+ function decodeScreenshot(
319
+ sessionId: string,
320
+ atMs: number,
321
+ base64: string,
322
+ ): Buffer | null {
323
+ const bytes = Buffer.from(base64, "base64");
324
+ if (bytes.length === 0) {
325
+ log.warn({ sessionId, atMs }, "Discarding an undecodable watch screenshot");
326
+ return null;
327
+ }
328
+ if (bytes.length > MAX_SCREENSHOT_BYTES) {
329
+ log.warn(
330
+ { sessionId, atMs, bytes: bytes.length },
331
+ "Discarding a watch screenshot over the per-entry size cap",
332
+ );
333
+ return null;
334
+ }
335
+ return bytes;
336
+ }
337
+
338
+ /** Append what the user said at `atMs` milliseconds into the session. */
339
+ export function appendNarration(
340
+ sessionId: string,
341
+ options: { conversationId: string; text: string; atMs: number },
342
+ ): WatchAppendResult {
343
+ const text = options.text.trim();
344
+ if (text.length === 0) {
345
+ return { ok: false, reason: "empty" };
346
+ }
347
+ return insertEntry({
348
+ id: randomUUID(),
349
+ sessionId,
350
+ conversationId: options.conversationId,
351
+ atMs: normalizeAtMs(options.atMs),
352
+ kind: "narration",
353
+ text,
354
+ axTree: null,
355
+ axDiff: null,
356
+ screenshot: null,
357
+ createdAt: Date.now(),
358
+ });
359
+ }
360
+
361
+ /**
362
+ * Append what was on screen at `atMs` milliseconds into the session.
363
+ *
364
+ * A failed or empty observation appends nothing: a row saying the screen could
365
+ * not be read is a row the retrospective has to reason about, and the honest
366
+ * timeline of a session where observation stalled is simply a sparser one.
367
+ *
368
+ * The screenshot is stored only when `attachScreenshot` asks for it, and
369
+ * carrying one is not asking. The host captures a screenshot on every observe
370
+ * with no opt-out, so an observation always has pixels available and a policy
371
+ * of "store what arrives" is a policy of storing every frame. Which frames are
372
+ * worth an image is a cadence decision, and it belongs to the caller driving
373
+ * the session rather than to the row writer.
374
+ *
375
+ * A screenshot the caller asked to keep is content on its own, so an
376
+ * observation carrying one is never empty. The host falls back to a bare
377
+ * screenshot whenever accessibility enumeration yields no focused window
378
+ * (`HostCuExecutor.swift`), which is the ordinary shape for an inaccessible
379
+ * app: requiring a tree or a diff would make watching one produce an empty
380
+ * timeline while the frames the user asked for were being discarded.
381
+ */
382
+ export function appendObservation(
383
+ sessionId: string,
384
+ options: {
385
+ conversationId: string;
386
+ observation: WatchObservationInput;
387
+ atMs: number;
388
+ /** Store the observation's screenshot. Defaults to false. */
389
+ attachScreenshot?: boolean;
390
+ },
391
+ ): WatchAppendResult {
392
+ const { observation } = options;
393
+ if (observation.executionError) {
394
+ log.debug(
395
+ { sessionId, executionError: observation.executionError },
396
+ "Skipping a failed watch observation",
397
+ );
398
+ return { ok: false, reason: "observation_failed" };
399
+ }
400
+ const captured =
401
+ options.attachScreenshot === true ? observation.screenshot : undefined;
402
+ if (!observation.axTree && !observation.axDiff && !captured) {
403
+ return { ok: false, reason: "empty" };
404
+ }
405
+
406
+ const atMs = normalizeAtMs(options.atMs);
407
+ const screenshot = captured
408
+ ? decodeScreenshot(sessionId, atMs, captured)
409
+ : null;
410
+
411
+ // A screenshot-only observation whose frame was discarded carries nothing at
412
+ // all, so it falls back to the empty case rather than persisting a blank row.
413
+ if (!observation.axTree && !observation.axDiff && !screenshot) {
414
+ return { ok: false, reason: "empty" };
415
+ }
416
+
417
+ return insertEntry({
418
+ id: randomUUID(),
419
+ sessionId,
420
+ conversationId: options.conversationId,
421
+ atMs,
422
+ kind: "observation",
423
+ text: "",
424
+ axTree: observation.axTree ?? null,
425
+ axDiff: observation.axDiff ?? null,
426
+ screenshot,
427
+ createdAt: Date.now(),
428
+ });
429
+ }
430
+
431
+ /**
432
+ * Read one entry's screenshot, or null when it has none.
433
+ *
434
+ * Frames are fetched one at a time because a render's worth of them is the
435
+ * only part of a timeline large enough to matter in memory, and a caller
436
+ * attaching images knows which moments it wants.
437
+ */
438
+ export function readWatchScreenshot(
439
+ entryId: string,
440
+ ): { mimeType: string; bytes: Buffer } | null {
441
+ const row = getDb()
442
+ .select({ screenshot: watchTimelineEntries.screenshot })
443
+ .from(watchTimelineEntries)
444
+ .where(eq(watchTimelineEntries.id, entryId))
445
+ .get();
446
+ if (!row?.screenshot) {
447
+ return null;
448
+ }
449
+ return { mimeType: WATCH_SCREENSHOT_MIME, bytes: row.screenshot };
450
+ }
451
+
452
+ /** How many entries the session has, including any a read leaves out. */
453
+ function countEntries(sessionId: string): number {
454
+ return (
455
+ getDb()
456
+ .select({ total: count() })
457
+ .from(watchTimelineEntries)
458
+ .where(eq(watchTimelineEntries.sessionId, sessionId))
459
+ .get()?.total ?? 0
460
+ );
461
+ }
462
+
463
+ /**
464
+ * Read the newest `limit` entries of a session, oldest first.
465
+ *
466
+ * The bound is the SQL `LIMIT`, not a slice of the result: every row carries an
467
+ * AX tree that runs to the macOS enumerator's ceiling, so a session-wide select
468
+ * hydrates the whole session into memory before any render bound has a say. The
469
+ * descending order is the exact inverse of the ascending one the rows are
470
+ * rendered in, so taking the newest `limit` and reversing them gives the same
471
+ * tail an ordered read would end with.
472
+ *
473
+ * The screenshot column is measured rather than selected, so the render learns
474
+ * which entries have a frame and how large it is without pulling the pixels.
475
+ */
476
+ function readNewestEntries(
477
+ sessionId: string,
478
+ limit: number,
479
+ ): WatchTimelineEntry[] {
480
+ if (limit <= 0) {
481
+ return [];
482
+ }
483
+ const rows = getDb()
484
+ .select({
485
+ id: watchTimelineEntries.id,
486
+ sessionId: watchTimelineEntries.sessionId,
487
+ conversationId: watchTimelineEntries.conversationId,
488
+ atMs: watchTimelineEntries.atMs,
489
+ kind: watchTimelineEntries.kind,
490
+ text: watchTimelineEntries.text,
491
+ axTree: watchTimelineEntries.axTree,
492
+ axDiff: watchTimelineEntries.axDiff,
493
+ screenshotBytes: sql<
494
+ number | null
495
+ >`length(${watchTimelineEntries.screenshot})`,
496
+ createdAt: watchTimelineEntries.createdAt,
497
+ })
498
+ .from(watchTimelineEntries)
499
+ .where(eq(watchTimelineEntries.sessionId, sessionId))
500
+ .orderBy(
501
+ desc(watchTimelineEntries.atMs),
502
+ desc(watchTimelineEntries.createdAt),
503
+ desc(watchTimelineEntries.id),
504
+ )
505
+ .limit(limit)
506
+ .all()
507
+ .map((row) => ({ ...row, kind: row.kind as WatchEntryKind }));
508
+ rows.reverse();
509
+ return rows;
510
+ }
511
+
512
+ function byteLength(value: string): number {
513
+ return Buffer.byteLength(value, "utf8");
514
+ }
515
+
516
+ /**
517
+ * Clip `value` to `maxBytes` of UTF-8 and mark the cut.
518
+ *
519
+ * Slicing by bytes can land inside a multi-byte character, so the replacement
520
+ * character the decode leaves at the tail is dropped.
521
+ */
522
+ function clip(
523
+ value: string,
524
+ maxBytes: number,
525
+ ): { text: string; truncated: boolean } {
526
+ if (byteLength(value) <= maxBytes) {
527
+ return { text: value, truncated: false };
528
+ }
529
+ const keep = Math.max(0, maxBytes - TRUNCATION_MARKER.length - 1);
530
+ const head = Buffer.from(value, "utf8")
531
+ .subarray(0, keep)
532
+ .toString("utf8")
533
+ .replace(/\uFFFD+$/, "");
534
+ return { text: `${head}\n${TRUNCATION_MARKER}`, truncated: true };
535
+ }
536
+
537
+ /** One entry rendered into the budget it was given. */
538
+ interface RenderedBlock {
539
+ readonly text: string;
540
+ readonly truncated: boolean;
541
+ }
542
+
543
+ /**
544
+ * Render one entry into at most `maxBytes`, with `renderAxTree` deciding
545
+ * whether its tree is spelled out.
546
+ *
547
+ * The offset prefix, the diff, and the notes are paid for before the tree. The
548
+ * tree is the bulk of an entry and the part that ages worst, so an entry the
549
+ * budget squeezes keeps when it happened and what moved and gives up the full
550
+ * screen. A screen the host captured but could not enumerate says so, so the
551
+ * retrospective can tell "nothing was on screen" from "the screen was not
552
+ * readable" and reach for the image instead.
553
+ */
554
+ function renderEntry(
555
+ entry: WatchTimelineEntry,
556
+ renderAxTree: boolean,
557
+ maxBytes: number,
558
+ ): RenderedBlock {
559
+ const offset = formatOffset(entry.atMs);
560
+ if (entry.kind === "narration") {
561
+ const head = `${offset} ${NARRATION_LABEL} `;
562
+ const body = clip(entry.text, Math.max(0, maxBytes - byteLength(head)));
563
+ return { text: `${head}${body.text}`, truncated: body.truncated };
564
+ }
565
+
566
+ const header = `${offset} ${OBSERVATION_LABEL}`;
567
+ const notes: string[] = [];
568
+ if (!entry.axTree && !entry.axDiff) {
569
+ notes.push(AX_TREE_UNAVAILABLE);
570
+ }
571
+ if (entry.screenshotBytes !== null) {
572
+ notes.push(SCREENSHOT_NOTE);
573
+ }
574
+
575
+ let remaining = maxBytes - byteLength(header);
576
+ for (const note of notes) {
577
+ remaining -= byteLength(note) + 1;
578
+ }
579
+
580
+ let truncated = false;
581
+
582
+ let diffBlock: string | null = null;
583
+ if (entry.axDiff) {
584
+ const prefix = "changed since the previous observation:\n";
585
+ const body = clip(
586
+ entry.axDiff,
587
+ Math.max(0, remaining - byteLength(prefix) - 1),
588
+ );
589
+ diffBlock = `${prefix}${body.text}`;
590
+ truncated = truncated || body.truncated;
591
+ remaining -= byteLength(diffBlock) + 1;
592
+ }
593
+
594
+ let treeBlock: string | null = null;
595
+ if (entry.axTree) {
596
+ const open = "<ax-tree>\n";
597
+ const close = "\n</ax-tree>";
598
+ const room = remaining - byteLength(open) - byteLength(close) - 1;
599
+ if (!renderAxTree || room < MIN_AX_TREE_BYTES) {
600
+ treeBlock = AX_TREE_OMITTED;
601
+ // The count bound collapsing a tree is the documented default; the
602
+ // budget collapsing one is a loss the caller has to hear about.
603
+ truncated = truncated || renderAxTree;
604
+ } else {
605
+ const body = clip(escapeAxTreeContent(entry.axTree), room);
606
+ treeBlock = `${open}${body.text}${close}`;
607
+ truncated = truncated || body.truncated;
608
+ }
609
+ }
610
+
611
+ const parts = [header];
612
+ if (treeBlock !== null) {
613
+ parts.push(treeBlock);
614
+ }
615
+ if (diffBlock !== null) {
616
+ parts.push(diffBlock);
617
+ }
618
+ parts.push(...notes);
619
+ return { text: parts.join("\n"), truncated };
620
+ }
621
+
622
+ /**
623
+ * Render a session's timeline for the retrospective.
624
+ *
625
+ * Two bounds apply. The count bounds pick which entries are candidates and
626
+ * which of their trees are spelled out; the byte budget then decides how much
627
+ * of that actually fits, because a count is no bound at all on text that is
628
+ * emitted verbatim. The budget is spent newest entry first, so the material
629
+ * closest to the moment the retrospective is about is the material that
630
+ * survives, and an entry too large for what is left is clipped with a marker
631
+ * rather than dropped without one.
632
+ *
633
+ * The AX tree comes before the diff in an observation deliberately: the tree
634
+ * is what the bounds collapse, so putting it first leaves the offset prefix
635
+ * and the diff intact in an entry whose tree was left out.
636
+ *
637
+ * The result carries what the retrospective needs to decide how much of this
638
+ * to use: how many entries the session actually has, whether anything was cut,
639
+ * and the ids of the rendered entries that have a screenshot, so it can fetch
640
+ * the images it wants and no others.
641
+ */
642
+ export function renderWatchTimeline(
643
+ sessionId: string,
644
+ options?: WatchTimelineRenderOptions,
645
+ ): WatchTimelineRender {
646
+ const maxEntries = Math.max(0, options?.maxEntries ?? DEFAULT_MAX_ENTRIES);
647
+ const maxAxTrees = Math.max(0, options?.maxAxTrees ?? DEFAULT_MAX_AX_TREES);
648
+ const maxRenderBytes = Math.max(
649
+ 0,
650
+ options?.maxRenderBytes ?? DEFAULT_MAX_RENDER_BYTES,
651
+ );
652
+
653
+ const totalEntries = countEntries(sessionId);
654
+ const candidates = readNewestEntries(sessionId, maxEntries);
655
+
656
+ const treeIndices = candidates
657
+ .map((entry, index) => (entry.axTree ? index : -1))
658
+ .filter((index) => index >= 0);
659
+ const fullTreeFrom = treeIndices.length - maxAxTrees;
660
+ const renderFullTree = new Set(treeIndices.slice(Math.max(0, fullTreeFrom)));
661
+
662
+ const blocks: string[] = [];
663
+ const entries: WatchTimelineEntry[] = [];
664
+ let remaining = maxRenderBytes;
665
+ let clipped = false;
666
+
667
+ for (let index = candidates.length - 1; index >= 0; index--) {
668
+ const entry = candidates[index];
669
+ if (!entry) {
670
+ continue;
671
+ }
672
+ const separator = blocks.length > 0 ? byteLength(BLOCK_SEPARATOR) : 0;
673
+ const available = remaining - separator;
674
+ if (available < MIN_ENTRY_BYTES) {
675
+ clipped = true;
676
+ break;
677
+ }
678
+ const block = renderEntry(entry, renderFullTree.has(index), available);
679
+ blocks.push(block.text);
680
+ entries.push(entry);
681
+ remaining -= separator + byteLength(block.text);
682
+ clipped = clipped || block.truncated;
683
+ }
684
+
685
+ blocks.reverse();
686
+ entries.reverse();
687
+
688
+ return {
689
+ text: blocks.join(BLOCK_SEPARATOR),
690
+ entries,
691
+ totalEntries,
692
+ truncated: clipped || entries.length < totalEntries,
693
+ screenshotEntryIds: entries
694
+ .filter((entry) => entry.screenshotBytes !== null)
695
+ .map((entry) => entry.id),
696
+ };
697
+ }
698
+
699
+ /**
700
+ * Delete the timeline entries matching `where` and return how many went.
701
+ *
702
+ * One statement takes the narration, the screen, and the pixels together, and
703
+ * `RETURNING` counts them without a second pass. It runs in process and
704
+ * synchronously, which is what makes it uninterruptible: no append can slip
705
+ * between the rows this statement matches and the rows it removes.
706
+ */
707
+ function purgeEntries(where: SQL | undefined): number {
708
+ return getDb()
709
+ .delete(watchTimelineEntries)
710
+ .where(where)
711
+ .returning({ id: watchTimelineEntries.id })
712
+ .all().length;
713
+ }
714
+
715
+ /**
716
+ * Delete every timeline entry belonging to a conversation and return how many
717
+ * went. Every conversation-delete path calls this: the entries hold frames of
718
+ * the user's screen, so a delete that left them behind would strand the
719
+ * conversation's most sensitive artifact in the database.
720
+ *
721
+ * Call it after the `conversations` row is gone. An append that arrives later
722
+ * has nothing to key itself to and is refused, so the purge is the last thing
723
+ * that has to reach a timeline row rather than one step among several.
724
+ */
725
+ export function purgeWatchTimelineForConversation(
726
+ conversationId: string,
727
+ ): number {
728
+ return purgeEntries(eq(watchTimelineEntries.conversationId, conversationId));
729
+ }
730
+
731
+ /**
732
+ * Delete every timeline entry in the store and return how many went. The
733
+ * clear-all wipe's counterpart to
734
+ * {@link purgeWatchTimelineForConversation}, with the same ordering
735
+ * requirement: it runs after `conversations` is emptied, so an append racing
736
+ * the wipe either lands before this statement and is swept by it or arrives
737
+ * afterwards and is refused.
738
+ */
739
+ export function purgeAllWatchTimelines(): number {
740
+ return purgeEntries(undefined);
741
+ }
742
+
743
+ /**
744
+ * Entries one sweep pass removes.
745
+ *
746
+ * A session runs to hundreds of entries, so a pass covers several whole
747
+ * sessions of residue while the statements it issues stay bounded rather than
748
+ * scaling with however large a backlog grew. Anything past the bound is left
749
+ * for the next pass.
750
+ */
751
+ const MAX_SWEEP_ENTRIES = 5_000;
752
+
753
+ /**
754
+ * Delete timeline entries whose conversation is no longer in the store and
755
+ * return how many went.
756
+ *
757
+ * This is the recovery path for a purge that did not happen.
758
+ * {@link purgeWatchTimelineForConversation} runs after the `conversations` row
759
+ * is already committed as deleted, so its caller reports a completed delete
760
+ * even when the purge fails, and a crash between those two writes leaves the
761
+ * same residue. Nothing cascades into this table, so without a sweep either
762
+ * case keeps narration, AX trees, and screenshots of the user for as long as
763
+ * the database lives.
764
+ *
765
+ * Two bounded statements rather than one anti-join `DELETE`: a `LIMIT`ed select
766
+ * picks a page of orphan ids, then the delete matches them by primary key.
767
+ * Conversation ids are never reused, so a row the select called an orphan is
768
+ * still one by the time the delete runs.
769
+ *
770
+ * Best-effort and idempotent. It runs from daemon startup and from database
771
+ * maintenance, neither of which has anything useful to do with a failure, so a
772
+ * failing statement logs and reports nothing swept and the next pass tries
773
+ * again; a second run over swept rows finds none.
774
+ */
775
+ export function sweepOrphanedWatchTimelineEntries(): number {
776
+ try {
777
+ const db = getDb();
778
+ const orphanIds = db
779
+ .select({ id: watchTimelineEntries.id })
780
+ .from(watchTimelineEntries)
781
+ .where(
782
+ notExists(
783
+ db
784
+ .select({ id: conversations.id })
785
+ .from(conversations)
786
+ .where(eq(conversations.id, watchTimelineEntries.conversationId)),
787
+ ),
788
+ )
789
+ .limit(MAX_SWEEP_ENTRIES)
790
+ .all()
791
+ .map((row) => row.id);
792
+ if (orphanIds.length === 0) {
793
+ return 0;
794
+ }
795
+ return purgeEntries(inArray(watchTimelineEntries.id, orphanIds));
796
+ } catch (err) {
797
+ log.warn({ err }, "Failed to sweep orphaned watch timeline entries");
798
+ return 0;
799
+ }
800
+ }
801
+
802
+ /**
803
+ * How many pages one drain will take before it stops.
804
+ *
805
+ * A ceiling on the work a single drain can do, not on the backlog it expects:
806
+ * at {@link MAX_SWEEP_ENTRIES} a page this covers half a million rows, far past
807
+ * any plausible residue. It exists so a page that reports rows swept without
808
+ * shrinking the orphan set cannot spin, and whatever a capped drain leaves is
809
+ * picked up by the next one.
810
+ */
811
+ const MAX_SWEEP_PASSES = 100;
812
+
813
+ /**
814
+ * Sweep until a pass comes back short, and return everything it removed.
815
+ *
816
+ * {@link sweepOrphanedWatchTimelineEntries} is deliberately one page, which is
817
+ * what the periodic maintenance pass wants: bounded work on a tick that has
818
+ * other things to do. Startup wants the opposite. It runs once, and on an
819
+ * install where database maintenance never runs (it is driven by the memory
820
+ * plugin's jobs worker, so a disabled plugin or `memory.enabled: false` stops
821
+ * it), a single page is the only sweep the residue will ever see. A backlog
822
+ * larger than one page would then keep narration, AX trees, and screenshots of
823
+ * the user for the life of the database, which is the outcome the sweep exists
824
+ * to prevent.
825
+ *
826
+ * A short page means the orphan set is exhausted, so the drain stops there
827
+ * rather than paying for a pass that finds nothing. A failing page reports zero
828
+ * and ends the drain; the next startup tries again.
829
+ *
830
+ * Async only to yield between pages. A page is a synchronous select and a
831
+ * synchronous delete of rows carrying image blobs, and the caller runs at
832
+ * startup with the HTTP server already bound, so a multi-page drain that never
833
+ * came up for air would hold the event loop through every one of them and stall
834
+ * requests the server has begun accepting. Yielding costs a macrotask per page
835
+ * and gives that time back.
836
+ */
837
+ export async function drainOrphanedWatchTimelineEntries(): Promise<number> {
838
+ let total = 0;
839
+ for (let pass = 0; pass < MAX_SWEEP_PASSES; pass += 1) {
840
+ const swept = sweepOrphanedWatchTimelineEntries();
841
+ total += swept;
842
+ if (swept < MAX_SWEEP_ENTRIES) {
843
+ break;
844
+ }
845
+ await new Promise((resolve) => setTimeout(resolve, 0));
846
+ }
847
+ return total;
848
+ }