@cjhyy/code-shell-web 0.9.8 → 0.9.9

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.
@@ -96,16 +96,6 @@ export function reduceStream(state, raw) {
96
96
  return [`${prefix}-${next}`, { ...s, seq: next }];
97
97
  };
98
98
  switch (type) {
99
- case "session_user_message": {
100
- const [id, s2] = freshId("u");
101
- return {
102
- ...s2,
103
- items: [
104
- ...s2.items,
105
- { kind: "user", id, text: typeof event.text === "string" ? event.text : "" },
106
- ],
107
- };
108
- }
109
99
  case "stream_request_start": {
110
100
  // Open a fresh assistant message (for THIS agent) to accumulate text into.
111
101
  const key = agentKey(event);
@@ -117,6 +107,7 @@ export function reduceStream(state, raw) {
117
107
  reasoning: "",
118
108
  done: false,
119
109
  agentId: event.agentId,
110
+ ...(typeof event.messageId === "string" ? { messageId: event.messageId } : {}),
120
111
  };
121
112
  return {
122
113
  ...s2,
@@ -336,6 +327,21 @@ export function reduceStream(state, raw) {
336
327
  items[idx] = { ...items[idx], summary };
337
328
  return { ...s, items };
338
329
  }
330
+ case "tombstone": {
331
+ if (typeof event.messageId !== "string")
332
+ return s;
333
+ const removed = new Set(s.items
334
+ .filter((item) => (item.kind === "assistant" && item.messageId === event.messageId) ||
335
+ (item.kind === "tool" && item.id === event.messageId))
336
+ .map((item) => item.id));
337
+ if (!removed.size)
338
+ return s;
339
+ return {
340
+ ...s,
341
+ items: s.items.filter((item) => !removed.has(item.id)),
342
+ liveByAgent: Object.fromEntries(Object.entries(s.liveByAgent).filter(([, id]) => !removed.has(id))),
343
+ };
344
+ }
339
345
  case "assistant_message": {
340
346
  // Final assistant message for the request — seal THIS agent's live bubble
341
347
  // AND mark its open assistant items done. Without the latter, a finalized
@@ -464,7 +470,7 @@ export function reduceStream(state, raw) {
464
470
  id: `sub-${agentId}`,
465
471
  agentId,
466
472
  label: event.name || event.description || "子代理",
467
- status: "running",
473
+ status: event.status === "recorded" ? "recorded" : "running",
468
474
  };
469
475
  if (s.items.some((i) => i.kind === "subagent" && i.agentId === agentId))
470
476
  return s;
@@ -494,16 +500,49 @@ export function reduceStream(state, raw) {
494
500
  case "session_title": {
495
501
  return { ...s, title: event.title };
496
502
  }
503
+ case "session_user_message":
497
504
  case "user_message": {
498
505
  // History replay surfaces past user turns as a synthetic event so the
499
506
  // same reducer rebuilds the full conversation (the live path uses
500
507
  // appendUserMessage instead, since the phone echoes locally).
501
- const [id, s2] = freshId("u");
508
+ const clientMessageId = typeof event.clientMessageId === "string" ? event.clientMessageId : undefined;
502
509
  const attachments = Array.isArray(event.attachments)
503
510
  ? event.attachments
504
511
  .map((item) => item)
505
- .filter((item) => typeof item.name === "string" && typeof item.size === "number")
512
+ .filter((item) => !!item &&
513
+ typeof item.name === "string" &&
514
+ typeof item.size === "number" &&
515
+ Number.isFinite(item.size) &&
516
+ item.size >= 0)
517
+ .map((item) => ({
518
+ name: item.name,
519
+ size: item.size,
520
+ ...(typeof item.mime === "string" ? { mime: item.mime } : {}),
521
+ ...(typeof item.path === "string" ? { path: item.path } : {}),
522
+ }))
506
523
  : undefined;
524
+ if (clientMessageId &&
525
+ s.items.some((item) => item.kind === "user" && item.clientMessageId === clientMessageId)) {
526
+ // The local echo has original filenames but no authenticated workspace
527
+ // paths yet. Reconcile the host's staged attachment metadata in place.
528
+ if (!attachments?.length)
529
+ return s;
530
+ return {
531
+ ...s,
532
+ items: s.items.map((item) => item.kind === "user" && item.clientMessageId === clientMessageId
533
+ ? {
534
+ ...item,
535
+ attachments: attachments.map((attachment, index) => {
536
+ const previous = item.attachments?.[index];
537
+ return previous?.name === attachment.name
538
+ ? { ...previous, ...attachment }
539
+ : attachment;
540
+ }),
541
+ }
542
+ : item),
543
+ };
544
+ }
545
+ const [id, s2] = freshId("u");
507
546
  return {
508
547
  ...s2,
509
548
  items: [
@@ -513,6 +552,7 @@ export function reduceStream(state, raw) {
513
552
  id,
514
553
  text: event.text ?? "",
515
554
  ...(attachments?.length ? { attachments } : {}),
555
+ ...(clientMessageId ? { clientMessageId } : {}),
516
556
  },
517
557
  ],
518
558
  };
@@ -551,14 +591,20 @@ export function reduceStream(state, raw) {
551
591
  }
552
592
  }
553
593
  /** Append a local user echo (the phone shows its own message immediately). */
554
- export function appendUserMessage(state, text, attachments) {
594
+ export function appendUserMessage(state, text, attachments, clientMessageId) {
555
595
  const id = `u-${state.seq + 1}`;
556
596
  return {
557
597
  ...state,
558
598
  seq: state.seq + 1,
559
599
  items: [
560
600
  ...state.items,
561
- { kind: "user", id, text, ...(attachments?.length ? { attachments } : {}) },
601
+ {
602
+ kind: "user",
603
+ id,
604
+ text,
605
+ ...(attachments?.length ? { attachments } : {}),
606
+ ...(clientMessageId ? { clientMessageId } : {}),
607
+ },
562
608
  ],
563
609
  };
564
610
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Browser-safe projection of Core's durable TranscriptEvent log into the same
3
+ * stream events used by the live reducer. Keep Node/Electron readers outside
4
+ * this module: the Hub, phone and other browser hosts can share this boundary.
5
+ */
6
+ import { type ChatState, type UserAttachmentSummary } from "./streamReducer.js";
7
+ type ObjectValue = Record<string, unknown>;
8
+ /** Hide engine attachment plumbing while retaining attachment-only messages. */
9
+ export declare function transcriptUserDisplay(data: ObjectValue): {
10
+ text: string;
11
+ attachments: UserAttachmentSummary[];
12
+ };
13
+ /** Durable logs contain both assistant tool_use blocks and dedicated tool_use
14
+ * events. Their tool IDs are identical: emit idempotent starts, never two cards.
15
+ */
16
+ export declare function transcriptToStreamEvents(records: readonly unknown[]): ObjectValue[];
17
+ export declare function replayTranscript(records: readonly unknown[]): ChatState;
18
+ export {};
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Browser-safe projection of Core's durable TranscriptEvent log into the same
3
+ * stream events used by the live reducer. Keep Node/Electron readers outside
4
+ * this module: the Hub, phone and other browser hosts can share this boundary.
5
+ */
6
+ import { initialChatState, reduceStream, } from "./streamReducer.js";
7
+ function object(value) {
8
+ return value !== null && typeof value === "object" && !Array.isArray(value)
9
+ ? value
10
+ : undefined;
11
+ }
12
+ function text(value) {
13
+ return typeof value === "string" ? value : "";
14
+ }
15
+ function contentBlocks(value) {
16
+ return Array.isArray(value)
17
+ ? value.map(object).filter((block) => block !== undefined)
18
+ : [];
19
+ }
20
+ function textContent(value) {
21
+ if (typeof value === "string")
22
+ return value;
23
+ return contentBlocks(value)
24
+ .filter((block) => block.type === "text")
25
+ .map((block) => text(block.text))
26
+ .join("");
27
+ }
28
+ function unescapeAttribute(value) {
29
+ return value
30
+ .replace(/&quot;/g, '"')
31
+ .replace(/&lt;/g, "<")
32
+ .replace(/&gt;/g, ">")
33
+ .replace(/&amp;/g, "&");
34
+ }
35
+ function attachmentName(path) {
36
+ const name = path.replace(/\\/g, "/").split("/").filter(Boolean).pop() || "附件";
37
+ // The canonical staging service prefixes a content hash; it is not part of
38
+ // the filename the user selected. Other paths keep their original basename.
39
+ return path.includes(".code-shell/attachments/") ? name.replace(/^[a-f0-9]{16}-/, "") : name;
40
+ }
41
+ function byteSize(value) {
42
+ const number = typeof value === "number" ? value : Number(value);
43
+ return Number.isSafeInteger(number) && number >= 0 ? number : 0;
44
+ }
45
+ /** Hide engine attachment plumbing while retaining attachment-only messages. */
46
+ export function transcriptUserDisplay(data) {
47
+ const attachments = [];
48
+ const imagePaths = [];
49
+ let raw = textContent(data.content);
50
+ raw = raw.replace(/<attached-file\s+path="([^"]*)">\s*([\s\S]*?)<\/attached-file>/g, (match, path, metadata) => {
51
+ if (!/^absolutePath:\s*.+$/m.test(metadata) || !/^origin:\s*.+$/m.test(metadata)) {
52
+ return match;
53
+ }
54
+ const mime = metadata.match(/^mime:\s*(.+)$/m)?.[1]?.trim();
55
+ attachments.push({
56
+ name: attachmentName(unescapeAttribute(path)),
57
+ path: unescapeAttribute(path),
58
+ size: byteSize(metadata.match(/^size:\s*(\d+)\s*$/m)?.[1]),
59
+ ...(mime ? { mime } : {}),
60
+ });
61
+ return "";
62
+ });
63
+ raw = raw.replace(/<attached-directory\s+path="([^"]*)"[^>]*>[\s\S]*?<\/attached-directory>/g, (_match, path) => {
64
+ attachments.push({
65
+ name: `${attachmentName(unescapeAttribute(path))}/`,
66
+ path: unescapeAttribute(path),
67
+ size: 0,
68
+ });
69
+ return "";
70
+ });
71
+ raw = raw.replace(/<attached-image-paths>\s*([\s\S]*?)<\/attached-image-paths>\s*(?:\(上面附带的图片在工作区的真实路径[^\n]*\))?/g, (_match, paths) => {
72
+ imagePaths.push(...paths
73
+ .split("\n")
74
+ .map((path) => path.trim())
75
+ .filter(Boolean));
76
+ return "";
77
+ });
78
+ const imageBlocks = contentBlocks(data.content).filter((block) => block.type === "image");
79
+ for (let index = 0; index < Math.max(imageBlocks.length, imagePaths.length); index++) {
80
+ const source = object(imageBlocks[index]?.source);
81
+ const encoded = text(source?.data).replace(/\s/g, "");
82
+ attachments.push({
83
+ name: imagePaths[index] ? attachmentName(imagePaths[index]) : `图片 ${index + 1}`,
84
+ ...(imagePaths[index] ? { path: imagePaths[index] } : {}),
85
+ size: encoded
86
+ ? Math.max(0, Math.floor((encoded.length * 3) / 4) - (encoded.match(/=+$/)?.[0].length ?? 0))
87
+ : 0,
88
+ ...(typeof source?.media_type === "string" ? { mime: source.media_type } : {}),
89
+ });
90
+ }
91
+ const explicit = Array.isArray(data.attachments)
92
+ ? data.attachments.flatMap((item) => {
93
+ const attachment = object(item);
94
+ const name = text(attachment?.name) || text(attachment?.originalName);
95
+ return name
96
+ ? [
97
+ {
98
+ name,
99
+ size: byteSize(attachment?.size),
100
+ ...(typeof attachment?.mime === "string" ? { mime: attachment.mime } : {}),
101
+ ...(typeof attachment?.path === "string" ? { path: attachment.path } : {}),
102
+ },
103
+ ]
104
+ : [];
105
+ })
106
+ : [];
107
+ return {
108
+ text: typeof data.displayText === "string" ? data.displayText : raw.trim(),
109
+ attachments: explicit.length ? explicit : attachments,
110
+ };
111
+ }
112
+ function eventData(event) {
113
+ return object(event.data) ?? object(event.message) ?? event;
114
+ }
115
+ /** Durable logs contain both assistant tool_use blocks and dedicated tool_use
116
+ * events. Their tool IDs are identical: emit idempotent starts, never two cards.
117
+ */
118
+ export function transcriptToStreamEvents(records) {
119
+ const result = [];
120
+ const seenIds = new Set();
121
+ const starts = new Set();
122
+ const knownTools = new Map();
123
+ for (const raw of records) {
124
+ const record = object(raw);
125
+ if (!record)
126
+ continue;
127
+ const data = eventData(record);
128
+ if (record.type === "tool_use" && text(data.toolCallId)) {
129
+ knownTools.set(text(data.toolCallId), {
130
+ name: text(data.toolName) || "工具",
131
+ args: object(data.args) ?? {},
132
+ });
133
+ }
134
+ for (const block of contentBlocks(data.content)) {
135
+ if (block.type === "tool_use" && text(block.id)) {
136
+ knownTools.set(text(block.id), {
137
+ name: text(block.name) || "工具",
138
+ args: object(block.input) ?? {},
139
+ });
140
+ }
141
+ }
142
+ }
143
+ const startTool = (id, name, args, agentId) => {
144
+ if (!id)
145
+ return;
146
+ if (!starts.has(id)) {
147
+ starts.add(id);
148
+ result.push({
149
+ type: "tool_use_start",
150
+ toolCall: { id, toolName: name || "工具", args },
151
+ ...(agentId ? { agentId } : {}),
152
+ });
153
+ }
154
+ else {
155
+ result.push({
156
+ type: "tool_use_args_delta",
157
+ toolCallId: id,
158
+ args,
159
+ ...(agentId ? { agentId } : {}),
160
+ });
161
+ }
162
+ };
163
+ const finishTool = (id, data, agentId) => {
164
+ if (!id)
165
+ return;
166
+ const known = knownTools.get(id);
167
+ if (!starts.has(id))
168
+ startTool(id, known?.name || text(data.toolName) || "工具", known?.args ?? {}, agentId);
169
+ const output = typeof data.result === "string"
170
+ ? data.result
171
+ : textContent(data.content ?? data.contentBlocks);
172
+ const error = text(data.error);
173
+ result.push({
174
+ type: "tool_result",
175
+ result: {
176
+ id,
177
+ result: output || error,
178
+ ...(error ? { error } : {}),
179
+ isError: data.isError === true || data.is_error === true || !!error,
180
+ },
181
+ ...(agentId ? { agentId } : {}),
182
+ });
183
+ };
184
+ for (const raw of records) {
185
+ const record = object(raw);
186
+ if (!record)
187
+ continue;
188
+ const id = text(record.id);
189
+ if (id && seenIds.has(id))
190
+ continue;
191
+ if (id)
192
+ seenIds.add(id);
193
+ const data = eventData(record);
194
+ const agentId = text(data.agentId) || undefined;
195
+ const type = typeof record.type === "string"
196
+ ? record.type
197
+ : typeof data.role === "string"
198
+ ? "message"
199
+ : "";
200
+ switch (type) {
201
+ case "message": {
202
+ const blocks = contentBlocks(data.content);
203
+ if (data.role === "user") {
204
+ if (data.injected === true ||
205
+ data.authority === "agent" ||
206
+ data.authority === "system" ||
207
+ data.authority === "policy")
208
+ break;
209
+ const display = transcriptUserDisplay(data);
210
+ if (display.text.startsWith("<system-reminder>"))
211
+ break;
212
+ if (display.text || display.attachments.length) {
213
+ result.push({
214
+ type: "user_message",
215
+ ...display,
216
+ ...(typeof data.clientMessageId === "string"
217
+ ? { clientMessageId: data.clientMessageId }
218
+ : {}),
219
+ });
220
+ }
221
+ for (const block of blocks) {
222
+ if (block.type === "tool_result")
223
+ finishTool(text(block.tool_use_id), block, agentId);
224
+ }
225
+ }
226
+ else if (data.role === "assistant") {
227
+ const body = textContent(data.content);
228
+ const reasoning = blocks
229
+ .filter((block) => block.type === "reasoning")
230
+ .map((block) => text(block.reasoningContent) || text(block.text))
231
+ .join("");
232
+ if (body || reasoning) {
233
+ const scope = agentId ? { agentId } : {};
234
+ result.push({ type: "stream_request_start", ...scope });
235
+ if (reasoning)
236
+ result.push({ type: "thinking_delta", text: reasoning, ...scope });
237
+ if (body)
238
+ result.push({ type: "text_delta", text: body, ...scope });
239
+ result.push({ type: "assistant_message", ...scope });
240
+ }
241
+ for (const block of blocks) {
242
+ if (block.type === "tool_use")
243
+ startTool(text(block.id), text(block.name), object(block.input) ?? {}, agentId);
244
+ }
245
+ }
246
+ else if (data.role === "tool") {
247
+ finishTool(text(data.tool_call_id), data, agentId);
248
+ }
249
+ break;
250
+ }
251
+ case "tool_use":
252
+ startTool(text(data.toolCallId), text(data.toolName), object(data.args) ?? {}, agentId);
253
+ break;
254
+ case "tool_result":
255
+ finishTool(text(data.toolCallId), data, agentId);
256
+ break;
257
+ case "subagent":
258
+ if (agentId)
259
+ result.push({
260
+ type: "agent_start",
261
+ agentId,
262
+ name: text(data.name),
263
+ description: text(data.description),
264
+ status: "recorded",
265
+ });
266
+ break;
267
+ case "error":
268
+ if (text(data.error))
269
+ result.push({ type: "error", error: data.error });
270
+ break;
271
+ case "turn_stopped":
272
+ result.push({ type: "turn_complete", reason: "aborted_tools" });
273
+ break;
274
+ case "session_meta":
275
+ if (text(data.sessionId))
276
+ result.push({ type: "session_started", sessionId: data.sessionId });
277
+ break;
278
+ // turn_boundary marks the START of a loop, and checkpoint/summary logs
279
+ // are model context maintenance. They are not user-visible messages.
280
+ }
281
+ }
282
+ return result;
283
+ }
284
+ export function replayTranscript(records) {
285
+ let state = initialChatState();
286
+ for (const event of transcriptToStreamEvents(records))
287
+ state = reduceStream(state, event);
288
+ // A durable snapshot cannot establish liveness. The authenticated host owns
289
+ // running state; leave incomplete tools visible but never spin from old logs.
290
+ return { ...state, run: state.run === "error" ? "error" : "idle", liveByAgent: {} };
291
+ }