dsh-cindy-host 0.1.20

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 (82) hide show
  1. package/CHANGELOG.md +462 -0
  2. package/LICENSE +21 -0
  3. package/README.md +178 -0
  4. package/cordis.patch.yml +6 -0
  5. package/doc/architecture.md +34 -0
  6. package/doc/cindy-android-verify.md +362 -0
  7. package/doc/cindy-mobile-freeze-handoff.md +124 -0
  8. package/doc/cindy-phone-link.md +2902 -0
  9. package/doc/desktop-runtime-fix-20260930.md +69 -0
  10. package/doc/development-plan.md +19 -0
  11. package/doc/file-peer-apply-recovery-20260921.md +35 -0
  12. package/doc/file-peer-installed-hotfix-20260921.md +53 -0
  13. package/doc/file-peer-minimal-apply-20260921.md +78 -0
  14. package/doc/file-peer-original-contract-alignment-20260921.md +119 -0
  15. package/doc/file-peer-phone-export-observation-20260921.md +75 -0
  16. package/doc/file-peer-product-contract-audit-20260921.md +106 -0
  17. package/doc/file-peer-root-cause-20260921.md +53 -0
  18. package/doc/file-transfer.md +50 -0
  19. package/doc/host-performance-20260930.md +91 -0
  20. package/doc/neonabyss-apk-file-peer-diagnosis-20260921.md +87 -0
  21. package/doc/new-session-sync-fix-20260930.md +59 -0
  22. package/doc/patches/cindy-peer-silence-probe.patch +1112 -0
  23. package/doc/projection-read-probe.md +77 -0
  24. package/doc/publishing.md +259 -0
  25. package/doc/releasing.md +149 -0
  26. package/doc/research.md +24 -0
  27. package/doc/session-discovery-2026-09-20.md +67 -0
  28. package/lib/client.js +763 -0
  29. package/package.json +84 -0
  30. package/src/auth-session.js +189 -0
  31. package/src/authorization-policy.js +59 -0
  32. package/src/cindy-channels.js +1707 -0
  33. package/src/cindy-login-flow.js +159 -0
  34. package/src/cindy-login.js +80 -0
  35. package/src/cindy-message-row.js +432 -0
  36. package/src/cindy-session-row.js +171 -0
  37. package/src/console-sink.js +6 -0
  38. package/src/contracts.js +26 -0
  39. package/src/credential-store.js +128 -0
  40. package/src/demo.js +9 -0
  41. package/src/dsh-active-sessions.js +59 -0
  42. package/src/dsh-host-source.js +62 -0
  43. package/src/dsh-message-fold.js +608 -0
  44. package/src/dsh-plugin.js +1932 -0
  45. package/src/dsh-session-source.js +529 -0
  46. package/src/file-peer-protocol.js +7 -0
  47. package/src/file-peer-rtc.js +41 -0
  48. package/src/fixture-source.js +13 -0
  49. package/src/host-approvals.js +329 -0
  50. package/src/host-attachments.js +249 -0
  51. package/src/host-authorization.js +51 -0
  52. package/src/host-demo.js +3 -0
  53. package/src/host-export-jobs.js +356 -0
  54. package/src/host-export-owner.js +62 -0
  55. package/src/host-file-browser.js +400 -0
  56. package/src/host-file-ice.js +63 -0
  57. package/src/host-file-peer.js +289 -0
  58. package/src/host-file-reference.js +16 -0
  59. package/src/host-files.js +148 -0
  60. package/src/host-frame-budget.js +310 -0
  61. package/src/host-goals.js +284 -0
  62. package/src/host-history-view-limits.js +22 -0
  63. package/src/host-history-view.js +417 -0
  64. package/src/host-input-queue.js +505 -0
  65. package/src/host-media-fetch.js +511 -0
  66. package/src/host-media.js +678 -0
  67. package/src/host-models.js +231 -0
  68. package/src/host-palette.js +111 -0
  69. package/src/host-push-channels.js +37 -0
  70. package/src/host-reconnect.js +54 -0
  71. package/src/host-routes.js +266 -0
  72. package/src/host-settings.js +41 -0
  73. package/src/host-status.js +216 -0
  74. package/src/host-transfer-diagnostics.js +50 -0
  75. package/src/host.js +2497 -0
  76. package/src/login.js +31 -0
  77. package/src/logout.js +3 -0
  78. package/src/projection-read-model-sink.js +6 -0
  79. package/src/projection.js +43 -0
  80. package/src/session-flags.js +160 -0
  81. package/src/session-publisher.js +123 -0
  82. package/src/session-read-model.js +6 -0
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Keep every reply this Host sends inside the device-link frame limit.
3
+ *
4
+ * Why this exists at all: the relay **rejects** any frame over `MAX_FRAME_BYTES`
5
+ * (`@cindy/device-link-protocol`'s 2 MiB, enforced by the client's `sendEnvelope`
6
+ * as `PAYLOAD_TOO_LARGE` and by the relay server on the same byte count), and the
7
+ * failure is not a small error the controller can recover from:
8
+ *
9
+ * - the reference controlled end documents the exact consequence — 「若不接住,
10
+ * 异常会冒泡到 handleFrame 的 .catch(只 log),控制端收不到任何 invoke-result,
11
+ * 只能干等 30s 超时」 (`apps/desktop/src/main/device-link/dispatch.ts`
12
+ * `sendInvokeResultSafe`);
13
+ * - the controller reports it as 手机偶尔只剩自己那一行、历史全部消失、重新加载更早
14
+ * 也没有响应, because a page that never answers leaves its stored window without
15
+ * the rows it asked for.
16
+ *
17
+ * So the controlled end owes the controller two things, and this module is both:
18
+ *
19
+ * 1. **A ladder that degrades a message page instead of failing it.** The
20
+ * reference does content truncation → whole-row placeholder → row slicing,
21
+ * marking what it cut so the controller keeps its "load earlier" affordance
22
+ * (`compactInvokeResultForDeviceLink`). Copied here stage for stage, adapted to
23
+ * this Host's row shape (whose `content` is an object with `text` / `images[]`,
24
+ * not a string).
25
+ * 2. **A last-resort refusal.** If even one row cannot be made to fit, answer a
26
+ * compact `PAYLOAD_TOO_LARGE` error — the reference does exactly this for every
27
+ * non-message channel — rather than handing the socket a frame the relay drops.
28
+ *
29
+ * The budget is measured on the **serialized frame**, in UTF-8 bytes, because that
30
+ * is what the relay measures: a page of CJK characters is three bytes per
31
+ * character, and a page of base64 images is 4/3 of their size.
32
+ */
33
+
34
+ /**
35
+ * The relay's hard frame ceiling, mirrored from `@cindy/device-link-protocol`.
36
+ *
37
+ * Duplicated rather than imported on purpose: this Host composes the protocol
38
+ * over the wire, not the package, so the number is stated where it is applied —
39
+ * and a change on the Cindy side shows up as a failing test in `test/host.test.js`
40
+ * rather than as a silently dropped frame.
41
+ */
42
+ export const MAX_FRAME_BYTES = 2 * 1024 * 1024;
43
+
44
+ /**
45
+ * Headroom under the ceiling.
46
+ *
47
+ * The reference reserves 1 KiB (`REMOTE_INVOKE_FRAME_SAFETY_BYTES`) so that the
48
+ * measurement here and the relay's own `Buffer.byteLength` cannot disagree at the
49
+ * boundary over escaping or normalization.
50
+ */
51
+ export const FRAME_SAFETY_BYTES = 1024;
52
+
53
+ /** What a reply may occupy on the wire. */
54
+ export const FRAME_BUDGET_BYTES = MAX_FRAME_BYTES - FRAME_SAFETY_BYTES;
55
+
56
+ /** Longest text one ordinary row may carry, as the reference bounds it. */
57
+ export const ROW_TEXT_LIMIT = 128 * 1024;
58
+
59
+ /** Longest tool output one row may carry, as the reference bounds it. */
60
+ export const TOOL_RESULT_LIMIT = 8 * 1024;
61
+
62
+ /** Suffix appended where a text field was cut, so the controller can say so. */
63
+ export const TRUNCATION_SUFFIX = '\n\n[remote content truncated: payload too large]';
64
+
65
+ /** Whole-row replacement when even a truncated row does not fit. */
66
+ export const TRUNCATED_CONTENT = '[remote content truncated: payload too large]';
67
+
68
+ /**
69
+ * Channels whose result is a page of message rows.
70
+ *
71
+ * Only these can be degraded into a *usable* answer; every other channel either
72
+ * already answers something small or gets the compact error, which is the
73
+ * reference's split too (`REMOTE_MESSAGE_CHANNELS`).
74
+ */
75
+ export const MESSAGE_PAGE_CHANNELS = new Set([
76
+ 'local-db:messages:list',
77
+ 'local-db:messages:around',
78
+ 'local-db:messages:around-client-id',
79
+ ]);
80
+
81
+ /** UTF-8 length of one frame, which is the unit the relay counts in. */
82
+ export function frameByteLength(frame) {
83
+ try {
84
+ const text = JSON.stringify(frame);
85
+ return typeof text === 'string' ? Buffer.byteLength(text, 'utf8') : 0;
86
+ } catch {
87
+ // A frame that cannot be serialized is not a frame; treating it as oversized
88
+ // makes the caller refuse it instead of throwing inside the socket path.
89
+ return Number.POSITIVE_INFINITY;
90
+ }
91
+ }
92
+
93
+ /** Whether one frame may be sent as-is. */
94
+ export function frameFits(frame, budgetBytes = FRAME_BUDGET_BYTES) {
95
+ return frameByteLength(frame) <= budgetBytes;
96
+ }
97
+
98
+ /** UTF-8 length of a string, for the per-field limits. */
99
+ function byteLength(value) {
100
+ return Buffer.byteLength(value, 'utf8');
101
+ }
102
+
103
+ /**
104
+ * Cut one string to a byte budget without splitting a multi-byte character.
105
+ * @param text - the field's text.
106
+ * @param limit - the budget in UTF-8 bytes.
107
+ * @returns the text, or the text plus the truncation suffix when it was cut.
108
+ */
109
+ export function cutToBytes(text, limit) {
110
+ if (typeof text !== 'string' || byteLength(text) <= limit) return { text, cut: false };
111
+ let cut = Math.max(0, limit - byteLength(TRUNCATION_SUFFIX));
112
+ // Never cut inside a multi-byte sequence: the phone would render a replacement
113
+ // character where the Host meant nothing.
114
+ while (cut > 0) {
115
+ const candidate = text.slice(0, cut);
116
+ if (!candidate.endsWith('\uFFFD') && byteLength(candidate) <= limit - byteLength(TRUNCATION_SUFFIX)) break;
117
+ cut -= 1;
118
+ }
119
+ return { text: `${text.slice(0, cut)}${TRUNCATION_SUFFIX}`, cut: true };
120
+ }
121
+
122
+ /** `agentMeta` with the truncation witness merged in, as the reference marks it. */
123
+ function markContentTruncated(agentMeta) {
124
+ const base = agentMeta !== null && typeof agentMeta === 'object' && !Array.isArray(agentMeta) ? agentMeta : {};
125
+ return { ...base, remoteContentTruncated: true };
126
+ }
127
+
128
+ /** `agentMeta` with the trimmed-window witness the controller's paging reads. */
129
+ function markRowsTrimmed(agentMeta, originalCount) {
130
+ const base = agentMeta !== null && typeof agentMeta === 'object' && !Array.isArray(agentMeta) ? agentMeta : {};
131
+ return { ...base, remoteRowsTrimmed: true, remoteOriginalRowCount: originalCount };
132
+ }
133
+
134
+ /**
135
+ * Stage 1: shorten the long text a row carries.
136
+ *
137
+ * Bounds are the reference's (`REMOTE_MESSAGE_CONTENT_LIMIT` 128 KiB for a
138
+ * message, `REMOTE_TOOL_RESULT_CONTENT_LIMIT` 8 KiB for tool output).
139
+ * @param rows - the page's rows.
140
+ * @returns `{ rows, changed }`.
141
+ */
142
+ export function compactRowText(rows) {
143
+ let changed = false;
144
+ const next = rows.map((row) => {
145
+ if (row === null || typeof row !== 'object') return row;
146
+ const content = row.content;
147
+ if (typeof content === 'string') {
148
+ const result = cutToBytes(content, ROW_TEXT_LIMIT);
149
+ if (!result.cut) return row;
150
+ changed = true;
151
+ return { ...row, content: result.text, agentMeta: markContentTruncated(row.agentMeta) };
152
+ }
153
+ if (content === null || typeof content !== 'object' || Array.isArray(content)) return row;
154
+ if (row.role === 'tool_result' || row.role === 'tool-use') {
155
+ const result = cutToBytes(content.text, TOOL_RESULT_LIMIT);
156
+ if (result.cut) {
157
+ changed = true;
158
+ return { ...row, content: { ...content, text: result.text }, agentMeta: markContentTruncated(row.agentMeta) };
159
+ }
160
+ return row;
161
+ }
162
+ const result = cutToBytes(content.text, ROW_TEXT_LIMIT);
163
+ if (result.cut) {
164
+ changed = true;
165
+ return { ...row, content: { ...content, text: result.text }, agentMeta: markContentTruncated(row.agentMeta) };
166
+ }
167
+ return row;
168
+ });
169
+ return { rows: next, changed };
170
+ }
171
+
172
+ /**
173
+ * Stage 2: give up the inlined image bytes, keeping the row itself.
174
+ *
175
+ * This Host inlines a picture as `content.images[].base64` (≈4/3 of the file), which
176
+ * is the single largest thing a page can carry. When the page still does not fit,
177
+ * the pictures go before the conversation does: the row keeps its text, and the
178
+ * controller's own file chip path is unaffected because the chip was consumed when
179
+ * the image was inlined.
180
+ * @param rows - the page's rows.
181
+ * @returns `{ rows, changed }`.
182
+ */
183
+ export function dropInlinedImages(rows) {
184
+ let changed = false;
185
+ const next = rows.map((row) => {
186
+ const content = row?.content;
187
+ if (content === null || typeof content !== 'object' || Array.isArray(content)) return row;
188
+ if (!Array.isArray(content.images) || content.images.length === 0) return row;
189
+ changed = true;
190
+ const { images: _images, ...rest } = content;
191
+ return { ...row, content: rest, agentMeta: markContentTruncated(row.agentMeta) };
192
+ });
193
+ return { rows: next, changed };
194
+ }
195
+
196
+ /**
197
+ * Stage 3: replace every row's content with one placeholder line.
198
+ *
199
+ * The row identities stay, so the controller's window keeps its shape and its
200
+ * cursor still resolves — only the bodies are gone. Tool calls keep their name,
201
+ * because "the agent ran a tool here" is the part that is still true.
202
+ * @param rows - the page's rows.
203
+ * @returns the placeholder rows.
204
+ */
205
+ export function placeholderRows(rows) {
206
+ return rows.map((row) => {
207
+ if (row === null || typeof row !== 'object') return row;
208
+ const content = row.content !== null && typeof row.content === 'object' && !Array.isArray(row.content) ? row.content : {};
209
+ const nextContent = row.role === 'tool-use'
210
+ ? { toolName: content.toolName ?? '', input: null, ...(content.toolUseId === undefined ? {} : { toolUseId: content.toolUseId }) }
211
+ : { text: TRUNCATED_CONTENT };
212
+ return { ...row, content: nextContent, agentMeta: markContentTruncated(row.agentMeta) };
213
+ });
214
+ }
215
+
216
+ /**
217
+ * Cut a newest-first page down to its newest `keep` rows, without splitting one
218
+ * message's rows across the cut.
219
+ *
220
+ * Rows of one message share `createdAt` (this Host stamps every block of a message
221
+ * with the message's time), so the cut walks back to a group boundary; the rest of
222
+ * a split group would otherwise sit behind the next cursor and read as a hole.
223
+ * @param rows - the newest-first page.
224
+ * @param keep - how many rows to keep, at most.
225
+ * @returns the prefix.
226
+ */
227
+ export function sliceRowsAtMessageBoundary(rows, keep) {
228
+ const end = Math.max(1, Math.min(keep, rows.length));
229
+ let cut = end;
230
+ while (cut > 1 && cut < rows.length && rows[cut - 1]?.createdAt === rows[cut]?.createdAt) cut -= 1;
231
+ return rows.slice(0, cut);
232
+ }
233
+
234
+ /**
235
+ * Fit one invoke-result reply inside the frame budget.
236
+ *
237
+ * @param input - `reply` (the frame `invokeResult` built), `request` (`{ id, src }`,
238
+ * used to rebuild the envelope while measuring), `channel`, and an optional
239
+ * `budgetBytes` for tests.
240
+ * @returns `{ reply, level, rows, bytes }` — `reply` is what to send, and `level` is
241
+ * which stage produced it (`'fits' | 'text' | 'images' | 'placeholder' | 'trimmed' |
242
+ * 'refused'`), which is what the invoke log reports.
243
+ */
244
+ export function fitInvokeResultToFrame({ reply, request, channel, budgetBytes = FRAME_BUDGET_BYTES }) {
245
+ const measure = (payload) => frameByteLength({
246
+ v: reply?.v ?? 1,
247
+ kind: 'invoke-result',
248
+ id: request?.id,
249
+ dst: request?.src,
250
+ payload,
251
+ });
252
+ const original = reply?.payload;
253
+ if (original?.ok !== true || !Array.isArray(original.result) || !MESSAGE_PAGE_CHANNELS.has(channel)) {
254
+ // Not a page: nothing here can be degraded into a smaller *truthful* answer.
255
+ const bytes = measure(original);
256
+ if (bytes <= budgetBytes) return { reply, level: 'fits', rows: null, bytes };
257
+ return { reply: oversizedReply(reply, request, bytes), level: 'refused', rows: null, bytes };
258
+ }
259
+ const rows = original.result;
260
+ if (measure(original) <= budgetBytes) return { reply, level: 'fits', rows: rows.length, bytes: measure(original) };
261
+
262
+ const stages = [
263
+ ['text', () => compactRowText(rows)],
264
+ ['images', () => dropInlinedImages(rows)],
265
+ ['placeholder', () => ({ rows: placeholderRows(rows), changed: true })],
266
+ ];
267
+ let current = rows;
268
+ for (const [level, produce] of stages) {
269
+ const produced = produce();
270
+ if (produced.changed !== true) continue;
271
+ current = produced.rows;
272
+ const payload = { ...original, result: current };
273
+ const bytes = measure(payload);
274
+ if (bytes <= budgetBytes) return { reply: withResult(reply, current), level, rows: current.length, bytes };
275
+ }
276
+ const placeholders = placeholderRows(rows);
277
+ for (let keep = placeholders.length - 1; keep > 0; keep -= 1) {
278
+ const sliced = sliceRowsAtMessageBoundary(placeholders, keep);
279
+ const trimmed = sliced.map((row) => (row === null || typeof row !== 'object' ? row : { ...row, agentMeta: markRowsTrimmed(row.agentMeta, placeholders.length) }));
280
+ const payload = { ...original, result: trimmed };
281
+ const bytes = measure(payload);
282
+ if (bytes <= budgetBytes) return { reply: withResult(reply, trimmed), level: 'trimmed', rows: trimmed.length, bytes };
283
+ }
284
+ // One row that still does not fit: a single row is always under the budget in
285
+ // practice (its text is bounded above), so this is the "something is wrong with
286
+ // this page" path. Refuse compactly instead of dropping the connection.
287
+ return { reply: oversizedReply(rows.length === 0 ? reply : reply, request, measure({ ...original, result: placeholders })), level: 'refused', rows: 0, bytes: measure(original) };
288
+ }
289
+
290
+ /** The reply with a different result, leaving the original frame untouched. */
291
+ function withResult(reply, result) {
292
+ return { ...reply, payload: { ...reply.payload, result } };
293
+ }
294
+
295
+ /** The compact refusal a controller reads as "this page is too large". */
296
+ function oversizedReply(reply, request, bytes) {
297
+ return {
298
+ v: reply?.v ?? 1,
299
+ kind: 'invoke-result',
300
+ id: request?.id ?? reply?.id,
301
+ dst: request?.src ?? reply?.dst,
302
+ payload: {
303
+ ok: false,
304
+ error: {
305
+ code: 'PAYLOAD_TOO_LARGE',
306
+ message: `this reply cannot fit one device-link frame (${bytes} bytes over the ${FRAME_BUDGET_BYTES} byte budget)`,
307
+ },
308
+ },
309
+ };
310
+ }
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Read DSH's goal state for one session, without waking it.
3
+ *
4
+ * `ctx.goals` is keyed by `Agent`, and the only public way to obtain a session's
5
+ * Agent is `sessionController.resolveAgent(sessionId)` — which **resumes** the
6
+ * session. The controller asks for goal status on *every* session open, so
7
+ * resuming on a status read would be a side effect nobody asked for.
8
+ *
9
+ * It is also unnecessary: `dsh-goal` registers a session projection under the key
10
+ * `goal`, and projection frames ride the same `sessionController.control()`
11
+ * stream this Host already folds for the input queue. So the goal arrives as
12
+ * data, and a status read costs nothing. That stream is a baseline followed by
13
+ * replacements, and both halves matter: a replacement only follows a *change*,
14
+ * so a goal that already existed when the stream opened arrives in the baseline
15
+ * and nowhere else.
16
+ *
17
+ * The two vocabularies line up for free — DSH phases are `active | paused |
18
+ * blocked | complete`, a direct subset of the controller's
19
+ * `… | budgetLimited | usageLimited` (`maker-shared/deviceLinkContract.ts`).
20
+ * This Host has no budget or usage limits for goals, so those two are never
21
+ * emitted rather than invented.
22
+ */
23
+
24
+ /** Projection key DSH's goal service registers. */
25
+ export const GOAL_PROJECTION_KEY = 'goal';
26
+
27
+ /**
28
+ * Fold projection frames out of the control stream.
29
+ *
30
+ * `undefined` and `null` mean different things to the controller — "never
31
+ * fetched" versus "fetched, no goal" — so an unset key stays `undefined` rather
32
+ * than being flattened to `null`. That distinction only holds for a session the
33
+ * stream never named: a session the baseline named without a `goal` key has been
34
+ * fetched, and reports no goal.
35
+ * @returns the tracker the runtime feeds and the channel reads.
36
+ */
37
+ export function createProjectionTracker() {
38
+ /** sessionId -> Map<key, value> */
39
+ const bySession = new Map();
40
+
41
+ /** Apply one `SessionControlFrame`; anything that is not a projection is ignored. */
42
+ function apply(frame) {
43
+ if (frame === null || typeof frame !== 'object') return;
44
+ // The stream opens with one complete baseline, and a replacement frame only
45
+ // follows a *change*. A goal created before this Host connected — or while
46
+ // it was disconnected — is therefore carried by the baseline alone, so
47
+ // ignoring it reported "no goal" for a goal that exists. The baseline also
48
+ // replaces the maps rather than merging into them: state folded from a
49
+ // previous stream is not evidence about the current one.
50
+ if (frame.type === 'baseline') {
51
+ bySession.clear();
52
+ const blocks = frame.value?.projections;
53
+ if (blocks === null || typeof blocks !== 'object') return;
54
+ for (const [sessionId, block] of Object.entries(blocks)) {
55
+ if (sessionId === '' || block === null || typeof block !== 'object') continue;
56
+ const values = block.values;
57
+ if (values === null || typeof values !== 'object') continue;
58
+ if (!Object.prototype.hasOwnProperty.call(values, GOAL_PROJECTION_KEY)) continue;
59
+ bySession.set(sessionId, new Map([[GOAL_PROJECTION_KEY, values[GOAL_PROJECTION_KEY]]]));
60
+ }
61
+ return;
62
+ }
63
+ if (frame.type !== 'projection') return;
64
+ if (typeof frame.sessionId !== 'string' || frame.sessionId === '') return;
65
+ if (typeof frame.key !== 'string' || frame.key === '') return;
66
+ const values = bySession.get(frame.sessionId) ?? new Map();
67
+ values.set(frame.key, frame.value);
68
+ bySession.set(frame.sessionId, values);
69
+ }
70
+
71
+ /** One projection value, or undefined when the stream has never carried it. */
72
+ function valueOf(sessionId, key) {
73
+ return bySession.get(sessionId)?.get(key);
74
+ }
75
+
76
+ /** The raw DSH goal projection for one session. */
77
+ function goalOf(sessionId) {
78
+ return valueOf(sessionId, GOAL_PROJECTION_KEY);
79
+ }
80
+
81
+ function clear() {
82
+ bySession.clear();
83
+ }
84
+
85
+ return { apply, valueOf, goalOf, clear };
86
+ }
87
+
88
+ /**
89
+ * Normalise the two shapes a goal value arrives in.
90
+ *
91
+ * The `goal` projection is `{ goal: GoalSnapshot, roundsStarted, createdAt, … }`,
92
+ * while a live service view is the snapshot **flattened** with the same counters
93
+ * (`GoalView`). Both describe the same goal, so both must produce the same
94
+ * controller payload — a second mapper for the second shape is exactly the kind
95
+ * of drift that has already cost this Host a day on session rows.
96
+ * @param value - a projection value or a service view.
97
+ * @returns the snapshot and its counters, or null when there is no goal.
98
+ */
99
+ function normalizeGoal(value) {
100
+ if (value === null || value === undefined) return null;
101
+ if (typeof value !== 'object') return null;
102
+ // A projection always wraps its snapshot under `goal`; a view never has the key.
103
+ const isProjection = Object.prototype.hasOwnProperty.call(value, 'goal');
104
+ const snapshot = isProjection ? value.goal : value;
105
+ if (snapshot === null || typeof snapshot !== 'object') return null;
106
+ // A flattened view is only a goal when it carries the snapshot's own phase.
107
+ // Without this, any stray object with an `id` — a `clear` tombstone ref, say,
108
+ // or a malformed frame — would be reported to the controller as a live goal,
109
+ // and an "active" card for a goal that no longer exists is worse than none.
110
+ if (typeof snapshot.phase !== 'string') return null;
111
+ return { snapshot, counters: value };
112
+ }
113
+
114
+ /**
115
+ * Map one DSH goal projection onto the controller's status payload.
116
+ *
117
+ * Accepts either the projection value or a live `GoalView`, so a status read and
118
+ * the answer to a write cannot disagree about the same goal.
119
+ * @param sessionId - the session the projection belongs to.
120
+ * @param value - the raw projection value or service view.
121
+ * @returns the controller payload, or null when the session has no goal. An
122
+ * `undefined` value means "unknown", which is not the same answer.
123
+ */
124
+ export function toGoalStatusPayload(sessionId, value) {
125
+ if (value === undefined) return undefined;
126
+ const normalized = normalizeGoal(value);
127
+ if (normalized === null) return null;
128
+
129
+ const { snapshot: goal, counters } = normalized;
130
+ const phase = typeof goal.phase === 'string' ? goal.phase : 'active';
131
+ const blockedMessage = goal.blockedReason?.message;
132
+ return {
133
+ sessionId,
134
+ // DSH's four phases are a direct subset of the controller's vocabulary.
135
+ status: phase,
136
+ objective: typeof goal.objective === 'string' ? goal.objective : '',
137
+ // DSH counts continuation rounds, which is what the controller shows.
138
+ turnsUsed: Number.isFinite(counters.roundsStarted) ? counters.roundsStarted : 0,
139
+ // This Host tracks no token budget for a goal, so it reports none rather
140
+ // than a fabricated zero that the controller would render as real usage.
141
+ tokensUsed: 0,
142
+ maxTurns: Number.isFinite(goal.maxGoalRounds) ? goal.maxGoalRounds : null,
143
+ noProgressLimit: null,
144
+ budgetTokens: null,
145
+ usageResetAt: null,
146
+ startedAt: Number.isFinite(counters.createdAt) ? counters.createdAt : 0,
147
+ lastReason: typeof blockedMessage === 'string' && blockedMessage !== '' ? blockedMessage : null,
148
+ };
149
+ }
150
+
151
+ /**
152
+ * DSH's goal has exactly one limit — `maxGoalRounds`. The controller's form
153
+ * offers three.
154
+ *
155
+ * `maxTurns` maps onto it. `budgetTokens` and `noProgressLimit` have no DSH
156
+ * counterpart and are therefore not sent: the status read reports both as null,
157
+ * so the controller's own panel shows them unset. Silently accepting them would
158
+ * let the user believe a guard exists that this Host never enforces — the same
159
+ * rule that keeps this Host from inventing a token count.
160
+ * @param limits - the controller's `MobileGoalLimitsInput`.
161
+ * @returns the round cap to send, or undefined to let DSH apply its default.
162
+ */
163
+ function roundsFromLimits(limits) {
164
+ const value = limits?.maxTurns;
165
+ return Number.isInteger(value) && value > 0 ? value : undefined;
166
+ }
167
+
168
+ /** Map a DSH `GoalError` onto a code the controller can show. */
169
+ function goalWriteFailure(error) {
170
+ const code = typeof error?.code === 'string' && error.code !== '' ? error.code : 'THREW';
171
+ const message = typeof error?.message === 'string' && error.message !== '' ? error.message : String(error);
172
+ return { ok: false, code, message };
173
+ }
174
+
175
+ /**
176
+ * Perform the controller's goal write commands against DSH's own goal service.
177
+ *
178
+ * Reads stay projection-based (see the module note above); writes cannot. A
179
+ * mutation is compare-and-set on a live `Agent`, so this **resumes** the session
180
+ * through `sessionController.resolveAgent` — a side effect that is acceptable
181
+ * for an action the user just took, and unacceptable for a status read, which is
182
+ * why the two paths are separate.
183
+ *
184
+ * @param options - the DSH goal service and the agent resolver.
185
+ * @returns the writer the channel layer calls, one method per channel.
186
+ */
187
+ export function createGoalWriter({ goals, resolveAgent }) {
188
+ /** Resolve the live agent a mutation needs, or a stable failure. */
189
+ async function agentFor(sessionId) {
190
+ if (typeof resolveAgent !== 'function') {
191
+ return { ok: false, code: 'NOT_AVAILABLE', message: 'This DSH Host cannot resolve a session agent' };
192
+ }
193
+ let resolved;
194
+ try {
195
+ resolved = await resolveAgent(sessionId);
196
+ } catch (error) {
197
+ return goalWriteFailure(error);
198
+ }
199
+ if (resolved?.agent !== undefined) return { ok: true, agent: resolved.agent };
200
+ const message = resolved?.error?.message;
201
+ return { ok: false, code: 'NOT_FOUND', message: typeof message === 'string' ? message : `No live DSH agent for ${sessionId}` };
202
+ }
203
+
204
+ /**
205
+ * The current goal's compare-and-set identity.
206
+ *
207
+ * Every DSH mutation carries the revision it expects, so this read is not
208
+ * optional bookkeeping: it is the concurrency token, and it must come from the
209
+ * live service rather than the folded projection, which can trail a mutation.
210
+ */
211
+ function refFor(agent, sessionId) {
212
+ let view;
213
+ try {
214
+ view = goals.get(agent);
215
+ } catch (error) {
216
+ return goalWriteFailure(error);
217
+ }
218
+ if (view === undefined || view === null) {
219
+ return { ok: false, code: 'NOT_FOUND', message: `No goal on ${sessionId}` };
220
+ }
221
+ return { ok: true, ref: { id: view.id, revision: view.revision } };
222
+ }
223
+
224
+ /** Run one mutation and answer the controller with the goal's new state. */
225
+ async function mutate(sessionId, run, { needsGoal = true } = {}) {
226
+ if (typeof goals !== 'object' || goals === null) {
227
+ return { ok: false, code: 'NOT_AVAILABLE', message: 'This DSH Host composes no goal service' };
228
+ }
229
+ const found = await agentFor(sessionId);
230
+ if (!found.ok) return found;
231
+
232
+ let ref;
233
+ if (needsGoal) {
234
+ const current = refFor(found.agent, sessionId);
235
+ if (!current.ok) return current;
236
+ ref = current.ref;
237
+ }
238
+
239
+ let view;
240
+ try {
241
+ view = run(found.agent, ref);
242
+ } catch (error) {
243
+ return goalWriteFailure(error);
244
+ }
245
+ // A clear leaves no goal; the controller reads that as `null`, not as absent.
246
+ let after;
247
+ try {
248
+ after = goals.get(found.agent);
249
+ } catch {
250
+ after = undefined;
251
+ }
252
+ return { ok: true, status: toGoalStatusPayload(sessionId, after ?? view ?? null) };
253
+ }
254
+
255
+ return {
256
+ /** `maker:goal:set` — the controller's create form. */
257
+ async set({ sessionId, objective, limits }) {
258
+ const text = typeof objective === 'string' ? objective.trim() : '';
259
+ if (text === '') return { ok: false, code: 'BAD_REQUEST', message: 'maker:goal:set needs an objective' };
260
+ const rounds = roundsFromLimits(limits);
261
+ return mutate(sessionId, (agent) => goals.create(agent, {
262
+ objective: text,
263
+ ...(rounds === undefined ? {} : { maxGoalRounds: rounds }),
264
+ }), { needsGoal: false });
265
+ },
266
+ pause: (sessionId) => mutate(sessionId, (agent, ref) => goals.pause(agent, ref)),
267
+ resume: (sessionId) => mutate(sessionId, (agent, ref) => goals.resume(agent, ref)),
268
+ clear: (sessionId) => mutate(sessionId, (agent, ref) => goals.clear(agent, ref)),
269
+ /** `maker:goal:update` — objective and/or round cap, phase untouched. */
270
+ async update({ sessionId, patch }) {
271
+ const request = {};
272
+ const objective = typeof patch?.objective === 'string' ? patch.objective.trim() : '';
273
+ if (objective !== '') request.objective = objective;
274
+ const rounds = roundsFromLimits(patch?.limits);
275
+ if (rounds !== undefined) request.maxGoalRounds = rounds;
276
+ if (request.objective === undefined && request.maxGoalRounds === undefined) {
277
+ // DSH rejects an edit with nothing to change; say so locally instead of
278
+ // surfacing the service's own wording for an input it never saw.
279
+ return { ok: false, code: 'BAD_REQUEST', message: 'maker:goal:update needs an objective or a round limit' };
280
+ }
281
+ return mutate(sessionId, (agent, ref) => goals.edit(agent, ref, request));
282
+ },
283
+ };
284
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The history view's page budgets, mirrored from the Cindy contract.
3
+ *
4
+ * Stated here rather than imported because this Host composes the protocol over the wire,
5
+ * not the package: `@cindy/maker-shared/message-window` publishes
6
+ * `HISTORY_VIEW_PAGE_ITEMS = 20`, `HISTORY_VIEW_PAGE_BYTES = 256 * 1024`,
7
+ * `HISTORY_DETAIL_PAGE_BYTES = 256 * 1024`, and `HISTORY_VIEW_VERSION = 1`. A change on
8
+ * the Cindy side shows up as a failing test here instead of a page the phone silently
9
+ * truncates.
10
+ */
11
+
12
+ /** Items one `local-db:messages:view` page may carry. */
13
+ export const HISTORY_PAGE_ITEMS = 20;
14
+
15
+ /** Bytes one `local-db:messages:view` page may carry. */
16
+ export const HISTORY_PAGE_BYTES = 256 * 1024;
17
+
18
+ /** Bytes one `local-db:messages:work-details` page may carry. */
19
+ export const HISTORY_DETAIL_PAGE_BYTES = 256 * 1024;
20
+
21
+ /** The protocol version the three view channels answer with. */
22
+ export const HISTORY_VIEW_VERSION = 1;