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,159 @@
1
+ /**
2
+ * Non-interactive Cindy login steps, driven by the Settings card.
3
+ *
4
+ * `cindy-login.js` owns the interactive CLI flow (readline prompts). This module
5
+ * exposes the same three wire calls as separate steps so a browser surface can
6
+ * stage them: request a verification code, verify it, and refresh an existing
7
+ * session. Nothing here prompts, and nothing here prints a token.
8
+ *
9
+ * Outcomes are values rather than exceptions: `binding_required` and
10
+ * `sso_verification_required` are answers the card must render, not transport
11
+ * failures, so callers can distinguish "Cindy said no" from "the request broke".
12
+ */
13
+ import { MAINLAND_CINDY_AUTH_BASE_URL, cindyRequest } from './cindy-login.js';
14
+
15
+ export { MAINLAND_CINDY_AUTH_BASE_URL };
16
+
17
+ /** Login identifiers Cindy's code endpoints accept. */
18
+ export const LOGIN_KINDS = Object.freeze(['phone', 'email']);
19
+
20
+ /** Normalize a base URL the same way the CLI flow does. */
21
+ export function normalizeAuthBaseUrl(value) {
22
+ const base = typeof value === 'string' && value.trim() !== '' ? value.trim() : MAINLAND_CINDY_AUTH_BASE_URL;
23
+ return base.replace(/\/$/, '');
24
+ }
25
+
26
+ /** Reject an identifier the card could not have meant, before spending a code. */
27
+ export function validateIdentifier(kind, identifier) {
28
+ const value = typeof identifier === 'string' ? identifier.trim() : '';
29
+ if (!LOGIN_KINDS.includes(kind)) return { ok: false, message: 'Cindy 登录方式无效' };
30
+ if (value === '') return { ok: false, message: kind === 'phone' ? '请填写手机号' : '请填写邮箱' };
31
+ if (kind === 'phone' && !/^\+?[0-9][0-9\s-]{4,19}$/.test(value)) return { ok: false, message: '手机号格式不正确' };
32
+ if (kind === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return { ok: false, message: '邮箱格式不正确' };
33
+ return { ok: true, value };
34
+ }
35
+
36
+ /**
37
+ * Ask Cindy to send a verification code.
38
+ * @param options - auth base URL, identifier kind, and the raw identifier.
39
+ * @returns `{ ok: true }` once Cindy accepted the request, else an ok:false message.
40
+ */
41
+ export async function requestLoginCode({ authBaseUrl, kind, identifier, locale = 'zh-CN' }) {
42
+ const checked = validateIdentifier(kind, identifier);
43
+ if (!checked.ok) return checked;
44
+ try {
45
+ await cindyRequest(normalizeAuthBaseUrl(authBaseUrl), `/api/auth/${kind}/request-code`, { [kind]: checked.value, locale });
46
+ return { ok: true, identifier: checked.value };
47
+ } catch {
48
+ return { ok: false, message: '验证码发送失败,请检查账号或网络' };
49
+ }
50
+ }
51
+
52
+ /** Select one token pair from a Cindy auth answer, or explain why there is none. */
53
+ function tokenPairOf(outcome) {
54
+ const tokens = outcome?.tokens || outcome;
55
+ if (typeof tokens?.accessToken !== 'string' || typeof tokens?.refreshToken !== 'string') {
56
+ return { ok: false, message: 'Cindy 未返回登录凭据' };
57
+ }
58
+ return {
59
+ ok: true,
60
+ session: {
61
+ accessToken: tokens.accessToken,
62
+ refreshToken: tokens.refreshToken,
63
+ ...(tokens.expiresAt === undefined ? {} : { expiresAt: tokens.expiresAt }),
64
+ },
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Exchange a verification code for a Cindy session.
70
+ *
71
+ * The device handle is minted once and reused, so a second login on the same
72
+ * Host does not register as a new device (`cindy-login.js` mints one per
73
+ * process; a card can log in repeatedly, so the caller supplies and persists it).
74
+ * @param options - auth base URL, kind, identifier, code, and the stable device handle.
75
+ * @returns an ok session, or a status naming what Cindy still requires.
76
+ */
77
+ export async function verifyLoginCode({ authBaseUrl, kind, identifier, code, deviceId, clientType = 'desktop', locale = 'zh-CN' }) {
78
+ const checked = validateIdentifier(kind, identifier);
79
+ if (!checked.ok) return checked;
80
+ const trimmedCode = typeof code === 'string' ? code.trim() : '';
81
+ if (trimmedCode === '') return { ok: false, message: '请填写验证码' };
82
+ if (typeof deviceId !== 'string' || deviceId === '') return { ok: false, message: '缺少设备标识' };
83
+
84
+ const base = normalizeAuthBaseUrl(authBaseUrl);
85
+ let outcome;
86
+ try {
87
+ outcome = await cindyRequest(base, `/api/auth/${kind}/verify-code`, { [kind]: checked.value, code: trimmedCode, deviceId, clientType, locale });
88
+ } catch {
89
+ return { ok: false, message: '验证失败,请检查验证码或网络' };
90
+ }
91
+
92
+ if (outcome?.status === 'select_account') {
93
+ const accounts = Array.isArray(outcome.accounts) ? outcome.accounts.map((account) => ({ id: account?.id, displayName: account?.displayName })) : [];
94
+ return { ok: false, status: 'select_account', message: '该账号需要选择要登录的 Cindy 账户', accounts, loginTicket: outcome.loginTicket };
95
+ }
96
+ if (outcome?.status === 'binding_required') return { ok: false, status: 'binding_required', message: '该 Cindy 账号需要先完成绑定' };
97
+ if (outcome?.status === 'sso_verification_required') return { ok: false, status: 'sso_verification_required', message: '该 Cindy 账号需要 SSO 验证' };
98
+ if (outcome?.status !== undefined && outcome.status !== 'ok') return { ok: false, message: `Cindy 返回了未支持的登录结果:${String(outcome.status)}` };
99
+
100
+ const pair = tokenPairOf(outcome);
101
+ if (!pair.ok) return pair;
102
+ return { ok: true, session: { authBaseUrl: base, deviceId, kind, identifier: checked.value, clientType, ...pair.session } };
103
+ }
104
+
105
+ /** Finish a multi-account login once the user picked one. */
106
+ export async function selectLoginAccount({ authBaseUrl, loginTicket, accountId, deviceId, clientType = 'desktop', locale = 'zh-CN' }) {
107
+ if (typeof loginTicket !== 'string' || loginTicket === '') return { ok: false, message: '缺少登录票据' };
108
+ if (typeof accountId !== 'string' || accountId === '') return { ok: false, message: '缺少账户选择' };
109
+ const base = normalizeAuthBaseUrl(authBaseUrl);
110
+ let outcome;
111
+ try {
112
+ outcome = await cindyRequest(base, '/api/auth/select-account', { loginTicket, accountId, deviceId });
113
+ } catch {
114
+ return { ok: false, message: '账户选择失败,请重试' };
115
+ }
116
+ const pair = tokenPairOf(outcome);
117
+ if (!pair.ok) return pair;
118
+ return { ok: true, session: { authBaseUrl: base, deviceId, clientType, ...pair.session } };
119
+ }
120
+
121
+ /**
122
+ * Refresh a stored session. Rejects nothing: an expired session is a value, not a
123
+ * crash.
124
+ *
125
+ * It distinguishes **rejected** from **unreachable**, because the caller deletes
126
+ * the stored credential on the first and must never do so on the second:
127
+ *
128
+ * - the service answered and refused the token (400/401/403) → `rejected: true`;
129
+ * - the request timed out, DNS failed, or the service answered 5xx/429 →
130
+ * `transient: true`.
131
+ *
132
+ * Collapsing both into one outcome is what signed a working session out on a
133
+ * flaky connection: every Host start refreshes, so a single hiccup deleted the
134
+ * credential permanently and the user had to log in again.
135
+ * @param session - the stored session.
136
+ * @returns `{ ok: true, session }`, or a failure carrying `rejected` or `transient`.
137
+ */
138
+ export async function refreshStoredSession(session, { signal } = {}) {
139
+ const base = normalizeAuthBaseUrl(session?.authBaseUrl);
140
+ if (typeof session?.refreshToken !== 'string' || typeof session?.deviceId !== 'string') {
141
+ return { ok: false, rejected: true, message: '本机没有可续期的 Cindy 登录态' };
142
+ }
143
+ let next;
144
+ try {
145
+ next = await cindyRequest(base, '/api/auth/refresh', { deviceId: session.deviceId, refreshToken: session.refreshToken }, { signal });
146
+ } catch (error) {
147
+ const status = typeof error?.status === 'number' ? error.status : null;
148
+ const refused = status === 400 || status === 401 || status === 403;
149
+ if (refused) return { ok: false, rejected: true, message: 'Cindy 登录态已过期' };
150
+ return {
151
+ ok: false,
152
+ transient: true,
153
+ message: `暂时无法续期 Cindy 登录态:${status === null ? '网络不通' : `HTTP ${status}`}`,
154
+ };
155
+ }
156
+ const pair = tokenPairOf(next);
157
+ if (!pair.ok) return pair;
158
+ return { ok: true, session: { ...session, authBaseUrl: base, ...pair.session } };
159
+ }
@@ -0,0 +1,80 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { createInterface } from "node:readline/promises";
3
+ import { stdin, stdout } from "node:process";
4
+
5
+ /** Explicit email-code login. Tokens remain in memory and are never printed or persisted. */
6
+ export const MAINLAND_CINDY_AUTH_BASE_URL = "https://auth.cindy.com.cn";
7
+
8
+ export function loginWithEmail(options = {}) { return loginWithVerification({ ...options, kind: "email", identifierLabel: "Cindy email" }); }
9
+ export function loginWithPhone(options = {}) { return loginWithVerification({ ...options, kind: "phone", identifierLabel: "Cindy phone number" }); }
10
+
11
+ async function loginWithVerification({ authBaseUrl = MAINLAND_CINDY_AUTH_BASE_URL, locale = "zh-CN", clientType = "desktop", prompt = defaultPrompt, kind, identifierLabel }) {
12
+ const base = authBaseUrl.replace(/\/$/, "");
13
+ const deviceId = randomUUID();
14
+ const identifier = await prompt(`${identifierLabel}: `);
15
+ await request(base, `/api/auth/${kind}/request-code`, { [kind]: identifier, locale });
16
+ const code = await prompt("Verification code: " );
17
+ const outcome = await request(base, `/api/auth/${kind}/verify-code`, { [kind]: identifier, code, deviceId, clientType, locale });
18
+ const resolved = await resolveLoginOutcome({ base, outcome, deviceId, prompt });
19
+ const tokens = selectTokenPair(resolved);
20
+ return { authBaseUrl: base, deviceId, accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, expiresAt: tokens.expiresAt };
21
+ }
22
+
23
+ export async function refreshLogin({ authBaseUrl, deviceId, refreshToken }) {
24
+ const base = authBaseUrl.replace(/\/$/, "");
25
+ return request(base, "/api/auth/refresh", { deviceId, refreshToken });
26
+ }
27
+
28
+ /**
29
+ * Send one Cindy auth request. Shared by the interactive CLI flow below and the
30
+ * settings-card flow in `cindy-login-flow.js`, so both speak the same wire.
31
+ *
32
+ * A failure carries `status` when the service answered at all. That distinction is
33
+ * load-bearing for refresh: "the service said this refresh token is invalid" and
34
+ * "the request never arrived" look identical otherwise, and treating the second as
35
+ * the first signs the user out (see `refreshStoredSession`). A transport failure
36
+ * carries `transport: true` and no status.
37
+ */
38
+ export async function cindyRequest(base, path, body, { signal } = {}) {
39
+ let response;
40
+ try {
41
+ response = await fetch(base + path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), ...(signal ? { signal } : {}) });
42
+ } catch (error) {
43
+ const failure = new Error(`Cindy request failed to reach the service: ${String(error?.message ?? error)}`);
44
+ failure.transport = true;
45
+ throw failure;
46
+ }
47
+ if (!response.ok) {
48
+ const failure = new Error(`Cindy authentication failed (HTTP ${response.status})`);
49
+ failure.status = response.status;
50
+ throw failure;
51
+ }
52
+ return response.json();
53
+ }
54
+
55
+ const request = cindyRequest;
56
+
57
+ async function resolveLoginOutcome({ base, outcome, deviceId, prompt }) {
58
+ if (outcome.status === "ok") return outcome;
59
+ if (outcome.status === "select_account") {
60
+ const choices = outcome.accounts.map((account, index) => `${index + 1}. ${account.displayName}`).join("\n");
61
+ const answer = await prompt(`Select Cindy account:\n${choices}\n> `);
62
+ const selected = outcome.accounts[Number(answer) - 1];
63
+ if (!selected) throw new Error("Invalid Cindy account selection");
64
+ return request(base, "/api/auth/select-account", { loginTicket: outcome.loginTicket, accountId: selected.id, deviceId });
65
+ }
66
+ if (outcome.status === "binding_required") throw new Error("Cindy account binding is required; complete binding in Cindy first");
67
+ if (outcome.status === "sso_verification_required") throw new Error("Cindy SSO verification is required; this MVP supports email-code login only");
68
+ throw new Error("Unsupported Cindy login outcome");
69
+ }
70
+
71
+ function selectTokenPair(outcome) {
72
+ const tokens = outcome.tokens || outcome;
73
+ if (typeof tokens.accessToken !== "string" || typeof tokens.refreshToken !== "string") throw new Error("Cindy authentication returned no token pair");
74
+ return tokens;
75
+ }
76
+
77
+ async function defaultPrompt(label) {
78
+ const rl = createInterface({ input: stdin, output: stdout });
79
+ try { return (await rl.question(label)).trim(); } finally { rl.close(); }
80
+ }
@@ -0,0 +1,432 @@
1
+ /**
2
+ * Fold DSH model messages into the rows the Cindy phone renders.
3
+ *
4
+ * DSH stores one message with a `content: ContentBlock[]`; the phone stores one
5
+ * row per block. The split is the whole job: an assistant turn that thought,
6
+ * called a tool, and then answered becomes a `thinking` row, a `tool_use` row,
7
+ * and an `assistant` row, in that order.
8
+ *
9
+ * The target is `RemoteMessage` (`apps/mobile/src/session/types.ts`), and the
10
+ * fields each role reads are defined by the phone's own normalizer
11
+ * (`apps/mobile/src/session/messageNormalize.ts`), not by this file:
12
+ *
13
+ * assistant / user content: string (or `{ text, files }` for attachments)
14
+ * thinking content: string
15
+ * tool_use content: { toolName, input } read by `parseMessageToolUse`
16
+ * tool_result content: anything previewable read by `contentToPreview`
17
+ */
18
+
19
+ /**
20
+ * DSH message roles this Host renders, and the phone row role each becomes.
21
+ *
22
+ * A role that is NOT in this map produces no rows at all. The earlier test was
23
+ * `role === 'user' ? 'user' : 'assistant'`, which made **every** unknown role an
24
+ * assistant turn — so DSH's `system/message` events arrived on the phone as
25
+ * ordinary replies. Those events are the prompt injection itself: DSH requires
26
+ * their source to be `{kind: 'plugin'}` and the plugin name to be present
27
+ * (`dsh-session`'s own `assertMessageEventShape`), and the plugin that emits
28
+ * them is `@deepseek-ai/dsh-system-prompt`. Nothing about the model's
29
+ * instructions belongs in a conversation transcript, so the default is silence
30
+ * rather than a guess.
31
+ */
32
+ const ROW_ROLE_BY_MESSAGE_ROLE = new Map([
33
+ ['user', 'user'],
34
+ ['assistant', 'assistant'],
35
+ ]);
36
+
37
+ /** Roles the phone knows; anything else would be dropped by its normalizer. */
38
+ const ROLE_BY_BLOCK = {
39
+ text: 'text',
40
+ reasoning: 'thinking',
41
+ 'tool-call': 'tool_use',
42
+ 'tool-result': 'tool_result',
43
+ };
44
+
45
+ /** Parse the raw JSON argument string a tool call carries. */
46
+ function parseToolArguments(raw) {
47
+ if (typeof raw !== 'string') return raw ?? null;
48
+ const trimmed = raw.trim();
49
+ if (trimmed === '') return null;
50
+ try {
51
+ return JSON.parse(trimmed);
52
+ } catch {
53
+ // A malformed argument string is still the only thing the model produced;
54
+ // the phone's summary formatter handles a non-object input.
55
+ return trimmed;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * DSH tool names → the names the phone's renderer switches on.
61
+ *
62
+ * Most of the phone's cards are chosen by input shape, but the plan/todo card is
63
+ * chosen by **name**: `extractPlanTodos` reads `input.todos` only for
64
+ * `TodoWrite`, and `agentPlanSource` returns `'todo'` for that name alone
65
+ * (`@cindy/maker-shared/message-render`). DSH's equivalent tool is `todo_write`
66
+ * and carries the identical `{ todos: [{ content, status, activeForm? }] }`
67
+ * input, so the card was never selected and the agent's plan stayed invisible on
68
+ * the phone. This translates the name only — never the data.
69
+ */
70
+ const TOOL_NAME_BY_DSH_NAME = { todo_write: 'TodoWrite' };
71
+
72
+ /** Map one DSH tool name to the phone's vocabulary. */
73
+ function cindyToolName(name) {
74
+ return TOOL_NAME_BY_DSH_NAME[name] ?? name;
75
+ }
76
+
77
+ /**
78
+ * The phone's entry for one durable DSH attachment block.
79
+ *
80
+ * The phone reads an attachment from the message's **content JSON**, and it reads
81
+ * the two kinds from two different fields (`messageNormalize.ts`):
82
+ *
83
+ * - `files[]` → `readFileAttachments`: `{ name, path?, url?, mimeType? }`. A file
84
+ * with neither `path` nor `url` renders as 没有可展示的远程路径 — which is what an
85
+ * image reported here used to look like.
86
+ * - `images[]` → `readImageAttachments`: needs a `url` **or** `base64`, and an entry
87
+ * with neither is dropped outright.
88
+ *
89
+ * DSH stores an image content-addressed and states that its reference is "never a
90
+ * filesystem path or bearer URL", so there is nothing to put in `path` and nothing to
91
+ * serve as a URL. What it does give is a `attachmentId` that `ctx.attachments.readImage`
92
+ * can turn back into bytes — so an image entry carries that handle **internally**
93
+ * (`imageRef`), the page is hydrated into `images[]` by
94
+ * {@link hydrateImageAttachments}, and the handle is stripped before the row leaves
95
+ * this Host. An image whose bytes cannot be read stays an honest file chip rather than
96
+ * a fabricated path.
97
+ *
98
+ * @param block - one `image` or `file` content block.
99
+ * @returns the entry, or null when the block names nothing.
100
+ */
101
+ function attachmentEntryOf(block) {
102
+ const ref = block?.attachment;
103
+ const name = typeof ref?.name === 'string' && ref.name !== '' ? ref.name : '';
104
+ const bytes = Number.isFinite(ref?.bytes) ? ref.bytes : undefined;
105
+ if (name === '' && bytes === undefined) return null;
106
+ const mimeType = typeof ref?.mediaType === 'string' && ref.mediaType !== '' ? ref.mediaType : undefined;
107
+ // The **whole** durable descriptor travels as the handle, not a subset: the store
108
+ // validates the reference it is asked to read (it re-derives the object from it), and
109
+ // a handle missing `width`/`height`/`name` is a different value than the one DSH
110
+ // stored. Measured: a subset silently failed every image read.
111
+ const imageRef = block?.type === 'image' && ref !== null && typeof ref === 'object' && typeof ref.attachmentId === 'string' && ref.attachmentId !== ''
112
+ ? { ...ref }
113
+ : null;
114
+ return {
115
+ ...(name === '' ? {} : { name }),
116
+ ...(bytes === undefined ? {} : { size: bytes }),
117
+ ...(mimeType === undefined ? {} : { mimeType }),
118
+ ...(imageRef === null ? {} : { imageRef }),
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Longest image this Host will inline into a transcript row.
124
+ *
125
+ * An inlined image is base64 (≈4/3 of the bytes) inside a JSON row the phone keeps in
126
+ * memory and in its on-disk message cache, so this is a memory budget rather than a
127
+ * protocol limit — and DSH's own normalization has usually shrunk the stored image
128
+ * well below it. Past the bound the entry stays a file chip: honest, if not pretty.
129
+ */
130
+ export const MAX_INLINE_IMAGE_BYTES = 1024 * 1024;
131
+
132
+ /**
133
+ * How much inlined image data one page may carry in total.
134
+ *
135
+ * This is the bound that actually matters, and it comes from the wire, not from
136
+ * memory: the relay **rejects** any device-link frame over `MAX_FRAME_BYTES`
137
+ * (2 MiB) outright, so a page whose images add up past the ceiling does not arrive
138
+ * late — it does not arrive at all, and the controller is left with a window it
139
+ * cannot fill (reported as 整个对话变成只有一行我的输出、重新加载更早消息也没有响应).
140
+ *
141
+ * 1 MiB of base64 is ≈ 768 KiB of image data, and after JSON and the page's own text
142
+ * rows the frame still lands around 1.1–1.4 MiB — comfortably inside the 2 MiB
143
+ * ceiling with room for the rest of the transcript. Images beyond the budget stay
144
+ * file chips, which is a per-attachment degradation rather than a per-page failure.
145
+ */
146
+ export const MAX_INLINE_IMAGE_TOTAL_BYTES = 1024 * 1024;
147
+
148
+ /** Estimated JSON overhead one inlined image adds on top of its base64. */
149
+ const INLINE_IMAGE_ENTRY_BYTES = 96;
150
+
151
+ /**
152
+ * Turn the page's image handles into bytes the phone can render.
153
+ *
154
+ * Only the rows being returned are hydrated (not the whole session log), so a
155
+ * transcript with a hundred photos costs one page's worth of reads. Each image is
156
+ * read once even when several rows share it, and **one unreadable image costs that
157
+ * image, never the page** — the row keeps its file entry instead.
158
+ *
159
+ * The page's rows arrive newest first, so the budget is spent from the newest
160
+ * picture backwards: the images the user is looking at are the ones that render.
161
+ *
162
+ * @param rows - the page of rows about to be served.
163
+ * @param options - `readImage` (the attachment store's reader), `maxBytes` (one
164
+ * image), and `maxTotalBytes` (the whole page).
165
+ * @returns the same rows, with readable images moved into `content.images[]`.
166
+ */
167
+ export async function hydrateImageAttachments(rows, {
168
+ readImage,
169
+ maxBytes = MAX_INLINE_IMAGE_BYTES,
170
+ maxTotalBytes = MAX_INLINE_IMAGE_TOTAL_BYTES,
171
+ } = {}) {
172
+ const list = Array.isArray(rows) ? rows : [];
173
+ // Plan first, read second.
174
+ //
175
+ // The page's rows arrive newest first, so the budget is spent from the newest
176
+ // picture backwards — the images the user is looking at are the ones that render.
177
+ // A descriptor that declares its size is judged on that number, so an image too
178
+ // large for the page is never read at all: reading 2.4 MB to throw it away is work
179
+ // the page does not owe anyone (and it made the inlining counters lie about what
180
+ // the page tried to serve).
181
+ const planned = new Set();
182
+ const wanted = new Map();
183
+ let spent = 0;
184
+ for (const row of list) {
185
+ for (const entry of Array.isArray(row?.content?.files) ? row.content.files : []) {
186
+ const handle = entry?.imageRef;
187
+ if (handle === null || typeof handle !== 'object' || typeof handle.attachmentId !== 'string') continue;
188
+ if (wanted.has(handle.attachmentId)) continue;
189
+ const declared = Number.isFinite(entry?.size) ? entry.size : (Number.isFinite(handle?.bytes) ? handle.bytes : null);
190
+ if (declared !== null && declared > maxBytes) continue;
191
+ if (declared !== null) {
192
+ // base64 is ≈4/3 of the bytes, plus the entry's own JSON.
193
+ const cost = Math.ceil((declared * 4) / 3) + INLINE_IMAGE_ENTRY_BYTES;
194
+ if (spent + cost > maxTotalBytes) continue;
195
+ spent += cost;
196
+ }
197
+ wanted.set(handle.attachmentId, handle);
198
+ planned.add(handle.attachmentId);
199
+ }
200
+ }
201
+ const inlined = new Map();
202
+ if (typeof readImage === 'function' && wanted.size > 0) {
203
+ await Promise.all([...wanted].map(async ([attachmentId, handle]) => {
204
+ try {
205
+ const stored = await readImage(handle);
206
+ if (stored === null || typeof stored !== 'object') return;
207
+ if (typeof stored.base64 !== 'string' || stored.base64 === '') return;
208
+ // An undeclared size can only be judged now, and an oversize one costs this
209
+ // image rather than the page.
210
+ if (Number.isFinite(stored.bytes) && stored.bytes > maxBytes) return;
211
+ inlined.set(attachmentId, stored);
212
+ } catch {
213
+ // Per attachment, exactly like every other unservable attachment here.
214
+ }
215
+ }));
216
+ }
217
+ for (const row of list) {
218
+ const files = Array.isArray(row?.content?.files) ? row.content.files : null;
219
+ if (files === null) continue;
220
+ const images = [];
221
+ const kept = [];
222
+ for (const entry of files) {
223
+ const handle = entry?.imageRef;
224
+ const stored = handle === undefined ? undefined : inlined.get(handle.attachmentId);
225
+ if (stored !== undefined) {
226
+ // `base64` (not `url`) is what the phone's normalizer turns into
227
+ // `data:<mimeType>;base64,…`, which `isPreviewableUri` accepts — so the bubble
228
+ // renders the picture itself, with no media channel and no fetch.
229
+ const mimeType = stored.mediaType ?? entry.mimeType ?? 'image/png';
230
+ images.push({
231
+ base64: stored.base64,
232
+ mimeType,
233
+ ...(entry.name === undefined ? {} : { originalName: entry.name }),
234
+ });
235
+ continue;
236
+ }
237
+ const { imageRef: _handle, ...rest } = entry;
238
+ kept.push(rest);
239
+ }
240
+ const content = { ...row.content };
241
+ if (images.length > 0) content.images = images;
242
+ if (kept.length > 0) content.files = kept;
243
+ else delete content.files;
244
+ row.content = content;
245
+ }
246
+ return list;
247
+ }
248
+
249
+ /** Flatten nested tool-result content into the text the phone previews. */
250
+ function flattenToolResult(blocks) {
251
+ const list = Array.isArray(blocks) ? blocks : [];
252
+ const parts = [];
253
+ for (const block of list) {
254
+ if (block === null || typeof block !== 'object') continue;
255
+ if (block.type === 'text' && typeof block.text === 'string') parts.push(block.text);
256
+ else if (typeof block.text === 'string') parts.push(block.text);
257
+ }
258
+ return parts.join('\n');
259
+ }
260
+
261
+ /**
262
+ * The message sources this Host renders, on purpose and by name.
263
+ *
264
+ * DSH states the rule itself: a `user/message` event carries **three** producers
265
+ * — a human's prompt, a plugin notification, and an injected goal continuation
266
+ * round — and "all three project their `content` verbatim; `source` tells them
267
+ * apart" (`dsh-session` known event types). Role alone is therefore never enough,
268
+ * and a *denylist* is never enough either: this started as "drop `plugin`", which
269
+ * cleaned up the system prompt and the runtime snapshot and still left the skills
270
+ * catalog in the transcript, because `@deepseek-ai/dsh-tool-skill` builds that one
271
+ * with `source: { kind: 'skill-catalog', form: 'catalog' }` on a `user` message.
272
+ *
273
+ * So the test is inverted: render exactly what a participant produced — the
274
+ * human (`user`), the model (`model`), and a tool's own result (`tool`) — and
275
+ * every other named kind is producer-supplied context that belongs in the model's
276
+ * request, not in a conversation transcript. A source that is absent entirely is
277
+ * still rendered: DSH requires one on every message, so its absence means a shape
278
+ * this Host cannot classify, and hiding a user's own words is the worse failure.
279
+ */
280
+ const RENDERABLE_SOURCE_KINDS = new Set(['user', 'model', 'tool']);
281
+
282
+ /**
283
+ * Build the Cindy rows for one DSH message.
284
+ * @param message - `{ id, role, content }` in DSH's model shape.
285
+ * @param context - the session id, the row's creation time (ISO), and — for a
286
+ * user turn — the `clientId` the controller minted for the prompt.
287
+ * @returns one row per renderable content block, in order.
288
+ */
289
+ export function toCindyMessageRows(message, { sessionId, createdAt, now = () => new Date(), promptClientId = null }) {
290
+ const rows = [];
291
+ const role = ROW_ROLE_BY_MESSAGE_ROLE.get(message?.role);
292
+ // `system` (the prompt injection), a hook's injected turn, or any role this
293
+ // Host has never seen: none of them is something a participant said.
294
+ if (role === undefined) return rows;
295
+ const blocks = Array.isArray(message?.content) ? message.content : [];
296
+ // DSH records a tool result as a `user`-role message whose source is
297
+ // `{kind: 'tool'}`. Its content block is what makes it a result row; a *text*
298
+ // block on such a message is not the user speaking, so it is not rendered as
299
+ // one.
300
+ const sourceKind = typeof message?.source?.kind === 'string' ? message.source.kind : null;
301
+ // Everything a producer injected — the system prompt and runtime-context
302
+ // snapshot (`plugin`), the skills catalog (`skill-catalog`), and whatever kind
303
+ // the next plugin adds — is the model's request, not the conversation. The
304
+ // runtime snapshot is re-injected on every turn, so rendering it meant the
305
+ // phone showed the whole system prompt as an assistant reply and a
306
+ // "Current runtime context. This snapshot supersedes…" bubble as a user message.
307
+ if (sourceKind !== null && !RENDERABLE_SOURCE_KINDS.has(sourceKind)) return rows;
308
+ const toolResultMessage = sourceKind === 'tool';
309
+ const time = createdAt ?? now().toISOString();
310
+ // A user turn's attachments belong to the message, not to a block: the phone
311
+ // reads them from the same `content` object as the text, so they are collected
312
+ // here and carried by the message's text row (or by a row of their own when the
313
+ // message was nothing but attachments).
314
+ const attachments = role === 'user' && !toolResultMessage
315
+ ? blocks.filter((block) => block?.type === 'image' || block?.type === 'file').map(attachmentEntryOf).filter(Boolean)
316
+ : [];
317
+ let attachmentsCarried = false;
318
+
319
+ blocks.forEach((block, index) => {
320
+ if (block === null || typeof block !== 'object') return;
321
+ const kind = ROLE_BY_BLOCK[block.type];
322
+ // Images and files are not rows of their own here: an image needs a media
323
+ // channel this Host does not serve, and the file's chip travels on the text
324
+ // row above. Emitting them as empty rows would add noise, not information.
325
+ if (kind === undefined) return;
326
+ if (toolResultMessage && block.type !== 'tool-result') return;
327
+
328
+ // A stable id: the phone keys, dedupes, and patches rows by it.
329
+ const id = `${sessionId}:${message.id}:${index}`;
330
+ // The controller shows a message the moment it sends it, and retires that
331
+ // optimistic copy by matching `clientId` against the durable row's
332
+ // (`projectOptimisticUserMessages` splices out the echo on exactly that
333
+ // field). A synthetic id here means the echo is never recognised, so the
334
+ // user sees their message twice — once spinning, once real — until some
335
+ // later reconciliation drops one. Carrying the prompt's own `clientId` on
336
+ // the user row is what lets the controller retire its copy immediately.
337
+ const clientId = role === 'user' && typeof promptClientId === 'string' && promptClientId !== '' ? promptClientId : id;
338
+ const base = {
339
+ id,
340
+ clientId,
341
+ sessionId,
342
+ role: kind === 'text' ? role : kind,
343
+ toolUseId: null,
344
+ agentMeta: null,
345
+ createdAt: time,
346
+ };
347
+
348
+ switch (block.type) {
349
+ case 'text':
350
+ // Reasoning models emit empty text blocks for usage only; the phone's
351
+ // normalizer drops them, but not emitting them at all is cheaper.
352
+ if (typeof block.text !== 'string' || block.text === '') return;
353
+ const carriesAttachments = attachments.length > 0 && !attachmentsCarried;
354
+ rows.push({
355
+ ...base,
356
+ // Desktop stringifies objects before rendering assistant markdown; use native prose strings.
357
+ content: carriesAttachments ? { text: block.text, files: attachments } : block.text,
358
+ });
359
+ attachmentsCarried = attachmentsCarried || carriesAttachments;
360
+ return;
361
+ case 'reasoning':
362
+ if (typeof block.text !== 'string' || block.text === '') return;
363
+ rows.push({ ...base, content: block.text });
364
+ return;
365
+ case 'tool-call':
366
+ rows.push({
367
+ ...base,
368
+ toolUseId: typeof block.id === 'string' ? block.id : null,
369
+ content: {
370
+ toolName: typeof block.name === 'string' ? cindyToolName(block.name) : '',
371
+ input: parseToolArguments(block.arguments),
372
+ ...(typeof block.id === 'string' ? { toolUseId: block.id } : {}),
373
+ },
374
+ });
375
+ return;
376
+ case 'tool-result':
377
+ rows.push({
378
+ ...base,
379
+ toolUseId: typeof block.toolCallId === 'string' ? block.toolCallId : null,
380
+ content: { text: flattenToolResult(block.content), isError: block.isError === true },
381
+ });
382
+ return;
383
+ default:
384
+ return;
385
+ }
386
+ });
387
+
388
+ // A message that was nothing but attachments still has to show them: the phone
389
+ // renders the chip from the user row, and a message with no rows at all would
390
+ // simply not exist in the transcript.
391
+ if (attachments.length > 0 && !attachmentsCarried) {
392
+ const id = `${sessionId}:${message.id}:attachments`;
393
+ rows.unshift({
394
+ id,
395
+ clientId: typeof promptClientId === 'string' && promptClientId !== '' ? promptClientId : id,
396
+ sessionId,
397
+ role: 'user',
398
+ toolUseId: null,
399
+ agentMeta: null,
400
+ createdAt: time,
401
+ content: { text: '', files: attachments },
402
+ });
403
+ }
404
+
405
+ return rows;
406
+ }
407
+
408
+ /**
409
+ * Fold a whole session's messages, newest first.
410
+ *
411
+ * The phone's paging contract is `ORDER BY createdAt DESC` with the oldest row
412
+ * at the page tail (`apps/mobile/src/session/historyWindowGap.ts`), and its
413
+ * cursor is a timestamp, so the order here is part of the contract.
414
+ * @param messages - DSH model messages with their event times.
415
+ * @param context - the session id.
416
+ * @returns the rows the phone parses, newest first.
417
+ */
418
+ export function toCindyMessages(messages, { sessionId, now = () => new Date() }) {
419
+ const list = Array.isArray(messages) ? messages : [];
420
+ // Reverse the MESSAGE order only. Within one message the blocks keep their
421
+ // order (thought → tool → answer): they share a timestamp, so the phone's
422
+ // stable sort would otherwise present them backwards.
423
+ const perMessage = list.map((entry) => toCindyMessageRows(entry.message, {
424
+ sessionId,
425
+ createdAt: entry.createdAt,
426
+ now,
427
+ promptClientId: entry.promptClientId ?? null,
428
+ }));
429
+ const rows = [];
430
+ for (const group of perMessage.reverse()) rows.push(...group);
431
+ return rows;
432
+ }