@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.
- package/AGENTS.md +5 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
- package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +70 -0
- package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +16 -1
- package/node_modules/@vellumai/gateway-client/src/index.ts +6 -2
- package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +121 -61
- package/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
- package/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
- package/openapi.yaml +421 -15
- package/package.json +1 -1
- package/scripts/sync-web-search-catalog.ts +6 -0
- package/src/__tests__/app-pin-store.test.ts +149 -0
- package/src/__tests__/channel-availability-routes.test.ts +23 -1
- package/src/__tests__/channel-readiness-discord.test.ts +231 -0
- package/src/__tests__/channel-readiness-service.test.ts +126 -0
- package/src/__tests__/channel-readiness-slack-remote.test.ts +141 -0
- package/src/__tests__/channel-reply-delivery.test.ts +4 -4
- package/src/__tests__/client-os-metadata-persistence.test.ts +23 -10
- package/src/__tests__/conversation-delete-watch-timeline.test.ts +231 -0
- package/src/__tests__/conversation-error.test.ts +17 -0
- package/src/__tests__/conversation-seed-composer.test.ts +8 -0
- package/src/__tests__/conversation-slash-commands.test.ts +8 -0
- package/src/__tests__/disk-pressure-policy.test.ts +6 -0
- package/src/__tests__/gemini-provider.test.ts +138 -0
- package/src/__tests__/history-repair.test.ts +105 -3
- package/src/__tests__/identity-routes.test.ts +1 -0
- package/src/__tests__/llm-catalog-parity.test.ts +45 -0
- package/src/__tests__/migration-import-from-path.test.ts +349 -0
- package/src/__tests__/notification-telegram-adapter.test.ts +102 -0
- package/src/__tests__/oauth-commands-routes.test.ts +89 -0
- package/src/__tests__/oauth-provider-profiles.test.ts +7 -6
- package/src/__tests__/openai-provider.test.ts +18 -0
- package/src/__tests__/openai-responses-provider.test.ts +18 -0
- package/src/__tests__/platform-callback-registration.test.ts +184 -0
- package/src/__tests__/plugin-api-store-credential.test.ts +71 -3
- package/src/__tests__/pricing.test.ts +2 -2
- package/src/__tests__/public-ingress-urls.test.ts +36 -0
- package/src/__tests__/resolve-trust-class.test.ts +0 -48
- package/src/__tests__/sanitize-config-for-transfer.test.ts +28 -0
- package/src/__tests__/secret-routes-platform-proxy.test.ts +49 -0
- package/src/__tests__/settings-routes.test.ts +85 -3
- package/src/__tests__/web-search-catalog-parity.test.ts +8 -0
- package/src/agent/history-repair/history-repair.ts +45 -14
- package/src/agent/loop.ts +4 -1
- package/src/api/constants/profile-config-validation.ts +60 -0
- package/src/api/events/tool-result.ts +6 -1
- package/src/api/events/watch-retro-completed.ts +52 -0
- package/src/api/index.ts +11 -0
- package/src/apps/app-pin-reconciler.ts +92 -0
- package/src/apps/app-pin-store.ts +125 -0
- package/src/channels/gateway-channel-socket-health.ts +32 -0
- package/src/channels/gateway-discord-admission.ts +32 -0
- package/src/channels/types.ts +20 -0
- package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -1
- package/src/cli/commands/__tests__/inference-profiles.test.ts +16 -4
- package/src/cli/commands/__tests__/inference-providers.test.ts +67 -2
- package/src/cli/commands/channels/__tests__/channels.test.ts +85 -0
- package/src/cli/commands/channels/index.ts +45 -31
- package/src/cli/commands/inference-profiles.ts +56 -3
- package/src/cli/commands/inference-providers.ts +28 -2
- package/src/cli/commands/oauth/index.help.ts +7 -1
- package/src/cli/commands/oauth/request.test.ts +290 -0
- package/src/cli/commands/oauth/request.ts +57 -41
- package/src/cli/lib/bundled-marketplace.json +14 -1
- package/src/cli/lib/open-browser.test.ts +67 -0
- package/src/cli/lib/open-browser.ts +24 -5
- package/src/config/__tests__/profile-materialization.test.ts +26 -0
- package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +6 -0
- package/src/config/bundled-skills/schedule/SKILL.md +1 -1
- package/src/config/feature-flag-registry.json +17 -1
- package/src/config/profile-materialization.ts +29 -0
- package/src/config/sanitize-for-transfer.ts +16 -0
- package/src/config/schemas/llm.ts +7 -0
- package/src/config/schemas/services.ts +6 -0
- package/src/context/outbound-sanitize.ts +6 -0
- package/src/daemon/__tests__/lifecycle-watch-timeline-sweep.test.ts +98 -0
- package/src/daemon/conversation-error.ts +24 -2
- package/src/daemon/conversation-slash.ts +6 -15
- package/src/daemon/daemon-control.ts +1 -0
- package/src/daemon/disk-pressure-policy.ts +7 -1
- package/src/daemon/handlers/__tests__/config-ingress-tunnel-records.test.ts +208 -0
- package/src/daemon/handlers/config-ingress.ts +115 -5
- package/src/daemon/lifecycle.ts +26 -0
- package/src/daemon/message-types/web-activity.ts +3 -2
- package/src/daemon/trust-context.ts +0 -37
- package/src/inbound/__tests__/tunnel-probe.test.ts +448 -0
- package/src/inbound/platform-callback-registration.ts +28 -2
- package/src/inbound/public-ingress-urls.ts +12 -0
- package/src/inbound/tunnel-probe.ts +261 -0
- package/src/live-voice/__tests__/live-voice-connection.test.ts +25 -0
- package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +8 -2
- package/src/live-voice/__tests__/live-voice-session-manager.test.ts +212 -10
- package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +5 -2
- package/src/live-voice/live-voice-connection.ts +46 -8
- package/src/live-voice/live-voice-manager.ts +25 -0
- package/src/live-voice/live-voice-session-manager.ts +318 -2
- package/src/live-voice/live-voice-session.ts +52 -2
- package/src/messaging/providers/__tests__/transport-dispatch.test.ts +126 -68
- package/src/messaging/providers/channel-transport.ts +64 -47
- package/src/messaging/providers/discord/send.test.ts +46 -1
- package/src/messaging/providers/discord/send.ts +51 -0
- package/src/messaging/providers/discord/transport.ts +26 -3
- package/src/messaging/providers/index.ts +22 -47
- package/src/messaging/providers/slack/send.test.ts +83 -26
- package/src/messaging/providers/slack/send.ts +120 -51
- package/src/messaging/providers/slack/stream-tasks.test.ts +26 -0
- package/src/messaging/providers/slack/stream-tasks.ts +39 -0
- package/src/messaging/providers/slack/transport.ts +24 -22
- package/src/messaging/providers/telegram-bot/send.test.ts +109 -12
- package/src/messaging/providers/telegram-bot/send.ts +43 -0
- package/src/messaging/providers/telegram-bot/transport.ts +25 -8
- package/src/notifications/__tests__/assistant-reply-producer.test.ts +30 -7
- package/src/notifications/adapters/telegram.ts +48 -1
- package/src/notifications/assistant-reply-producer.ts +7 -7
- package/src/notifications/conversation-seed-composer.ts +7 -2
- package/src/oauth/byo-connection.test.ts +63 -0
- package/src/oauth/byo-connection.ts +16 -15
- package/src/oauth/connection.test.ts +111 -0
- package/src/oauth/connection.ts +142 -1
- package/src/oauth/platform-connection.test.ts +34 -0
- package/src/oauth/platform-connection.ts +28 -5
- package/src/oauth/seed-providers.ts +15 -1
- package/src/permissions/types.ts +3 -1
- package/src/persistence/conversation-crud.ts +46 -0
- package/src/persistence/conversation-types.ts +11 -9
- package/src/persistence/db-async-query.ts +2 -1
- package/src/persistence/db-maintenance.ts +15 -0
- package/src/persistence/embeddings/qdrant-manager.ts +1 -0
- package/src/persistence/migrations/367-create-watch-timeline-entries.ts +46 -0
- package/src/persistence/migrations/368-watch-timeline-screenshot-blob.ts +33 -0
- package/src/persistence/migrations/369-create-app-pins.ts +37 -0
- package/src/persistence/migrations/__tests__/367-create-watch-timeline-entries.test.ts +98 -0
- package/src/persistence/migrations/__tests__/368-watch-timeline-screenshot-blob.test.ts +98 -0
- package/src/persistence/schema/index.ts +1 -0
- package/src/persistence/schema/infrastructure.ts +17 -0
- package/src/persistence/schema/watch.ts +29 -0
- package/src/persistence/steps.ts +6 -0
- package/src/plugins/mtime-cache.ts +11 -0
- package/src/providers/__tests__/retry-network-error.test.ts +84 -0
- package/src/providers/connection-resolution.ts +23 -1
- package/src/providers/content-blocks.ts +9 -0
- package/src/providers/fetch-provider-catalog.ts +19 -0
- package/src/providers/gemini/client.ts +13 -5
- package/src/providers/inference/__tests__/endpoint-probe.test.ts +92 -0
- package/src/providers/inference/__tests__/profile-config-validation.test.ts +39 -0
- package/src/providers/inference/__tests__/profile-probe-classify.test.ts +66 -0
- package/src/providers/inference/adapter-factory.ts +0 -9
- package/src/providers/inference/credential-rotation.ts +61 -0
- package/src/providers/inference/endpoint-probe.ts +115 -0
- package/src/providers/inference/profile-probe.ts +256 -0
- package/src/providers/model-catalog.ts +170 -125
- package/src/providers/openai/__tests__/api-error-normalization.test.ts +17 -1
- package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +42 -60
- package/src/providers/openai/__tests__/connection-error-wrap.test.ts +44 -0
- package/src/providers/openai/__tests__/orphan-tool-result-guard.test.ts +34 -2
- package/src/providers/openai/api-error-normalization.ts +16 -2
- package/src/providers/openai/chat-completions-provider.ts +75 -29
- package/src/providers/openai/responses-provider.ts +5 -2
- package/src/providers/openrouter/client.ts +0 -1
- package/src/providers/provider-send-message.ts +11 -0
- package/src/providers/retry.ts +6 -0
- package/src/providers/search-provider-catalog.ts +20 -0
- package/src/providers/vercel-ai-gateway/client.ts +0 -1
- package/src/runtime/AGENTS.md +1 -0
- package/src/runtime/__tests__/desktop-presence.test.ts +27 -4
- package/src/runtime/__tests__/host-observe.test.ts +302 -0
- package/src/runtime/channel-readiness-service.ts +214 -14
- package/src/runtime/channel-readiness-types.ts +49 -2
- package/src/runtime/channel-reply-delivery.ts +2 -2
- package/src/runtime/desktop-presence.ts +24 -21
- package/src/runtime/host-observe.ts +246 -0
- package/src/runtime/http-server.ts +181 -1
- package/src/runtime/migrations/__tests__/staged-import-path.test.ts +104 -0
- package/src/runtime/migrations/staged-import-path.ts +116 -0
- package/src/runtime/routes/__tests__/app-pin-routes.test.ts +383 -0
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +80 -0
- package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +118 -0
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +20 -0
- package/src/runtime/routes/__tests__/ingress-status-routes.test.ts +508 -0
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +35 -56
- package/src/runtime/routes/__tests__/watch-routes-guardian-cache.test.ts +139 -0
- package/src/runtime/routes/__tests__/watch-routes.test.ts +598 -0
- package/src/runtime/routes/app-management-routes.ts +140 -29
- package/src/runtime/routes/channel-availability-routes.ts +1 -0
- package/src/runtime/routes/channel-readiness-routes.ts +14 -2
- package/src/runtime/routes/conversation-query-routes.ts +10 -0
- package/src/runtime/routes/guardian-approval-interception.ts +24 -33
- package/src/runtime/routes/host-cu-routes.ts +18 -0
- package/src/runtime/routes/identity-routes.ts +2 -0
- package/src/runtime/routes/inbound-message-handler.ts +10 -7
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +166 -308
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +158 -335
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/inference-profiles-routes.ts +232 -31
- package/src/runtime/routes/inference-provider-connection-routes.ts +24 -4
- package/src/runtime/routes/ingress-status-routes.ts +180 -0
- package/src/runtime/routes/live-voice-routes.test.ts +40 -1
- package/src/runtime/routes/live-voice-routes.ts +34 -0
- package/src/runtime/routes/migration-routes.ts +218 -10
- package/src/runtime/routes/oauth-commands-routes.ts +23 -16
- package/src/runtime/routes/plugins-routes.ts +12 -28
- package/src/runtime/routes/question-routes.ts +6 -0
- package/src/runtime/routes/secret-routes.ts +7 -27
- package/src/runtime/routes/settings-routes.ts +9 -6
- package/src/runtime/routes/watch-routes.ts +807 -0
- package/src/runtime/slack-reply-session.test.ts +230 -121
- package/src/runtime/slack-reply-session.ts +113 -81
- package/src/runtime/{slack-task-progress.test.ts → task-progress.test.ts} +1 -28
- package/src/runtime/{slack-task-progress.ts → task-progress.ts} +30 -51
- package/src/security/__tests__/untrusted-content.test.ts +42 -0
- package/src/security/untrusted-content.ts +28 -9
- package/src/telemetry/__tests__/live-voice-funnel.test.ts +108 -0
- package/src/telemetry/live-voice-funnel.ts +75 -8
- package/src/tools/credentials/store.ts +18 -6
- package/src/tools/network/__tests__/firecrawl-compat.test.ts +77 -0
- package/src/tools/network/__tests__/web-fetch-fastcrw.test.ts +169 -0
- package/src/tools/network/__tests__/web-search.test.ts +97 -2
- package/src/tools/network/firecrawl-compat.ts +90 -0
- package/src/tools/network/web-fetch.ts +142 -62
- package/src/tools/network/web-search.ts +141 -55
- package/src/tools/types.ts +2 -1
- package/src/util/oauth-request-body.test.ts +74 -0
- package/src/util/oauth-request-body.ts +60 -0
- package/src/util/worker-process.ts +1 -0
- package/src/watch/__tests__/watch-retro.test.ts +665 -0
- package/src/watch/__tests__/watch-session-manager.test.ts +566 -0
- package/src/watch/__tests__/watch-timeline.test.ts +670 -0
- package/src/watch/watch-retro.ts +480 -0
- package/src/watch/watch-session-manager.ts +575 -0
- 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
|
+
}
|