@keyou007/agent-bot 0.1.24-alpha.2 → 0.1.24-alpha.3

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 (95) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +27 -5
  3. package/README.zh.md +26 -5
  4. package/dist/acp/AcpJsonRpcConnection.js +3 -1
  5. package/dist/acp/AcpJsonRpcConnection.js.map +1 -1
  6. package/dist/cli/SelfUpdateRunner.js.map +1 -1
  7. package/dist/cli/SelfUpdater.js +2 -0
  8. package/dist/cli/SelfUpdater.js.map +1 -1
  9. package/dist/codex/AppServerConnection.js +3 -1
  10. package/dist/codex/AppServerConnection.js.map +1 -1
  11. package/dist/codex/CodexEventMapper.js +28 -1
  12. package/dist/codex/CodexEventMapper.js.map +1 -1
  13. package/dist/codex/CodexRuntime.js +92 -0
  14. package/dist/codex/CodexRuntime.js.map +1 -1
  15. package/dist/commands/CommandRouter.js +5 -0
  16. package/dist/commands/CommandRouter.js.map +1 -1
  17. package/dist/feishu/CardRenderer.js +45 -23
  18. package/dist/feishu/CardRenderer.js.map +1 -1
  19. package/dist/feishu/FeishuConnector.js +2 -0
  20. package/dist/feishu/FeishuConnector.js.map +1 -1
  21. package/dist/feishu/FeishuMessageClient.js +44 -0
  22. package/dist/feishu/FeishuMessageClient.js.map +1 -1
  23. package/dist/feishu/FeishuTurnPresenter.js +114 -37
  24. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  25. package/dist/feishu/RecentMessageContext.js +70 -0
  26. package/dist/feishu/RecentMessageContext.js.map +1 -0
  27. package/dist/index.js +62 -24
  28. package/dist/index.js.map +1 -1
  29. package/dist/local-files/DiagramPreview.js +171 -0
  30. package/dist/local-files/DiagramPreview.js.map +1 -0
  31. package/dist/local-files/LocalFileViewerServer.js +141 -3
  32. package/dist/local-files/LocalFileViewerServer.js.map +1 -1
  33. package/dist/local-files/PreviewSyntaxHighlight.js +57 -0
  34. package/dist/local-files/PreviewSyntaxHighlight.js.map +1 -0
  35. package/dist/local-files/TurnPreviewPage.js +495 -60
  36. package/dist/local-files/TurnPreviewPage.js.map +1 -1
  37. package/dist/presentation/OutboundRouter.js +18 -9
  38. package/dist/presentation/OutboundRouter.js.map +1 -1
  39. package/dist/presentation/TurnStateReducer.js +155 -6
  40. package/dist/presentation/TurnStateReducer.js.map +1 -1
  41. package/dist/presentation/turnReasoning.js +23 -0
  42. package/dist/presentation/turnReasoning.js.map +1 -0
  43. package/dist/proxy/ProxySessionController.js +460 -34
  44. package/dist/proxy/ProxySessionController.js.map +1 -1
  45. package/dist/state/StateStore.js +43 -2
  46. package/dist/state/StateStore.js.map +1 -1
  47. package/dist/state/TurnPreviewJournal.js +469 -0
  48. package/dist/state/TurnPreviewJournal.js.map +1 -0
  49. package/dist/state/migrations.js +10 -0
  50. package/dist/state/migrations.js.map +1 -1
  51. package/dist/updates/AutomaticUpdatePreparer.js +16 -7
  52. package/dist/updates/AutomaticUpdatePreparer.js.map +1 -1
  53. package/dist/updates/ManualUpdateController.js +102 -0
  54. package/dist/updates/ManualUpdateController.js.map +1 -0
  55. package/dist/updates/PublishedRelease.js +42 -4
  56. package/dist/updates/PublishedRelease.js.map +1 -1
  57. package/dist/utils/ProtocolLineWarning.js +15 -0
  58. package/dist/utils/ProtocolLineWarning.js.map +1 -0
  59. package/docs/technical-reference.md +21 -1
  60. package/docs/technical-reference.zh.md +21 -1
  61. package/npm-shrinkwrap.json +1401 -10
  62. package/package.json +3 -1
  63. package/skills/agent-bot/SKILL.md +8 -1
  64. package/src/acp/AcpJsonRpcConnection.ts +3 -1
  65. package/src/cli/SelfUpdateRunner.ts +1 -0
  66. package/src/cli/SelfUpdater.ts +2 -0
  67. package/src/codex/AppServerConnection.ts +3 -1
  68. package/src/codex/CodexEventMapper.ts +29 -1
  69. package/src/codex/CodexRuntime.ts +87 -0
  70. package/src/commands/CommandRouter.ts +4 -0
  71. package/src/commands/commandTypes.ts +1 -0
  72. package/src/feishu/CardRenderer.ts +43 -21
  73. package/src/feishu/FeishuConnector.ts +2 -0
  74. package/src/feishu/FeishuMessageClient.ts +44 -0
  75. package/src/feishu/FeishuTurnPresenter.ts +107 -31
  76. package/src/feishu/RecentMessageContext.ts +90 -0
  77. package/src/feishu/types.ts +5 -0
  78. package/src/index.ts +62 -22
  79. package/src/local-files/DiagramPreview.ts +173 -0
  80. package/src/local-files/LocalFileViewerServer.ts +129 -2
  81. package/src/local-files/PreviewSyntaxHighlight.ts +58 -0
  82. package/src/local-files/TurnPreviewPage.ts +535 -57
  83. package/src/presentation/OutboundRouter.ts +25 -7
  84. package/src/presentation/TurnStateReducer.ts +151 -7
  85. package/src/presentation/turnReasoning.ts +21 -0
  86. package/src/presentation/turnViewTypes.ts +19 -1
  87. package/src/proxy/ProxySessionController.ts +452 -24
  88. package/src/runtime/types.ts +21 -0
  89. package/src/state/StateStore.ts +45 -2
  90. package/src/state/TurnPreviewJournal.ts +398 -0
  91. package/src/state/migrations.ts +10 -0
  92. package/src/updates/AutomaticUpdatePreparer.ts +21 -7
  93. package/src/updates/ManualUpdateController.ts +116 -0
  94. package/src/updates/PublishedRelease.ts +49 -3
  95. package/src/utils/ProtocolLineWarning.ts +13 -0
@@ -42,6 +42,7 @@ export interface PlanStep {
42
42
  }
43
43
 
44
44
  export interface ToolState {
45
+ previewRevision?: number;
45
46
  id: string;
46
47
  title: string;
47
48
  kind: string;
@@ -98,7 +99,10 @@ export type AgentEvent =
98
99
  activityId?: string;
99
100
  append?: boolean;
100
101
  severity?: "warning";
102
+ reasoning?: { itemId: string; summaryIndex: number };
101
103
  }
104
+ | { type: "reasoning_delta"; sessionId: string; turnId: string; itemId: string; contentIndex: number; text: string }
105
+ | { type: "reasoning_completed"; sessionId: string; turnId: string; itemId: string; summary: string[]; content: string[] }
102
106
  | { type: "plan_updated"; sessionId: string; turnId: string; steps: PlanStep[] }
103
107
  | { type: "tool_started"; sessionId: string; turnId: string; tool: ToolState }
104
108
  | { type: "tool_updated"; sessionId: string; turnId: string; tool: ToolState }
@@ -135,6 +139,22 @@ export interface ConversationTurn {
135
139
  messages: Array<{ role: "user" | "assistant"; text: string }>;
136
140
  }
137
141
 
142
+ export interface RemoteTurnDetails {
143
+ turnId: string;
144
+ status: "completed" | "cancelled" | "failed";
145
+ startedAt?: number;
146
+ completedAt?: number;
147
+ durationMs?: number;
148
+ finalResponse: string;
149
+ error?: string;
150
+ items: Array<
151
+ | { kind: "message"; id: string; role: "user" | "assistant"; text: string; localImagePaths?: string[] }
152
+ | { kind: "reasoning"; id: string; summary: string[]; content: string[] }
153
+ | { kind: "tool"; tool: ToolState }
154
+ | { kind: "plan"; steps: PlanStep[] }
155
+ >;
156
+ }
157
+
138
158
  export interface RemoteTurnPage {
139
159
  turns: RemoteCompletedTurnSummary[];
140
160
  nextCursor?: string;
@@ -273,6 +293,7 @@ export interface AgentRuntime {
273
293
  readRemoteForkSource?(remoteSessionId: string): Promise<RemoteSessionSummary>;
274
294
  listRemoteTurnSummaries?(remoteSessionId: string, input: { cursor?: string; limit: number }): Promise<RemoteTurnPage>;
275
295
  readConversation?(remoteSessionId: string, throughTurnId?: string): AsyncIterable<ConversationTurn>;
296
+ readRemoteTurn?(remoteSessionId: string, turnId: string): Promise<RemoteTurnDetails>;
276
297
  inspectRemoteSessionActivity?(remoteSessionId: string): Promise<RemoteSessionActivity>;
277
298
  synchronizeSession(sessionId: string): Promise<RuntimeSession>;
278
299
  startTurn(sessionId: string, prompt: RuntimePrompt): Promise<string>;
@@ -1,4 +1,7 @@
1
1
  import fs from "node:fs";
2
+ import { TurnPreviewJournal } from "./TurnPreviewJournal.js";
3
+ import { compactTurnView } from "../presentation/TurnStateReducer.js";
4
+ import type { TurnViewState } from "../presentation/turnViewTypes.js";
2
5
  import path from "node:path";
3
6
  import Database from "better-sqlite3";
4
7
  import { migrations } from "./migrations.js";
@@ -260,8 +263,10 @@ interface TurnAttemptRow {
260
263
 
261
264
  export class StateStore {
262
265
  private readonly db: Database.Database;
266
+ readonly previews: TurnPreviewJournal;
263
267
 
264
- constructor(sqlitePath: string) {
268
+ constructor(sqlitePath: string, onPreviewError?: (error: unknown) => void) {
269
+ this.previews = new TurnPreviewJournal(path.join(path.dirname(sqlitePath), "turn-previews"), onPreviewError);
265
270
  fs.mkdirSync(path.dirname(sqlitePath), { recursive: true });
266
271
  this.db = new Database(sqlitePath);
267
272
  this.db.pragma("journal_mode = WAL");
@@ -406,6 +411,7 @@ export class StateStore {
406
411
  }
407
412
 
408
413
  close(): void {
414
+ this.previews.close();
409
415
  this.db.close();
410
416
  }
411
417
 
@@ -1031,6 +1037,7 @@ export class StateStore {
1031
1037
  }
1032
1038
 
1033
1039
  saveTurnSnapshot(turnId: string, localSessionId: string, snapshot: unknown, contextKey?: string): void {
1040
+ if ((snapshot as TurnViewState)?.previewJournal) snapshot = compactTurnView(snapshot as TurnViewState);
1034
1041
  const now = new Date().toISOString();
1035
1042
  this.db
1036
1043
  .prepare(
@@ -1042,6 +1049,9 @@ export class StateStore {
1042
1049
  context_key = coalesce(excluded.context_key, turn_snapshots.context_key),
1043
1050
  snapshot_json = excluded.snapshot_json,
1044
1051
  updated_at = excluded.updated_at
1052
+ WHERE turn_snapshots.snapshot_json != excluded.snapshot_json
1053
+ OR turn_snapshots.local_session_id != excluded.local_session_id
1054
+ OR (excluded.context_key IS NOT NULL AND turn_snapshots.context_key IS NOT excluded.context_key)
1045
1055
  `,
1046
1056
  )
1047
1057
  .run(turnId, localSessionId, contextKey ?? null, JSON.stringify(snapshot), now);
@@ -1108,7 +1118,9 @@ export class StateStore {
1108
1118
  for (const id of branch.reverse()) {
1109
1119
  const row = read.get(id) as { prompt: string | null; answer: string | null; followups: string };
1110
1120
  const messages: ConversationTurn["messages"] = [];
1111
- for (const text of [row.prompt, ...(JSON.parse(row.followups) as string[])]) {
1121
+ const preview = this.previews.has(id) ? this.previews.load(id, false) : undefined;
1122
+ const followups = preview ? preview.activities.flatMap((a) => a.kind === "user" ? [a.text] : []) : JSON.parse(row.followups) as string[];
1123
+ for (const text of [row.prompt, ...followups]) {
1112
1124
  if (text?.trim()) messages.push({ role: "user", text });
1113
1125
  }
1114
1126
  if (row.answer?.trim()) messages.push({ role: "assistant", text: row.answer });
@@ -1880,6 +1892,37 @@ export class StateStore {
1880
1892
  .run(contextKey, eventType, JSON.stringify(payload), new Date().toISOString());
1881
1893
  }
1882
1894
 
1895
+ recentSubmittedMessageIds(localSessionId: string, remoteSessionId: string, contextKey: string): Set<string> {
1896
+ const rows = this.db.prepare(`SELECT b.message_id
1897
+ FROM (SELECT turn_id FROM turn_runtime_origins
1898
+ WHERE local_session_id = ? AND remote_session_id = ? ORDER BY created_at DESC LIMIT 200) o
1899
+ JOIN message_turn_bindings b ON b.turn_id = o.turn_id
1900
+ JOIN turn_snapshots t ON t.turn_id = o.turn_id
1901
+ WHERE b.local_session_id = ? AND t.context_key = ?
1902
+ ORDER BY b.created_at DESC LIMIT 200`)
1903
+ .all(localSessionId, remoteSessionId, localSessionId, contextKey) as Array<{ message_id: string }>;
1904
+ return new Set(rows.map((row) => row.message_id));
1905
+ }
1906
+
1907
+ recentContextMessageIds(localSessionId: string, remoteSessionId: string, contextKey: string, includeQueued = true): Set<string> {
1908
+ const rows = this.db.prepare(`SELECT message_id FROM recent_context_receipts r
1909
+ WHERE local_session_id = ? AND remote_session_id = ? AND context_key = ?
1910
+ AND (EXISTS (SELECT 1 FROM message_turn_bindings b WHERE b.message_id = r.prompt_message_id AND b.local_session_id = r.local_session_id)
1911
+ OR (? AND EXISTS (SELECT 1 FROM queued_prompts q WHERE q.message_id = r.prompt_message_id AND q.local_session_id = r.local_session_id)))`)
1912
+ .all(localSessionId, remoteSessionId, contextKey, includeQueued ? 1 : 0) as Array<{ message_id: string }>;
1913
+ return new Set(rows.map((row) => row.message_id));
1914
+ }
1915
+
1916
+ rememberRecentContextMessages(localSessionId: string, remoteSessionId: string, contextKey: string, promptMessageId: string, messageIds: string[]): void {
1917
+ const insert = this.db.prepare("INSERT INTO recent_context_receipts (local_session_id, remote_session_id, context_key, prompt_message_id, message_id) VALUES (?, ?, ?, ?, ?) ON CONFLICT(local_session_id, remote_session_id, context_key, message_id) DO UPDATE SET prompt_message_id = excluded.prompt_message_id");
1918
+ this.db.transaction(() => {
1919
+ for (const id of new Set(messageIds)) insert.run(localSessionId, remoteSessionId, contextKey, promptMessageId, id);
1920
+ this.db.prepare(`DELETE FROM recent_context_receipts WHERE local_session_id = ? AND remote_session_id = ? AND context_key = ? AND rowid NOT IN (
1921
+ SELECT rowid FROM recent_context_receipts WHERE local_session_id = ? AND remote_session_id = ? AND context_key = ? ORDER BY rowid DESC LIMIT 200
1922
+ )`).run(localSessionId, remoteSessionId, contextKey, localSessionId, remoteSessionId, contextKey);
1923
+ })();
1924
+ }
1925
+
1883
1926
  hasInjectedTopicRoot(localSessionId: string, rootMessageId: string): boolean {
1884
1927
  return Boolean(this.db.prepare(`
1885
1928
  SELECT 1
@@ -0,0 +1,398 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash, type Hash } from "node:crypto";
4
+ import type { AgentEvent, ToolState } from "../runtime/types.js";
5
+ import { reduceTurnEvent } from "../presentation/TurnStateReducer.js";
6
+ import type { TurnViewState } from "../presentation/turnViewTypes.js";
7
+
8
+ type TextChange = { key: string; field: string; append: boolean; text: string };
9
+ export type PreviewRecord =
10
+ | { kind: "seed"; state: TurnViewState }
11
+ | { kind: "state"; patch: Partial<TurnViewState> }
12
+ | { kind: "user"; id: string; text: string; images?: string[] }
13
+ | { kind: "event"; event: AgentEvent; texts: TextChange[] };
14
+ type Stream = { length: number; hash: Hash };
15
+ type Writer = { seeded?: boolean; pending: string[]; bytes: number; dirtyDisk?: boolean; streams: Map<string, Stream>; timings: Map<string, number>; timer?: NodeJS.Timeout; error?: unknown };
16
+
17
+ /** A presentation journal, not an agent/tool replay mechanism. All paths are local to this store. */
18
+ export class TurnPreviewJournal {
19
+ private readonly writers = new Map<string, Writer>();
20
+ private detailExpiry?: NodeJS.Timeout;
21
+ private readonly detailReaders = new Map<string, { projection: TurnPreviewProjection; offset: number; accessed: number }>();
22
+ constructor(readonly directory: string, private readonly onError: (error: unknown) => void = () => {}) {}
23
+
24
+ file(turnId: string): string {
25
+ const hash = createHash("sha256").update(turnId).digest("hex");
26
+ return path.join(this.directory, hash.slice(0, 2), hash + ".jsonl");
27
+ }
28
+
29
+ has(turnId: string): boolean { return this.writers.has(turnId) || fs.existsSync(this.file(turnId)); }
30
+
31
+ seed(state: TurnViewState): void {
32
+ const writer = this.writer(state.turnId);
33
+ if (writer.seeded) return;
34
+ this.append(state.turnId, { kind: "seed", state: { ...state, previewJournal: true } });
35
+ writer.seeded = true;
36
+ for (const change of seedTexts(state)) this.track(writer, change);
37
+ }
38
+
39
+ state(state: TurnViewState): void {
40
+ this.seed(state);
41
+ // Growing execution fields live only in events. Keep routing/final-delivery metadata here.
42
+ const { activities, reasoningItems, fullToolOutputs, fullToolErrors, toolStatuses,
43
+ completedTools, failedTools, activeTool, assistantText, progressText, plan, fileSummary,
44
+ activitiesTruncated, previewCursor, ...patch } = state;
45
+ this.append(state.turnId, { kind: "state", patch });
46
+ }
47
+
48
+ user(turnId: string, id: string, text: string, images?: string[]): void {
49
+ this.append(turnId, { kind: "user", id, text, images });
50
+ }
51
+
52
+ event(event: AgentEvent): void {
53
+ const writer = this.writer(event.turnId);
54
+ const texts: TextChange[] = [];
55
+ const copy = { ...event } as AgentEvent;
56
+ const capture = (key: string, field: string, text: string, append = false) => {
57
+ const old = writer.streams.get(key);
58
+ let suffix = text;
59
+ let incremental = append;
60
+ if (!append && old && text.length >= old.length
61
+ && createHash("sha256").update(text.slice(0, old.length)).digest("hex") === old.hash.copy().digest("hex")) {
62
+ suffix = text.slice(old.length);
63
+ incremental = true;
64
+ }
65
+ this.track(writer, { key, field, append: incremental, text: suffix });
66
+ texts.push({ key, field, append: incremental, text: suffix });
67
+ };
68
+ if (copy.type === "tool_started" || copy.type === "tool_updated") {
69
+ const startedAt = copy.tool.startedAt ?? writer.timings.get(copy.tool.id) ?? Date.now();
70
+ writer.timings.set(copy.tool.id, startedAt);
71
+ copy.tool = { ...copy.tool, startedAt,
72
+ completedAt: copy.tool.status === "running" ? copy.tool.completedAt : copy.tool.completedAt ?? Date.now() };
73
+ for (const field of ["title", "command", "output", "error"] as const) {
74
+ const text = copy.tool[field];
75
+ if (text === undefined) continue;
76
+ capture(`tool:${copy.tool.id}:${field}`, field, text);
77
+ delete (copy.tool as Partial<ToolState>)[field];
78
+ }
79
+ } else if (copy.type === "tool_output_delta") {
80
+ capture(`tool:${copy.toolId}:output`, "delta", copy.delta, true);
81
+ copy.delta = "";
82
+ } else if (copy.type === "progress") {
83
+ capture(copy.reasoning ? `reasoning:${copy.reasoning.itemId}:summary:${copy.reasoning.summaryIndex}`
84
+ : `progress:${copy.activityId ?? "progress"}`, "text", copy.text, copy.append);
85
+ copy.text = "";
86
+ copy.append = false;
87
+ }
88
+ if (copy.type === "reasoning_delta") {
89
+ capture(`reasoning:${copy.itemId}:content:${copy.contentIndex}`, "text", copy.text, true);
90
+ copy.text = "";
91
+ } else if (copy.type === "reasoning_completed") {
92
+ for (const field of ["summary", "content"] as const) {
93
+ copy[field] = copy[field].map((value, index) => {
94
+ capture(`reasoning:${copy.itemId}:${field}:${index}`, `${field}:${index}`, value);
95
+ return "";
96
+ });
97
+ }
98
+ }
99
+ this.append(event.turnId, { kind: "event", event: copy, texts });
100
+ }
101
+
102
+ flush(turnId?: string, durable = false): void {
103
+ if (!turnId) { for (const id of this.writers.keys()) this.flush(id, durable); return; }
104
+ const writer = this.writers.get(turnId);
105
+ if (!writer) return;
106
+ if (writer.timer) clearTimeout(writer.timer);
107
+ writer.timer = undefined;
108
+ if (writer.error) throw writer.error;
109
+ if (!writer.bytes && !(durable && writer.dirtyDisk)) return;
110
+ const file = this.file(turnId);
111
+ let fd: number | undefined;
112
+ let size = 0;
113
+ try {
114
+ fs.mkdirSync(path.dirname(file), { recursive: true });
115
+ fd = fs.openSync(file, "a");
116
+ size = fs.fstatSync(fd).size;
117
+ if (writer.bytes) fs.writeFileSync(fd, writer.pending.join(""));
118
+ writer.dirtyDisk = true;
119
+ if (durable) { fs.fsyncSync(fd); writer.dirtyDisk = false; }
120
+ writer.pending = [];
121
+ writer.bytes = 0;
122
+ } catch (error) {
123
+ // Never continue after a partial append: the journal must remain a valid prefix.
124
+ if (fd !== undefined) { try { fs.ftruncateSync(fd, size); } catch { /* Fail closed below. */ } }
125
+ writer.pending = [];
126
+ writer.bytes = 0;
127
+ writer.error = error;
128
+ throw error;
129
+ } finally { if (fd !== undefined) fs.closeSync(fd); }
130
+ }
131
+
132
+ release(turnId: string): void {
133
+ this.flush(turnId, true);
134
+ this.writers.delete(turnId);
135
+ }
136
+
137
+ close(): void {
138
+ try { this.flush(undefined, true); } finally {
139
+ for (const writer of this.writers.values()) if (writer.timer) clearTimeout(writer.timer);
140
+ this.writers.clear();
141
+ this.detailReaders.clear();
142
+ if (this.detailExpiry) clearTimeout(this.detailExpiry);
143
+ }
144
+ }
145
+
146
+ /** Bounded byte cursor; a partial final record is ignored until its newline is present. */
147
+ read(turnId: string, offset = 0, maxBytes = 256 * 1024, through = Infinity): { records: PreviewRecord[]; offset: number; end: boolean } {
148
+ const file = this.file(turnId);
149
+ if (!fs.existsSync(file)) return { records: [], offset, end: true };
150
+ const fd = fs.openSync(file, "r");
151
+ try {
152
+ const size = Math.min(fs.fstatSync(fd).size, through);
153
+ const records: PreviewRecord[] = [];
154
+ const start = offset;
155
+ let position = offset;
156
+ let parts: Buffer[] = [];
157
+ while (position < size) {
158
+ const chunk = Buffer.alloc(Math.min(64 * 1024, size - position));
159
+ const count = fs.readSync(fd, chunk, 0, chunk.length, position);
160
+ if (!count) break;
161
+ position += count;
162
+ let begin = 0;
163
+ let newline: number;
164
+ while ((newline = chunk.indexOf(10, begin)) !== -1 && newline < count) {
165
+ parts.push(chunk.subarray(begin, newline));
166
+ const line = Buffer.concat(parts);
167
+ records.push(JSON.parse(line.toString("utf8")) as PreviewRecord);
168
+ offset += line.length + 1;
169
+ parts = [];
170
+ begin = newline + 1;
171
+ }
172
+ if (begin < count) parts.push(chunk.subarray(begin, count));
173
+ if (records.length && position - start >= maxBytes) break;
174
+ }
175
+ return { records, offset, end: position >= size };
176
+ } finally { fs.closeSync(fd); }
177
+ }
178
+
179
+ load(turnId: string, details: boolean | string = true): TurnViewState | undefined {
180
+ this.flush(turnId);
181
+ const cacheKey = `${turnId}:${String(details)}`;
182
+ const cached = typeof details === "string" ? this.detailReaders.get(cacheKey) : undefined;
183
+ const projection = cached?.projection ?? new TurnPreviewProjection(details);
184
+ let offset = cached?.offset ?? 0;
185
+ for (;;) {
186
+ const batch = this.read(turnId, offset);
187
+ for (const record of batch.records) projection.apply(record, ++projection.revision);
188
+ if (batch.end || batch.offset === offset) {
189
+ if (typeof details === "string") {
190
+ const now = Date.now();
191
+ for (const [key, value] of this.detailReaders) if (now - value.accessed > 60_000) this.detailReaders.delete(key);
192
+ this.detailReaders.delete(cacheKey);
193
+ if (projection.retainedTextBytes() < 16 * 1024 * 1024)
194
+ this.detailReaders.set(cacheKey, { projection, offset: batch.offset, accessed: now });
195
+ if (this.detailExpiry) clearTimeout(this.detailExpiry);
196
+ this.detailExpiry = setTimeout(() => this.detailReaders.clear(), 60_000);
197
+ this.detailExpiry.unref();
198
+ while (this.detailReaders.size > 4) this.detailReaders.delete(this.detailReaders.keys().next().value!);
199
+ }
200
+ return projection.state ? { ...projection.state, previewCursor: batch.offset } : undefined;
201
+ }
202
+ offset = batch.offset;
203
+ }
204
+ }
205
+
206
+ outputAppend(turnId: string, toolId: string, after: number, through: number): string | undefined {
207
+ if (!Number.isSafeInteger(after) || after <= 0 || after > through) return undefined;
208
+ let offset = after;
209
+ const parts: string[] = [];
210
+ while (offset < through) {
211
+ const batch = this.read(turnId, offset, 256 * 1024, through);
212
+ if (batch.offset === offset) return undefined;
213
+ for (const record of batch.records) {
214
+ if (record.kind === "seed") return undefined;
215
+ if (record.kind !== "event") continue;
216
+ const event = record.event;
217
+ if ((event.type === "tool_started" || event.type === "tool_updated") && event.tool.id === toolId
218
+ && (event.tool.files || event.tool.imagePath)) return undefined;
219
+ for (const change of record.texts) {
220
+ if (!change.key.startsWith(`tool:${toolId}:`)) continue;
221
+ if (change.key === `tool:${toolId}:output` && change.append) parts.push(change.text);
222
+ else if (change.text || !change.append) return undefined;
223
+ }
224
+ }
225
+ offset = batch.offset;
226
+ }
227
+ return parts.join("");
228
+ }
229
+
230
+ private track(writer: Writer, change: TextChange): void {
231
+ const old = change.append ? writer.streams.get(change.key) : undefined;
232
+ const stream = old ?? { length: 0, hash: createHash("sha256") };
233
+ stream.hash.update(change.text);
234
+ stream.length += change.text.length;
235
+ writer.streams.set(change.key, stream);
236
+ }
237
+
238
+ private writer(turnId: string): Writer {
239
+ let writer = this.writers.get(turnId);
240
+ if (writer) return writer;
241
+ writer = { pending: [], bytes: 0, streams: new Map(), timings: new Map() };
242
+ // On first write after restart, rebuild only stream fingerprints, not the full execution.
243
+ let offset = 0;
244
+ if (fs.existsSync(this.file(turnId))) {
245
+ for (;;) {
246
+ const batch = this.read(turnId, offset);
247
+ for (const record of batch.records) {
248
+ if (record.kind === "event") {
249
+ for (const change of record.texts) this.track(writer, change);
250
+ const event = record.event;
251
+ if ((event.type === "tool_started" || event.type === "tool_updated") && event.tool.startedAt !== undefined)
252
+ writer.timings.set(event.tool.id, event.tool.startedAt);
253
+ }
254
+ if (record.kind === "seed") {
255
+ writer.seeded = true;
256
+ for (const change of seedTexts(record.state)) this.track(writer, change);
257
+ }
258
+ }
259
+ offset = batch.offset;
260
+ if (batch.end || !batch.records.length) break;
261
+ }
262
+ const size = fs.statSync(this.file(turnId)).size;
263
+ if (offset < size) fs.truncateSync(this.file(turnId), offset);
264
+ }
265
+ this.writers.set(turnId, writer);
266
+ return writer;
267
+ }
268
+
269
+ private append(turnId: string, record: PreviewRecord): void {
270
+ const writer = this.writer(turnId);
271
+ if (writer.error) throw writer.error;
272
+ const line = JSON.stringify(record) + "\n";
273
+ writer.pending.push(line);
274
+ writer.bytes += Buffer.byteLength(line);
275
+ if (writer.bytes >= 256 * 1024) this.flush(turnId);
276
+ else if (!writer.timer) {
277
+ writer.timer = setTimeout(() => {
278
+ try { this.flush(turnId); } catch (error) { this.onError(error); }
279
+ }, 250);
280
+ writer.timer.unref();
281
+ }
282
+ }
283
+ }
284
+
285
+ /** Built only when a human opens history. SSE keeps summaries, not full tool output, in memory. */
286
+ export class TurnPreviewProjection {
287
+ state?: TurnViewState;
288
+ revision = 0;
289
+ readonly changed = new Set<string>();
290
+ readonly removed = new Set<string>();
291
+ private readonly text = new Map<string, string>();
292
+ constructor(private readonly details: boolean | string = false) {}
293
+ retainedTextBytes(): number {
294
+ let bytes = 0;
295
+ for (const value of this.text.values()) bytes += value.length * 2;
296
+ return bytes;
297
+ }
298
+ private wantsFull(key: string): boolean {
299
+ return this.details === true || (typeof this.details === "string" && key.startsWith(`${this.details}:`));
300
+ }
301
+
302
+ apply(record: PreviewRecord, revision: number): void {
303
+ if (record.kind === "seed") {
304
+ this.state = { ...record.state, activities: [...record.state.activities], activitiesTruncated: false };
305
+ for (const change of seedTexts(record.state)) this.text.set(change.key, this.wantsFull(change.key) ? change.text : change.text.slice(0, 6000));
306
+ if (this.details !== true) {
307
+ this.state = { ...this.state, fullToolOutputs: {}, fullToolErrors: {}, activities: this.state.activities.map((a) => a.kind === "tool"
308
+ ? { ...a, tool: { ...a.tool, output: a.tool.output?.slice(0, 6000), error: a.tool.error?.slice(0, 6000) } } : a) };
309
+ }
310
+ if (typeof this.details === "string" && this.details.startsWith("tool:")) {
311
+ const id = this.details.slice(5);
312
+ const tool = record.state.activities.find((a) => a.kind === "tool" && a.id === id);
313
+ if (tool?.kind === "tool") {
314
+ this.state.activities = this.state.activities.map((a) => a.id === id ? tool : a);
315
+ this.state.fullToolOutputs = { [id]: record.state.fullToolOutputs?.[id] ?? tool.tool.output ?? "" };
316
+ this.state.fullToolErrors = { [id]: record.state.fullToolErrors?.[id] ?? tool.tool.error ?? "" };
317
+ }
318
+ }
319
+ for (const a of record.state.activities) this.changed.add(a.id);
320
+ return;
321
+ }
322
+ if (!this.state) return;
323
+ if (record.kind === "state") {
324
+ this.state = { ...this.state, ...record.patch, historyDetail: "full", previewJournal: true };
325
+ return;
326
+ }
327
+ if (record.kind === "user") {
328
+ const user = { kind: "user" as const, id: record.id, text: record.text, localImagePaths: record.images };
329
+ const index = this.state.activities.findIndex((a) => a.id === record.id);
330
+ this.state.activities = [...this.state.activities];
331
+ if (index < 0) this.state.activities.push(user); else this.state.activities[index] = user;
332
+ this.changed.add(record.id);
333
+ return;
334
+ }
335
+ let event = { ...record.event } as AgentEvent;
336
+ if (event.type === "tool_started" || event.type === "tool_updated") event.tool = { ...event.tool };
337
+ if (event.type === "reasoning_completed") event = { ...event, summary: [...event.summary], content: [...event.content] };
338
+ for (const change of record.texts) {
339
+ let value = (change.append ? this.text.get(change.key) ?? "" : "") + change.text;
340
+ const full = this.wantsFull(change.key);
341
+ if (!full && (change.key.startsWith("tool:") || change.key.startsWith("reasoning:"))) value = value.slice(0, 6000);
342
+ this.text.set(change.key, value);
343
+ if (event.type === "tool_started" || event.type === "tool_updated") {
344
+ (event.tool as unknown as Record<string, unknown>)[change.field] = value;
345
+ } else if (event.type === "progress") event.text = value;
346
+ else if (event.type === "reasoning_delta") event.text = change.text;
347
+ else if (event.type === "reasoning_completed") {
348
+ const [field, index] = change.field.split(":");
349
+ event[field as "summary" | "content"][Number(index)] = value;
350
+ }
351
+ else if (event.type === "tool_output_delta") {
352
+ const toolId = event.toolId;
353
+ const activity = this.state.activities.find((a) => a.kind === "tool" && a.id === toolId);
354
+ if (activity?.kind === "tool") event = { type: "tool_updated", sessionId: event.sessionId, turnId: event.turnId,
355
+ tool: { ...activity.tool, output: value } };
356
+ }
357
+ }
358
+ if (event.type === "tool_started" || event.type === "tool_updated") {
359
+ const toolId = event.tool.id;
360
+ const previous = this.state.activities.find((a) => a.kind === "tool" && a.id === toolId);
361
+ if (previous?.kind === "tool") event.tool = { ...previous.tool, ...event.tool };
362
+ event.tool.previewRevision = revision;
363
+ this.changed.add(event.tool.id);
364
+ } else if (event.type === "progress") {
365
+ this.changed.add(event.activityId ?? "progress");
366
+ if (event.reasoning) this.changed.add(`reasoning:${event.reasoning.itemId}`);
367
+ }
368
+ else if (event.type === "agent_text_delta" && event.replacesActivityId) this.removed.add(event.replacesActivityId);
369
+ if (event.type === "reasoning_delta" || event.type === "reasoning_completed") this.changed.add(`reasoning:${event.itemId}`);
370
+ const old = this.state;
371
+ this.state = reduceTurnEvent(old, event, true);
372
+ if (["completed", "failed", "cancelled"].includes(old.status) && !event.type.startsWith("turn_")) this.state.status = old.status;
373
+ const reasoningId = event.type === "reasoning_delta" || event.type === "reasoning_completed" ? event.itemId
374
+ : event.type === "progress" ? event.reasoning?.itemId : undefined;
375
+ if (reasoningId) this.state.reasoningItems = this.state.reasoningItems?.map((r) => r.itemId === reasoningId ? { ...r, previewRevision: revision } : r);
376
+ if (event.type === "context_compaction") {
377
+ for (const a of this.state.activities) if (!old.activities.includes(a)) this.changed.add(a.id);
378
+ }
379
+ if (this.details !== true && this.state.reasoningItems) this.state.reasoningItems = this.state.reasoningItems.map((r) => ({ ...r,
380
+ summary: r.summary.map((t) => this.wantsFull(`reasoning:${r.itemId}:`) ? t : t.slice(0, 6000)), content: r.content.map((t) => this.wantsFull(`reasoning:${r.itemId}:`) ? t : t.slice(0, 6000)) }));
381
+ this.state.activitiesTruncated = false;
382
+ }
383
+ }
384
+
385
+ function seedTexts(state: TurnViewState): TextChange[] {
386
+ const result: TextChange[] = [];
387
+ for (const a of state.activities) {
388
+ if (a.kind === "tool") for (const field of ["title", "command", "output", "error"] as const) {
389
+ const text = (field === "output" ? state.fullToolOutputs?.[a.id] : field === "error" ? state.fullToolErrors?.[a.id] : undefined) ?? a.tool[field];
390
+ if (text !== undefined) result.push({ key: `tool:${a.id}:${field}`, field, append: false, text });
391
+ }
392
+ else if (a.kind !== "user") result.push({ key: `progress:${a.id}`, field: "text", append: false, text: a.text });
393
+ }
394
+ for (const item of state.reasoningItems ?? []) for (const field of ["summary", "content"] as const) {
395
+ item[field].forEach((text, i) => result.push({ key: `reasoning:${item.itemId}:${field}:${i}`, field: `${field}:${i}`, append: false, text }));
396
+ }
397
+ return result;
398
+ }
@@ -207,4 +207,14 @@ export const migrations = [
207
207
  );
208
208
  CREATE INDEX IF NOT EXISTS idx_update_notices_status ON update_notices(status);
209
209
  `,
210
+ `
211
+ CREATE TABLE IF NOT EXISTS recent_context_receipts (
212
+ prompt_message_id TEXT NOT NULL,
213
+ local_session_id TEXT NOT NULL,
214
+ remote_session_id TEXT NOT NULL,
215
+ context_key TEXT NOT NULL,
216
+ message_id TEXT NOT NULL,
217
+ PRIMARY KEY (local_session_id, remote_session_id, context_key, message_id)
218
+ );
219
+ `,
210
220
  ] as const;
@@ -5,11 +5,11 @@ import { z } from "zod";
5
5
  import { loadConfig } from "../config/loadConfig.js";
6
6
  import { controlEndpoint } from "../cli/controlProtocol.js";
7
7
  import { finalizeSelfUpdatePlan, prepareSelfUpdate, releaseSelfUpdatePlan, requireNpmSelfUpdateInstallation } from "../cli/SelfUpdater.js";
8
- import { stableVersionSchema } from "./PublishedRelease.js";
8
+ import { publishedVersionSchema, stableVersionSchema } from "./PublishedRelease.js";
9
9
 
10
10
  const resultSchema = z.discriminatedUnion("status", [
11
11
  z.object({ status: z.literal("current") }),
12
- z.object({ status: z.literal("prepared"), planPath: z.string().min(1), targetVersion: stableVersionSchema }),
12
+ z.object({ status: z.literal("prepared"), planPath: z.string().min(1), targetVersion: publishedVersionSchema }),
13
13
  ]);
14
14
  const runFile = promisify(execFile);
15
15
  type RunPreparation = (file: string, args: string[], options: ExecFileOptionsWithStringEncoding) => Promise<{ stdout: string }>;
@@ -32,11 +32,24 @@ export async function prepareAutomaticUpdate(
32
32
  return result;
33
33
  }
34
34
 
35
- async function runPreparer(argument: string, profile: UpdateProfile, run: RunPreparation): Promise<unknown> {
35
+ export async function prepareSelectedUpdate(
36
+ version: string,
37
+ profile: UpdateProfile,
38
+ run: RunPreparation = runFile,
39
+ ): Promise<z.infer<typeof resultSchema>> {
40
+ publishedVersionSchema.parse(version);
41
+ const result = resultSchema.parse(await runPreparer("--manual", profile, run, version));
42
+ if (result.status === "prepared" && result.targetVersion !== version) {
43
+ throw new Error("The prepared update does not match the selected version.");
44
+ }
45
+ return result;
46
+ }
47
+
48
+ async function runPreparer(argument: string, profile: UpdateProfile, run: RunPreparation, version?: string): Promise<unknown> {
36
49
  const environment: NodeJS.ProcessEnv = { ...process.env, AGENT_BOT_HOME: profile.home, AGENT_BOT_CONFIG: profile.configPath };
37
50
  delete environment.AGENT_BOT;
38
51
  // npm preparation is synchronous and can take minutes; keep it off the live worker's event loop.
39
- const { stdout } = await run(process.execPath, [fileURLToPath(import.meta.url), argument], {
52
+ const { stdout } = await run(process.execPath, [fileURLToPath(import.meta.url), argument, ...(version ? [version] : [])], {
40
53
  env: environment, windowsHide: true, encoding: "utf8", maxBuffer: 1_000_000,
41
54
  });
42
55
  return JSON.parse(String(stdout)) as unknown;
@@ -48,9 +61,10 @@ async function main(): Promise<void> {
48
61
  process.stdout.write(JSON.stringify({ status: "supported" }));
49
62
  return;
50
63
  }
51
- const version = stableVersionSchema.parse(process.argv[2]);
64
+ const manual = process.argv[2] === "--manual";
65
+ const version = manual ? publishedVersionSchema.parse(process.argv[3]) : stableVersionSchema.parse(process.argv[2]);
52
66
  const config = loadConfig();
53
- const prepared = await prepareSelfUpdate({ version, channel: "latest" });
67
+ const prepared = await prepareSelfUpdate({ version });
54
68
  if (prepared.status === "prepared") {
55
69
  try {
56
70
  finalizeSelfUpdatePlan(prepared.planPath, {
@@ -58,7 +72,7 @@ async function main(): Promise<void> {
58
72
  databasePath: config.storage.sqlitePath,
59
73
  restartService: true,
60
74
  workingDirectory: process.cwd(),
61
- reason: `Agent Bot 自动更新到 ${version}`,
75
+ reason: `Agent Bot ${manual ? "更新" : "自动更新"}到 ${version}`,
62
76
  });
63
77
  } catch (error) {
64
78
  releaseSelfUpdatePlan(prepared.planPath);