@cjhyy/code-shell-web 0.9.27 → 0.9.29

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/dist/index.d.ts CHANGED
@@ -13,6 +13,7 @@ export { useRemoteSocket, type ConnStatus, type ResyncReason, type RemoteSocket,
13
13
  export * from "./hooks/remoteAppSync.js";
14
14
  export * from "./lib/streamReducer.js";
15
15
  export * from "./lib/transcriptReplay.js";
16
+ export * from "./lib/outputJournalRecovery.js";
16
17
  export * from "./lib/messageMappers.js";
17
18
  export * from "./lib/riskClassify.js";
18
19
  export * from "./lib/format.js";
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ export * from "./hooks/remoteAppSync.js";
15
15
  // ── Stream folding (shared with the desktop renderer's CC room view) ────────
16
16
  export * from "./lib/streamReducer.js";
17
17
  export * from "./lib/transcriptReplay.js";
18
+ export * from "./lib/outputJournalRecovery.js";
18
19
  export * from "./lib/messageMappers.js";
19
20
  // ── Pure helpers ─────────────────────────────────────────────────────────────
20
21
  export * from "./lib/riskClassify.js";
@@ -0,0 +1,28 @@
1
+ import type { StreamEvent } from "@cjhyy/code-shell-core";
2
+ import type { OutputJournalPage } from "@cjhyy/code-shell-core/internal";
3
+ /** Per-recovery state; keep it separate from Main/Hub transport sequence state. */
4
+ export interface OutputJournalRecovery {
5
+ cursor?: string;
6
+ /** Safe to persist across browser restarts; never points inside a fragmented event. */
7
+ appliedCursor?: string;
8
+ through?: string;
9
+ coverageStart?: string;
10
+ legacyBaseThroughEventId?: string;
11
+ incomplete: boolean;
12
+ pending?: {
13
+ id: string;
14
+ total: number;
15
+ next: number;
16
+ bytes: number;
17
+ chunks: Uint8Array[];
18
+ };
19
+ }
20
+ /** Only compare positions in the same Session journal, never Host counters. */
21
+ export declare function compareOutputCursors(left: string, right: string): number | undefined;
22
+ /**
23
+ * Fold a frozen, contiguous page exactly once. Callers first load legacy history
24
+ * through legacyBaseThroughEventId, then apply returned StreamEvents to their
25
+ * existing reducer. An unpaired legacy transcript remains behind its barrier.
26
+ * This function neither treats EOF as completion nor changes transport epochs.
27
+ */
28
+ export declare function applyOutputJournalPage(state: OutputJournalRecovery, page: OutputJournalPage, emit: (event: StreamEvent) => void): boolean;
@@ -0,0 +1,148 @@
1
+ import { projectOutputUserEvent } from "./userMessageDisplay.js";
2
+ const MAX_EVENT_BYTES = 16 * 1024 * 1024;
3
+ function position(token) {
4
+ if (typeof token !== "string" || token.length > 2048 || !/^[A-Za-z0-9_-]+$/.test(token))
5
+ throw new Error("Invalid output cursor");
6
+ const parsed = JSON.parse(atob(token.replace(/-/g, "+").replace(/_/g, "/")));
7
+ if (typeof parsed.identity !== "string" ||
8
+ !Number.isSafeInteger(parsed.sequence) ||
9
+ parsed.sequence < 0 ||
10
+ !Number.isSafeInteger(parsed.offset) ||
11
+ parsed.offset < 0)
12
+ throw new Error("Invalid output cursor");
13
+ return parsed;
14
+ }
15
+ function decodeFragment(value) {
16
+ if (value.length > Math.ceil((64 * 1024) / 3) * 4 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value))
17
+ throw new Error("Invalid output fragment");
18
+ const binary = atob(value);
19
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
20
+ }
21
+ /** Only compare positions in the same Session journal, never Host counters. */
22
+ export function compareOutputCursors(left, right) {
23
+ try {
24
+ const a = position(left), b = position(right);
25
+ return a.identity === b.identity ? Math.sign(a.sequence - b.sequence) : undefined;
26
+ }
27
+ catch {
28
+ return undefined;
29
+ }
30
+ }
31
+ /**
32
+ * Fold a frozen, contiguous page exactly once. Callers first load legacy history
33
+ * through legacyBaseThroughEventId, then apply returned StreamEvents to their
34
+ * existing reducer. An unpaired legacy transcript remains behind its barrier.
35
+ * This function neither treats EOF as completion nor changes transport epochs.
36
+ */
37
+ export function applyOutputJournalPage(state, page, emit) {
38
+ if (state.incomplete)
39
+ return false;
40
+ try {
41
+ if (page.version !== 1 ||
42
+ page.status !== "ok" ||
43
+ !page.from ||
44
+ !page.through ||
45
+ !page.next ||
46
+ !page.coverageStart ||
47
+ !Array.isArray(page.frames) ||
48
+ page.frames.length > 512 ||
49
+ new TextEncoder().encode(JSON.stringify(page)).length > 1024 * 1024 + 16 * 1024)
50
+ throw new Error("Output recovery is incomplete");
51
+ if ((state.cursor ?? page.coverageStart) !== page.from ||
52
+ (state.through && state.through !== page.through) ||
53
+ (state.coverageStart && state.coverageStart !== page.coverageStart) ||
54
+ (state.legacyBaseThroughEventId !== undefined &&
55
+ state.legacyBaseThroughEventId !== page.legacyBaseThroughEventId))
56
+ throw new Error("Output recovery page changed its snapshot");
57
+ let previous = position(page.from);
58
+ const through = position(page.through);
59
+ const start = position(page.coverageStart);
60
+ if (start.sequence !== 0 ||
61
+ start.identity !== previous.identity ||
62
+ through.identity !== previous.identity ||
63
+ previous.sequence > through.sequence)
64
+ throw new Error("Output recovery cursor domain changed");
65
+ let pending = state.pending
66
+ ? { ...state.pending, chunks: state.pending.chunks.slice() }
67
+ : undefined;
68
+ let appliedCursor = state.appliedCursor ?? page.from;
69
+ const events = [];
70
+ for (const frame of page.frames) {
71
+ const next = position(frame.cursor);
72
+ if (next.identity !== previous.identity ||
73
+ next.sequence !== previous.sequence + 1 ||
74
+ frame.sequence !== next.sequence ||
75
+ next.offset <= previous.offset ||
76
+ next.sequence > through.sequence ||
77
+ !!frame.event === !!frame.fragment)
78
+ throw new Error("Output recovery has a gap");
79
+ let event = frame.event;
80
+ if (frame.fragment) {
81
+ const part = frame.fragment;
82
+ if (!pending) {
83
+ if (part.index !== 0 ||
84
+ !Number.isSafeInteger(part.total) ||
85
+ part.total < 2 ||
86
+ part.total > 256)
87
+ throw new Error("Output fragment has no beginning");
88
+ pending = { id: part.id, total: part.total, next: 0, bytes: 0, chunks: [] };
89
+ }
90
+ if (part.id !== pending.id || part.total !== pending.total || part.index !== pending.next)
91
+ throw new Error("Output fragment has a gap");
92
+ const bytes = decodeFragment(part.data);
93
+ pending.bytes += bytes.length;
94
+ if (pending.bytes > MAX_EVENT_BYTES)
95
+ throw new Error("Output fragment exceeds its budget");
96
+ pending.chunks.push(bytes);
97
+ pending.next++;
98
+ if (pending.next === pending.total) {
99
+ const data = new Uint8Array(pending.bytes);
100
+ let offset = 0;
101
+ for (const chunk of pending.chunks) {
102
+ data.set(chunk, offset);
103
+ offset += chunk.length;
104
+ }
105
+ event = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(data));
106
+ pending = undefined;
107
+ }
108
+ }
109
+ else if (pending)
110
+ throw new Error("Output fragment was interrupted");
111
+ if (event) {
112
+ if (typeof event !== "object" ||
113
+ typeof event.type !== "string" ||
114
+ event.outputRecovery === "incomplete")
115
+ throw new Error("Invalid output event");
116
+ events.push({
117
+ ...projectOutputUserEvent(event),
118
+ outputCursor: frame.cursor,
119
+ });
120
+ appliedCursor = frame.cursor;
121
+ }
122
+ previous = next;
123
+ }
124
+ const next = position(page.next);
125
+ if (next.identity !== previous.identity ||
126
+ next.sequence !== previous.sequence ||
127
+ next.offset !== previous.offset ||
128
+ (page.frames.at(-1)?.cursor ?? page.from) !== page.next ||
129
+ page.complete !== (page.next === page.through) ||
130
+ (page.complete && pending) ||
131
+ (!page.complete && !page.frames.length))
132
+ throw new Error("Output recovery page did not establish coverage");
133
+ state.cursor = page.next;
134
+ state.appliedCursor = appliedCursor;
135
+ state.through = page.through;
136
+ state.coverageStart = page.coverageStart;
137
+ state.legacyBaseThroughEventId = page.legacyBaseThroughEventId;
138
+ state.pending = pending;
139
+ for (const event of events)
140
+ emit(event);
141
+ return page.complete;
142
+ }
143
+ catch {
144
+ state.incomplete = true;
145
+ state.pending = undefined;
146
+ return false;
147
+ }
148
+ }
@@ -21,6 +21,7 @@ export interface UserAttachmentSummary {
21
21
  mime?: string;
22
22
  size: number;
23
23
  path?: string;
24
+ absPath?: string;
24
25
  }
25
26
  export type ChatItem = {
26
27
  kind: "user";
@@ -15,6 +15,7 @@
15
15
  * UI can scope subagent rows (see commit fbe6f68 — task_update must be isolated
16
16
  * by agentId so a subagent never clobbers the main list).
17
17
  */
18
+ import { projectOutputUserEvent } from "./userMessageDisplay.js";
18
19
  /** Strip <system-reminder>…</system-reminder> blocks from tool output before
19
20
  * rendering — they're context for the model, not status for the user. */
20
21
  function stripSystemReminders(s) {
@@ -87,7 +88,8 @@ export function reduceStream(state, raw) {
87
88
  const unwrapped = asStreamEvent(raw);
88
89
  if (!unwrapped)
89
90
  return state;
90
- const { event, sessionId } = unwrapped;
91
+ const { sessionId } = unwrapped;
92
+ const event = projectOutputUserEvent(unwrapped.event);
91
93
  const type = event.type;
92
94
  let s = state;
93
95
  if (sessionId && sessionId !== s.sessionId) {
@@ -503,7 +505,15 @@ export function reduceStream(state, raw) {
503
505
  return { ...s, title: event.title };
504
506
  }
505
507
  case "session_user_message":
508
+ case "steer_injected":
506
509
  case "user_message": {
510
+ if (event.agentId)
511
+ return s;
512
+ if (event.injected === true ||
513
+ event.authority === "agent" ||
514
+ event.authority === "system" ||
515
+ event.authority === "policy")
516
+ return s;
507
517
  // History replay surfaces past user turns as a synthetic event so the
508
518
  // same reducer rebuilds the full conversation (the live path uses
509
519
  // appendUserMessage instead, since the phone echoes locally).
@@ -3,16 +3,11 @@
3
3
  * stream events used by the live reducer. Keep Node/Electron readers outside
4
4
  * this module: the Hub, phone and other browser hosts can share this boundary.
5
5
  */
6
- import { type ChatState, type UserAttachmentSummary } from "./streamReducer.js";
6
+ import { type ChatState } from "./streamReducer.js";
7
+ export { transcriptUserDisplay, projectOutputUserEvent } from "./userMessageDisplay.js";
7
8
  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
9
  /** Durable logs contain both assistant tool_use blocks and dedicated tool_use
14
10
  * events. Their tool IDs are identical: emit idempotent starts, never two cards.
15
11
  */
16
12
  export declare function transcriptToStreamEvents(records: readonly unknown[]): ObjectValue[];
17
13
  export declare function replayTranscript(records: readonly unknown[]): ChatState;
18
- export {};
@@ -3,7 +3,9 @@
3
3
  * stream events used by the live reducer. Keep Node/Electron readers outside
4
4
  * this module: the Hub, phone and other browser hosts can share this boundary.
5
5
  */
6
- import { initialChatState, reduceStream, } from "./streamReducer.js";
6
+ import { initialChatState, reduceStream } from "./streamReducer.js";
7
+ import { transcriptUserDisplay } from "./userMessageDisplay.js";
8
+ export { transcriptUserDisplay, projectOutputUserEvent } from "./userMessageDisplay.js";
7
9
  function object(value) {
8
10
  return value !== null && typeof value === "object" && !Array.isArray(value)
9
11
  ? value
@@ -25,90 +27,6 @@ function textContent(value) {
25
27
  .map((block) => text(block.text))
26
28
  .join("");
27
29
  }
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
30
  function eventData(event) {
113
31
  return object(event.data) ?? object(event.message) ?? event;
114
32
  }
@@ -0,0 +1,17 @@
1
+ /** Shared browser-only user input projection for live and durable replay. */
2
+ import type { UserAttachmentSummary } from "./streamReducer.js";
3
+ type ObjectValue = Record<string, unknown>;
4
+ /** Hide engine attachment plumbing while retaining attachment-only messages. */
5
+ export declare function transcriptUserDisplay(data: ObjectValue, options?: {
6
+ includeAbsolutePaths?: boolean;
7
+ cwd?: string;
8
+ }): {
9
+ text: string;
10
+ attachments: UserAttachmentSummary[];
11
+ };
12
+ /** Apply the canonical display projection without copying image payloads. */
13
+ export declare function projectOutputUserEvent<T extends {
14
+ type?: unknown;
15
+ transcriptMessage?: unknown;
16
+ }>(event: T): T;
17
+ export {};
@@ -0,0 +1,130 @@
1
+ function object(value) {
2
+ return value !== null && typeof value === "object" && !Array.isArray(value)
3
+ ? value
4
+ : undefined;
5
+ }
6
+ function text(value) {
7
+ return typeof value === "string" ? value : "";
8
+ }
9
+ function contentBlocks(value) {
10
+ return Array.isArray(value)
11
+ ? value.map(object).filter((block) => block !== undefined)
12
+ : [];
13
+ }
14
+ function textContent(value) {
15
+ if (typeof value === "string")
16
+ return value;
17
+ return contentBlocks(value)
18
+ .filter((block) => block.type === "text")
19
+ .map((block) => text(block.text))
20
+ .join("");
21
+ }
22
+ function unescapeAttribute(value) {
23
+ return value
24
+ .replace(/&quot;/g, '"')
25
+ .replace(/&lt;/g, "<")
26
+ .replace(/&gt;/g, ">")
27
+ .replace(/&amp;/g, "&");
28
+ }
29
+ function attachmentName(path) {
30
+ const name = path.replace(/\\/g, "/").split("/").filter(Boolean).pop() || "附件";
31
+ // The canonical staging service prefixes a content hash; it is not part of
32
+ // the filename the user selected. Other paths keep their original basename.
33
+ return path.includes(".code-shell/attachments/") ? name.replace(/^[a-f0-9]{16}-/, "") : name;
34
+ }
35
+ function byteSize(value) {
36
+ const number = typeof value === "number" ? value : Number(value);
37
+ return Number.isSafeInteger(number) && number >= 0 ? number : 0;
38
+ }
39
+ /** Hide engine attachment plumbing while retaining attachment-only messages. */
40
+ export function transcriptUserDisplay(data, options = {}) {
41
+ const attachments = [];
42
+ const absolutePath = (path) => /^(?:[a-zA-Z]:[\\/]|[\\/])/.test(path) || !options.cwd
43
+ ? path
44
+ : `${options.cwd.replace(/[\\/]$/, "")}/${path}`;
45
+ const imagePaths = [];
46
+ let raw = textContent(data.content);
47
+ raw = raw.replace(/<attached-file\s+path="([^"]*)">\s*([\s\S]*?)<\/attached-file>/g, (match, path, metadata) => {
48
+ if (!/^absolutePath:\s*.+$/m.test(metadata) || !/^origin:\s*.+$/m.test(metadata)) {
49
+ return match;
50
+ }
51
+ const mime = metadata.match(/^mime:\s*(.+)$/m)?.[1]?.trim();
52
+ attachments.push({
53
+ name: attachmentName(unescapeAttribute(path)),
54
+ path: unescapeAttribute(path),
55
+ ...(options.includeAbsolutePaths
56
+ ? { absPath: metadata.match(/^absolutePath:\s*(.+)$/m)[1].trim() }
57
+ : {}),
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
+ ...(options.includeAbsolutePaths ? { absPath: absolutePath(unescapeAttribute(path)) } : {}),
68
+ size: 0,
69
+ });
70
+ return "";
71
+ });
72
+ raw = raw.replace(/<attached-image-paths>\s*([\s\S]*?)<\/attached-image-paths>\s*(?:\(上面附带的图片在工作区的真实路径[^\n]*\))?/g, (_match, paths) => {
73
+ imagePaths.push(...paths
74
+ .split("\n")
75
+ .map((path) => path.trim())
76
+ .filter(Boolean));
77
+ return "";
78
+ });
79
+ const imageBlocks = contentBlocks(data.content).filter((block) => block.type === "image");
80
+ for (let index = 0; index < Math.max(imageBlocks.length, imagePaths.length); index++) {
81
+ const source = object(imageBlocks[index]?.source);
82
+ const encoded = text(source?.data).replace(/\s/g, "");
83
+ attachments.push({
84
+ name: imagePaths[index] ? attachmentName(imagePaths[index]) : `图片 ${index + 1}`,
85
+ ...(imagePaths[index] ? { path: imagePaths[index] } : {}),
86
+ ...(options.includeAbsolutePaths && imagePaths[index]
87
+ ? { absPath: absolutePath(imagePaths[index]) }
88
+ : {}),
89
+ size: encoded
90
+ ? Math.max(0, Math.floor((encoded.length * 3) / 4) - (encoded.match(/=+$/)?.[0].length ?? 0))
91
+ : byteSize(source?.byteLength),
92
+ ...(typeof source?.media_type === "string" ? { mime: source.media_type } : {}),
93
+ });
94
+ }
95
+ const explicit = Array.isArray(data.attachments)
96
+ ? data.attachments.flatMap((item) => {
97
+ const attachment = object(item);
98
+ const name = text(attachment?.name) || text(attachment?.originalName);
99
+ return name
100
+ ? [
101
+ {
102
+ name,
103
+ size: byteSize(attachment?.size),
104
+ ...(typeof attachment?.mime === "string" ? { mime: attachment.mime } : {}),
105
+ ...(typeof attachment?.path === "string" ? { path: attachment.path } : {}),
106
+ },
107
+ ]
108
+ : [];
109
+ })
110
+ : [];
111
+ return {
112
+ text: typeof data.displayText === "string" ? data.displayText : raw.trim(),
113
+ attachments: explicit.length ? explicit : attachments,
114
+ };
115
+ }
116
+ /** Apply the canonical display projection without copying image payloads. */
117
+ export function projectOutputUserEvent(event) {
118
+ if (event.type !== "session_user_message" && event.type !== "steer_injected")
119
+ return event;
120
+ const message = object(event.transcriptMessage);
121
+ return message
122
+ ? {
123
+ ...event,
124
+ ...transcriptUserDisplay(message, {
125
+ includeAbsolutePaths: true,
126
+ ...(typeof message.cwd === "string" ? { cwd: message.cwd } : {}),
127
+ }),
128
+ }
129
+ : event;
130
+ }