jinzd-ai-cli 0.4.154 → 0.4.156

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 (33) hide show
  1. package/dist/{batch-W57MV5OT.js → batch-DBOCPVH5.js} +2 -2
  2. package/dist/{chat-index-LUQWWLKO.js → chat-index-2I7ZHRE5.js} +2 -1
  3. package/dist/{chunk-SH7NTECG.js → chunk-3DCAQKVZ.js} +1 -1
  4. package/dist/{chunk-HVNEBTSF.js → chunk-3SQMKA4I.js} +1 -1
  5. package/dist/{chunk-UE26B3RO.js → chunk-3WLEDKOW.js} +1 -1
  6. package/dist/{chunk-2IODI5TI.js → chunk-7OCOFVQP.js} +1 -1
  7. package/dist/{chunk-NP7WOVIH.js → chunk-AACNCJMD.js} +1 -1
  8. package/dist/{chunk-ZAYDVWY4.js → chunk-GQ647SF3.js} +23 -787
  9. package/dist/{chunk-RXM76HB7.js → chunk-MM3F43H6.js} +3 -117
  10. package/dist/chunk-NZ4X6GUC.js +230 -0
  11. package/dist/{chunk-O6MLS5QO.js → chunk-OJL3PY36.js} +0 -226
  12. package/dist/{hub-OP7EWTQQ.js → chunk-Q3ZUDA6S.js} +10 -237
  13. package/dist/chunk-RUJQ5OUB.js +51 -0
  14. package/dist/chunk-SLSWPBK3.js +120 -0
  15. package/dist/chunk-TOTEUETI.js +768 -0
  16. package/dist/{chunk-OSTMMSOV.js → chunk-UAPNBQLU.js} +1 -1
  17. package/dist/{chunk-XWYWASPT.js → chunk-Z2UJDFJK.js} +4 -4
  18. package/dist/{ci-JYZGZSMP.js → ci-AWA6KC3X.js} +3 -3
  19. package/dist/{constants-S4Y6A25E.js → constants-QDTWBWWC.js} +1 -1
  20. package/dist/{doctor-cli-FMTMDO2Z.js → doctor-cli-4MTG6SFH.js} +6 -6
  21. package/dist/electron-server.js +740 -44
  22. package/dist/hub-QRDXG527.js +260 -0
  23. package/dist/{hub-server-OH7AYQIW.js → hub-server-GSTG5MNE.js} +4 -2
  24. package/dist/index.js +50 -44
  25. package/dist/persist-UI6WRBGB.js +12 -0
  26. package/dist/{run-tests-3QAZGHP2.js → run-tests-CNURD2ST.js} +2 -2
  27. package/dist/{run-tests-4XNY7QB4.js → run-tests-NPWSCWP5.js} +1 -1
  28. package/dist/{server-W4TBZN6I.js → server-HFG2SM3Y.js} +6 -5
  29. package/dist/{server-UL42EXOA.js → server-RRUCZMMM.js} +124 -29
  30. package/dist/{task-orchestrator-RLAZK5EB.js → task-orchestrator-GF6ZMNUK.js} +6 -5
  31. package/dist/web/client/app.js +138 -0
  32. package/dist/web/client/index.html +28 -0
  33. package/package.json +1 -1
@@ -0,0 +1,768 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ redactJson
4
+ } from "./chunk-SLSWPBK3.js";
5
+
6
+ // src/session/session-manager.ts
7
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, unlinkSync, renameSync, openSync, readSync, closeSync } from "fs";
8
+ import { join } from "path";
9
+ import { v4 as uuidv4 } from "uuid";
10
+
11
+ // src/core/types.ts
12
+ function getContentText(content) {
13
+ if (typeof content === "string") return content;
14
+ return content.filter((p) => p.type === "text").map((p) => p.text).join("");
15
+ }
16
+
17
+ // src/session/session.ts
18
+ function makeBranchId() {
19
+ return Math.random().toString(16).slice(2, 8);
20
+ }
21
+ function messagesEqual(a, b) {
22
+ if (a.role !== b.role) return false;
23
+ if (JSON.stringify(a.content) !== JSON.stringify(b.content)) return false;
24
+ if (JSON.stringify(a.toolCalls ?? null) !== JSON.stringify(b.toolCalls ?? null)) return false;
25
+ return true;
26
+ }
27
+ var Session = class _Session {
28
+ id;
29
+ provider;
30
+ model;
31
+ created;
32
+ updated;
33
+ messages = [];
34
+ title;
35
+ /**
36
+ * 标题是否已由 AI 生成过短摘要(v0.4.126+)。
37
+ * - false / undefined:title 仍是 addMessage 截取的首条用户消息前 50 字(heuristic)
38
+ * - true:已由 maybeGenerateSessionTitle 替换为 AI 摘要,不再二次生成
39
+ *
40
+ * 持久化在 toJSON / fromJSON 中,重启后不会重复请求。
41
+ */
42
+ titleAiGenerated;
43
+ tokenUsage = {
44
+ inputTokens: 0,
45
+ outputTokens: 0,
46
+ cacheCreationTokens: 0,
47
+ cacheReadTokens: 0
48
+ };
49
+ checkpoints = [];
50
+ // ── B2 Branches (v0.4.74+) ──────────────────────────────────────
51
+ /**
52
+ * All branches in this session. The 'main' branch is auto-created and
53
+ * represents the linear conversation for pre-B2 sessions.
54
+ */
55
+ branches = [];
56
+ /** Currently active branch — its messages live in `this.messages`. */
57
+ activeBranchId = "main";
58
+ /**
59
+ * Stashed message arrays for INACTIVE branches. The active branch's
60
+ * messages are always in `this.messages`, never duplicated here.
61
+ */
62
+ _inactiveBranchMessages = /* @__PURE__ */ new Map();
63
+ constructor(id, provider, model) {
64
+ this.id = id;
65
+ this.provider = provider;
66
+ this.model = model;
67
+ this.created = /* @__PURE__ */ new Date();
68
+ this.updated = /* @__PURE__ */ new Date();
69
+ this.branches.push({
70
+ id: "main",
71
+ title: "main",
72
+ parentBranchId: null,
73
+ parentMessageIndex: 0,
74
+ created: this.created
75
+ });
76
+ }
77
+ /**
78
+ * 更新 session 关联的 provider 和 model(在 /provider 或 /model 切换时调用)。
79
+ * 保留对话历史和所有状态,仅更新元数据。
80
+ */
81
+ updateProvider(provider, model) {
82
+ this.provider = provider;
83
+ this.model = model;
84
+ this.updated = /* @__PURE__ */ new Date();
85
+ }
86
+ addMessage(message) {
87
+ this.messages.push(message);
88
+ this.updated = /* @__PURE__ */ new Date();
89
+ if (!this.title && message.role === "user") {
90
+ this.title = getContentText(message.content).slice(0, 50).replace(/\n/g, " ");
91
+ }
92
+ }
93
+ addTokenUsage(usage) {
94
+ this.tokenUsage.inputTokens += usage.inputTokens;
95
+ this.tokenUsage.outputTokens += usage.outputTokens;
96
+ this.tokenUsage.cacheCreationTokens += usage.cacheCreationTokens ?? 0;
97
+ this.tokenUsage.cacheReadTokens += usage.cacheReadTokens ?? 0;
98
+ }
99
+ clear() {
100
+ this.messages = [];
101
+ this.title = void 0;
102
+ this.titleAiGenerated = false;
103
+ this.tokenUsage = {
104
+ inputTokens: 0,
105
+ outputTokens: 0,
106
+ cacheCreationTokens: 0,
107
+ cacheReadTokens: 0
108
+ };
109
+ this.updated = /* @__PURE__ */ new Date();
110
+ }
111
+ /**
112
+ * 上下文压缩:用摘要消息替换旧消息,保留最近 keepLast 条。
113
+ *
114
+ * Tool-history-aware: if the cut point lands inside a tool round
115
+ * (assistant+toolCalls followed by tool results), expand to keep the
116
+ * entire round intact. This prevents orphaned tool results.
117
+ *
118
+ * 压缩后消息结构:
119
+ * [summaryMsg(user), ackMsg(assistant), ...最近 N 条原始消息]
120
+ *
121
+ * @returns 被删除的消息条数
122
+ */
123
+ compact(summaryMsg, ackMsg, keepLast) {
124
+ let cutIndex = this.messages.length - keepLast;
125
+ if (cutIndex <= 0) {
126
+ return 0;
127
+ }
128
+ while (cutIndex > 0 && this.messages[cutIndex]?.role === "tool") {
129
+ cutIndex--;
130
+ }
131
+ const preserved = this.messages.slice(cutIndex);
132
+ const removedCount = cutIndex;
133
+ this.messages = [summaryMsg, ackMsg, ...preserved];
134
+ this.updated = /* @__PURE__ */ new Date();
135
+ return removedCount;
136
+ }
137
+ /** 在当前消息位置创建检查点 */
138
+ createCheckpoint(name) {
139
+ this.checkpoints = this.checkpoints.filter((c) => c.name !== name);
140
+ this.checkpoints.push({
141
+ name,
142
+ messageIndex: this.messages.length,
143
+ timestamp: /* @__PURE__ */ new Date()
144
+ });
145
+ }
146
+ /** 恢复到指定检查点:截断消息到检查点位置,移除后续检查点 */
147
+ restoreCheckpoint(name) {
148
+ const cp = this.checkpoints.find((c) => c.name === name);
149
+ if (!cp) return false;
150
+ this.messages = this.messages.slice(0, cp.messageIndex);
151
+ this.checkpoints = this.checkpoints.filter((c) => c.messageIndex <= cp.messageIndex);
152
+ this.updated = /* @__PURE__ */ new Date();
153
+ return true;
154
+ }
155
+ listCheckpoints() {
156
+ return [...this.checkpoints];
157
+ }
158
+ deleteCheckpoint(name) {
159
+ const len = this.checkpoints.length;
160
+ this.checkpoints = this.checkpoints.filter((c) => c.name !== name);
161
+ return this.checkpoints.length < len;
162
+ }
163
+ // ── B2 Branch operations ────────────────────────────────────────
164
+ /** Deep-clone a messages array (matches `Session.fork` semantics). */
165
+ static cloneMessages(msgs) {
166
+ return msgs.map((m) => {
167
+ const cloned = { ...m };
168
+ if (Array.isArray(cloned.content)) {
169
+ cloned.content = cloned.content.map(
170
+ (part) => typeof part === "object" && part !== null ? { ...part } : part
171
+ );
172
+ }
173
+ if (cloned.toolCalls) {
174
+ cloned.toolCalls = cloned.toolCalls.map((tc) => ({ ...tc }));
175
+ }
176
+ return cloned;
177
+ });
178
+ }
179
+ /** List all branches (metadata only). */
180
+ listBranches() {
181
+ return this.branches.map((b) => ({ ...b }));
182
+ }
183
+ /** Current active branch metadata. */
184
+ getActiveBranch() {
185
+ const b = this.branches.find((b2) => b2.id === this.activeBranchId);
186
+ if (!b) {
187
+ this.activeBranchId = this.branches[0]?.id ?? "main";
188
+ return this.branches[0] ?? { id: "main", title: "main", parentBranchId: null, parentMessageIndex: 0, created: /* @__PURE__ */ new Date() };
189
+ }
190
+ return b;
191
+ }
192
+ /**
193
+ * Create a new branch by forking the active branch at message index
194
+ * `fromIndex`. Copies `messages[0..fromIndex]` into the new branch
195
+ * and switches to it. The original active branch is preserved intact
196
+ * in the stash.
197
+ *
198
+ * @returns new branch id
199
+ * @throws if fromIndex is out of range
200
+ */
201
+ createBranch(fromIndex, title) {
202
+ if (fromIndex < 0 || fromIndex > this.messages.length) {
203
+ throw new Error(
204
+ `createBranch: fromIndex ${fromIndex} out of range [0, ${this.messages.length}]`
205
+ );
206
+ }
207
+ this._inactiveBranchMessages.set(this.activeBranchId, this.messages);
208
+ const id = makeBranchId();
209
+ const meta = {
210
+ id,
211
+ title: title || `branch-${this.branches.length + 1}`,
212
+ parentBranchId: this.activeBranchId,
213
+ parentMessageIndex: fromIndex,
214
+ created: /* @__PURE__ */ new Date()
215
+ };
216
+ this.branches.push(meta);
217
+ this.messages = _Session.cloneMessages(this.messages.slice(0, fromIndex));
218
+ this.activeBranchId = id;
219
+ this.updated = /* @__PURE__ */ new Date();
220
+ return id;
221
+ }
222
+ /**
223
+ * Switch the active branch. Stashes current messages under the old
224
+ * active id and loads the target branch's messages into `this.messages`.
225
+ *
226
+ * @returns true if switched, false if id not found or already active
227
+ */
228
+ switchBranch(id) {
229
+ if (id === this.activeBranchId) return false;
230
+ if (!this.branches.some((b) => b.id === id)) return false;
231
+ this._inactiveBranchMessages.set(this.activeBranchId, this.messages);
232
+ const target = this._inactiveBranchMessages.get(id) ?? [];
233
+ this._inactiveBranchMessages.delete(id);
234
+ this.messages = target;
235
+ this.activeBranchId = id;
236
+ this.updated = /* @__PURE__ */ new Date();
237
+ return true;
238
+ }
239
+ /**
240
+ * Delete a branch by id. Cannot delete the active branch or the last
241
+ * remaining branch. If other branches list this one as parent, their
242
+ * parent pointer is retargeted to this branch's parent (transparent
243
+ * to callers — branches still form a valid forest).
244
+ *
245
+ * @returns true if deleted
246
+ */
247
+ deleteBranch(id) {
248
+ if (id === this.activeBranchId) return false;
249
+ if (this.branches.length <= 1) return false;
250
+ const idx = this.branches.findIndex((b) => b.id === id);
251
+ if (idx === -1) return false;
252
+ const deleted = this.branches[idx];
253
+ for (const b of this.branches) {
254
+ if (b.parentBranchId === id) {
255
+ b.parentBranchId = deleted.parentBranchId;
256
+ }
257
+ }
258
+ this.branches.splice(idx, 1);
259
+ this._inactiveBranchMessages.delete(id);
260
+ this.updated = /* @__PURE__ */ new Date();
261
+ return true;
262
+ }
263
+ /** Rename a branch (affects only display title). */
264
+ renameBranch(id, newTitle) {
265
+ const b = this.branches.find((b2) => b2.id === id);
266
+ if (!b) return false;
267
+ b.title = newTitle;
268
+ this.updated = /* @__PURE__ */ new Date();
269
+ return true;
270
+ }
271
+ /**
272
+ * Resolve a user-supplied branch reference (v0.4.81+).
273
+ *
274
+ * Accepts the 6-hex branch id, the branch's display title, or an unambiguous
275
+ * id prefix (≥2 chars). This exists because users naturally type the title
276
+ * they chose at `/branch new`, not the auto-generated id — every other
277
+ * `/branch <sub>` used to error on that with "not found".
278
+ *
279
+ * Resolution order:
280
+ * 1. Exact id match
281
+ * 2. Exact title match (unique)
282
+ * 3. Id prefix match (unique, length ≥ 2)
283
+ *
284
+ * @returns `{ ok: true, id }` on success; on failure `{ ok: false, reason, matches }`
285
+ * where `matches` lists candidates for ambiguous input so callers
286
+ * can render a helpful error.
287
+ */
288
+ resolveBranchRef(ref) {
289
+ if (!ref) return { ok: false, reason: "not-found", matches: [] };
290
+ const byId = this.branches.find((b) => b.id === ref);
291
+ if (byId) return { ok: true, id: byId.id };
292
+ const byTitle = this.branches.filter((b) => b.title === ref);
293
+ if (byTitle.length === 1) return { ok: true, id: byTitle[0].id };
294
+ if (byTitle.length > 1) return { ok: false, reason: "ambiguous", matches: byTitle };
295
+ if (ref.length >= 2) {
296
+ const byPrefix = this.branches.filter((b) => b.id.startsWith(ref));
297
+ if (byPrefix.length === 1) return { ok: true, id: byPrefix[0].id };
298
+ if (byPrefix.length > 1) return { ok: false, reason: "ambiguous", matches: byPrefix };
299
+ }
300
+ return { ok: false, reason: "not-found", matches: [] };
301
+ }
302
+ /** Messages of any branch (active or inactive) — read-only copy. */
303
+ getBranchMessages(id) {
304
+ if (id === this.activeBranchId) return this.messages.slice();
305
+ const m = this._inactiveBranchMessages.get(id);
306
+ return m ? m.slice() : null;
307
+ }
308
+ // ── B3 Branch diff + cherry-pick (v0.4.80+) ────────────────────────
309
+ /**
310
+ * Compare messages between two branches. Finds the longest common prefix
311
+ * by message equality (role + content + toolCalls shape) — NOT by fork
312
+ * point metadata, so user-edited histories still diff cleanly.
313
+ *
314
+ * @param sourceId Branch to compare
315
+ * @param targetId Branch to compare against (defaults to active branch)
316
+ * @returns BranchDiff, or null if either branch id is unknown
317
+ */
318
+ diffBranches(sourceId, targetId) {
319
+ const tgt = targetId ?? this.activeBranchId;
320
+ const src = this.getBranchMessages(sourceId);
321
+ const dst = this.getBranchMessages(tgt);
322
+ if (!src || !dst) return null;
323
+ let i = 0;
324
+ while (i < src.length && i < dst.length && messagesEqual(src[i], dst[i])) i++;
325
+ return {
326
+ sourceId,
327
+ targetId: tgt,
328
+ commonPrefix: i,
329
+ sourceOnly: src.slice(i),
330
+ targetOnly: dst.slice(i)
331
+ };
332
+ }
333
+ /**
334
+ * Copy a single message from another branch into the active branch
335
+ * (appended at the end). Timestamp is reset to now so it's clear when
336
+ * the cherry-pick happened.
337
+ *
338
+ * @returns the cloned message, or null if sourceId / msgIndex is invalid
339
+ */
340
+ cherryPickMessage(sourceId, msgIndex) {
341
+ const src = this.getBranchMessages(sourceId);
342
+ if (!src) return null;
343
+ if (msgIndex < 0 || msgIndex >= src.length) return null;
344
+ const orig = src[msgIndex];
345
+ const copy = {
346
+ ...orig,
347
+ timestamp: /* @__PURE__ */ new Date()
348
+ };
349
+ this.messages.push(copy);
350
+ this.updated = /* @__PURE__ */ new Date();
351
+ return copy;
352
+ }
353
+ getMeta() {
354
+ return {
355
+ id: this.id,
356
+ provider: this.provider,
357
+ model: this.model,
358
+ messageCount: this.messages.length,
359
+ created: this.created,
360
+ updated: this.updated,
361
+ title: this.title
362
+ };
363
+ }
364
+ toJSON() {
365
+ const serializeMessages = (msgs) => msgs.map((m) => {
366
+ const out = {
367
+ role: m.role,
368
+ content: m.content,
369
+ timestamp: m.timestamp.toISOString()
370
+ };
371
+ if (m.toolCalls) out.toolCalls = m.toolCalls;
372
+ if (m.reasoningContent !== void 0) out.reasoningContent = m.reasoningContent;
373
+ if (m.toolCallId) out.toolCallId = m.toolCallId;
374
+ if (m.toolName) out.toolName = m.toolName;
375
+ if (m.isError !== void 0) out.isError = m.isError;
376
+ return out;
377
+ });
378
+ const branchMessages = {};
379
+ for (const [id, msgs] of this._inactiveBranchMessages.entries()) {
380
+ branchMessages[id] = serializeMessages(msgs);
381
+ }
382
+ return {
383
+ id: this.id,
384
+ provider: this.provider,
385
+ model: this.model,
386
+ created: this.created.toISOString(),
387
+ updated: this.updated.toISOString(),
388
+ title: this.title,
389
+ titleAiGenerated: this.titleAiGenerated ?? false,
390
+ tokenUsage: { ...this.tokenUsage },
391
+ checkpoints: this.checkpoints.map((c) => ({
392
+ name: c.name,
393
+ messageIndex: c.messageIndex,
394
+ timestamp: c.timestamp.toISOString()
395
+ })),
396
+ // B2 Branches (v0.4.74+). Omitted for sessions with only the default
397
+ // 'main' branch and no stashed messages (keeps file size identical
398
+ // to pre-B2 for the common case).
399
+ ...this.branches.length > 1 || this._inactiveBranchMessages.size > 0 ? {
400
+ activeBranchId: this.activeBranchId,
401
+ branches: this.branches.map((b) => ({
402
+ id: b.id,
403
+ title: b.title,
404
+ parentBranchId: b.parentBranchId,
405
+ parentMessageIndex: b.parentMessageIndex,
406
+ created: b.created.toISOString()
407
+ })),
408
+ branchMessages
409
+ } : {},
410
+ messages: serializeMessages(this.messages)
411
+ };
412
+ }
413
+ /**
414
+ * 从现有 session 分叉创建新 session。
415
+ *
416
+ * 复制 messages[0..messageCount],保留范围内的 checkpoints。
417
+ * 新 session 拥有独立 UUID,title 默认 "Fork of <原标题>"。
418
+ *
419
+ * @param original 原始 session
420
+ * @param newId 新 session 的 UUID
421
+ * @param messageCount 复制的消息数量(0 = 空 session,> messages.length 则取全部)
422
+ * @param newTitle 可选的新标题
423
+ */
424
+ static fork(original, newId, messageCount, newTitle) {
425
+ const forked = new _Session(newId, original.provider, original.model);
426
+ forked.title = newTitle ?? (original.title ? `Fork of ${original.title}` : void 0);
427
+ const clampedCount = Math.min(Math.max(messageCount, 0), original.messages.length);
428
+ forked.messages = original.messages.slice(0, clampedCount).map((m) => {
429
+ const cloned = { ...m };
430
+ if (Array.isArray(cloned.content)) {
431
+ cloned.content = cloned.content.map(
432
+ (part) => typeof part === "object" && part !== null ? { ...part } : part
433
+ );
434
+ }
435
+ return cloned;
436
+ });
437
+ forked.checkpoints = original.checkpoints.filter((c) => c.messageIndex <= clampedCount).map((c) => ({ ...c, timestamp: new Date(c.timestamp.getTime()) }));
438
+ forked.updated = /* @__PURE__ */ new Date();
439
+ return forked;
440
+ }
441
+ /**
442
+ * 从磁盘 JSON 数据恢复 Session 实例。
443
+ * 添加运行时校验:损坏或不兼容的历史文件会抛出明确错误,而非 TypeError 崩溃。
444
+ */
445
+ static fromJSON(data) {
446
+ const d = data;
447
+ if (!d || typeof d !== "object") {
448
+ throw new Error("Invalid session data: expected an object");
449
+ }
450
+ if (typeof d.id !== "string" || typeof d.provider !== "string" || typeof d.model !== "string") {
451
+ throw new Error("Invalid session data: missing or invalid id/provider/model fields");
452
+ }
453
+ if (!Array.isArray(d.messages)) {
454
+ throw new Error("Invalid session data: messages is not an array");
455
+ }
456
+ const session = new _Session(d.id, d.provider, d.model);
457
+ session.title = typeof d.title === "string" ? d.title : void 0;
458
+ session.titleAiGenerated = d.titleAiGenerated === true;
459
+ const created = new Date(d.created);
460
+ const updated = new Date(d.updated);
461
+ session.created = isNaN(created.getTime()) ? /* @__PURE__ */ new Date() : created;
462
+ session.updated = isNaN(updated.getTime()) ? /* @__PURE__ */ new Date() : updated;
463
+ const tu = d.tokenUsage;
464
+ if (tu && typeof tu === "object") {
465
+ session.tokenUsage = {
466
+ inputTokens: typeof tu.inputTokens === "number" ? tu.inputTokens : 0,
467
+ outputTokens: typeof tu.outputTokens === "number" ? tu.outputTokens : 0,
468
+ cacheCreationTokens: typeof tu.cacheCreationTokens === "number" ? tu.cacheCreationTokens : 0,
469
+ cacheReadTokens: typeof tu.cacheReadTokens === "number" ? tu.cacheReadTokens : 0
470
+ };
471
+ }
472
+ if (Array.isArray(d.checkpoints)) {
473
+ session.checkpoints = d.checkpoints.map((c) => ({
474
+ name: String(c.name ?? ""),
475
+ messageIndex: typeof c.messageIndex === "number" ? c.messageIndex : 0,
476
+ timestamp: new Date(c.timestamp)
477
+ }));
478
+ }
479
+ const deserializeMessages = (arr) => arr.map((m) => {
480
+ const ts = new Date(m.timestamp);
481
+ const msg = {
482
+ role: m.role ?? "user",
483
+ content: Array.isArray(m.content) ? m.content : String(m.content ?? ""),
484
+ timestamp: isNaN(ts.getTime()) ? /* @__PURE__ */ new Date() : ts
485
+ };
486
+ if (Array.isArray(m.toolCalls)) msg.toolCalls = m.toolCalls;
487
+ if (typeof m.reasoningContent === "string") msg.reasoningContent = m.reasoningContent;
488
+ if (typeof m.toolCallId === "string") msg.toolCallId = m.toolCallId;
489
+ if (typeof m.toolName === "string") msg.toolName = m.toolName;
490
+ if (typeof m.isError === "boolean") msg.isError = m.isError;
491
+ return msg;
492
+ });
493
+ session.messages = deserializeMessages(d.messages);
494
+ if (Array.isArray(d.branches) && d.branches.length > 0) {
495
+ session.branches = d.branches.map((b) => {
496
+ const ts = new Date(b.created);
497
+ return {
498
+ id: String(b.id ?? "main"),
499
+ title: String(b.title ?? b.id ?? "main"),
500
+ parentBranchId: typeof b.parentBranchId === "string" ? b.parentBranchId : null,
501
+ parentMessageIndex: typeof b.parentMessageIndex === "number" ? b.parentMessageIndex : 0,
502
+ created: isNaN(ts.getTime()) ? /* @__PURE__ */ new Date() : ts
503
+ };
504
+ });
505
+ session.activeBranchId = typeof d.activeBranchId === "string" ? d.activeBranchId : session.branches[0]?.id ?? "main";
506
+ const bm = d.branchMessages;
507
+ if (bm && typeof bm === "object") {
508
+ for (const [id, arr] of Object.entries(bm)) {
509
+ if (id === session.activeBranchId) continue;
510
+ if (!Array.isArray(arr)) continue;
511
+ session._inactiveBranchMessages.set(
512
+ id,
513
+ deserializeMessages(arr)
514
+ );
515
+ }
516
+ }
517
+ }
518
+ return session;
519
+ }
520
+ };
521
+
522
+ // src/session/session-manager.ts
523
+ function safeDate(value) {
524
+ const d = new Date(value);
525
+ return isNaN(d.getTime()) ? /* @__PURE__ */ new Date(0) : d;
526
+ }
527
+ function extractJsonField(header, field) {
528
+ const re = new RegExp(`"${field}"\\s*:\\s*"([^"]*)"`, "i");
529
+ const m = header.match(re);
530
+ return m ? m[1] : void 0;
531
+ }
532
+ var SessionManager = class {
533
+ _current = null;
534
+ historyDir;
535
+ config;
536
+ /** Last save's redaction hit count — exposed for /security status reporting */
537
+ lastRedactionHits = 0;
538
+ constructor(config) {
539
+ this.config = config;
540
+ this.historyDir = config.getHistoryDir();
541
+ }
542
+ /**
543
+ * Build redaction options from config. Returns `{ enabled: false }` when
544
+ * `security.redactOnSave` is off or `security.mode` is 'off'.
545
+ */
546
+ redactOptionsForSave() {
547
+ const security = this.config.get("security");
548
+ if (!security || !security.redactOnSave || security.mode === "off") {
549
+ return { enabled: false };
550
+ }
551
+ return {
552
+ enabled: true,
553
+ customRegexes: security.customPatterns ?? []
554
+ };
555
+ }
556
+ get current() {
557
+ return this._current;
558
+ }
559
+ createSession(provider, model) {
560
+ const session = new Session(uuidv4(), provider, model);
561
+ this._current = session;
562
+ return session;
563
+ }
564
+ /**
565
+ * 直接设置当前会话(用于从内存缓存恢复未保存的会话)。
566
+ * 与 `loadSession` 不同,此方法不读取磁盘,也不抛出错误。
567
+ * Web 多 Tab 场景下,SessionHandler 会维护一份未保存会话的内存缓存,
568
+ * 切换 Tab 时通过此方法将缓存中的会话设为当前会话,避免"Session not found"。
569
+ */
570
+ setCurrent(session) {
571
+ this._current = session;
572
+ }
573
+ /** 清除当前会话引用(下次访问将触发 lazy 创建)。 */
574
+ clearCurrent() {
575
+ this._current = null;
576
+ }
577
+ async save() {
578
+ if (!this._current) return;
579
+ mkdirSync(this.historyDir, { recursive: true });
580
+ const filePath = join(this.historyDir, `${this._current.id}.json`);
581
+ const raw = this._current.toJSON();
582
+ const opts = this.redactOptionsForSave();
583
+ const { value: payload, hits } = redactJson(raw, opts);
584
+ this.lastRedactionHits = hits.length;
585
+ const tmpPath = filePath + ".tmp";
586
+ writeFileSync(tmpPath, JSON.stringify(payload, null, 2), "utf-8");
587
+ renameSync(tmpPath, filePath);
588
+ }
589
+ loadSession(id) {
590
+ const filePath = join(this.historyDir, `${id}.json`);
591
+ if (!existsSync(filePath)) {
592
+ throw new Error(`Session ${id} not found`);
593
+ }
594
+ let data;
595
+ try {
596
+ data = JSON.parse(readFileSync(filePath, "utf-8"));
597
+ } catch (err) {
598
+ throw new Error(`Session ${id} is corrupted: ${err instanceof Error ? err.message : String(err)}`);
599
+ }
600
+ const session = Session.fromJSON(data);
601
+ this._current = session;
602
+ return session;
603
+ }
604
+ listSessions() {
605
+ if (!existsSync(this.historyDir)) return [];
606
+ const files = readdirSync(this.historyDir).filter((f) => f.endsWith(".json"));
607
+ const metas = [];
608
+ for (const file of files) {
609
+ try {
610
+ const meta = this.readSessionMeta(join(this.historyDir, file));
611
+ if (meta) metas.push(meta);
612
+ } catch (err) {
613
+ process.stderr.write(
614
+ `[Warning] Skipping corrupted session file "${file}": ${err instanceof Error ? err.message : String(err)}
615
+ `
616
+ );
617
+ }
618
+ }
619
+ return metas.sort((a, b) => b.updated.getTime() - a.updated.getTime());
620
+ }
621
+ /**
622
+ * P1-B: Read only the first ~1KB of a session file to extract metadata fields.
623
+ * Session JSON format puts id/provider/model/created/updated/title before the
624
+ * large "messages" array, so a small header read suffices for metadata extraction.
625
+ * Falls back to full file read if header parsing fails.
626
+ */
627
+ readSessionMeta(filePath) {
628
+ const HEADER_SIZE = 1024;
629
+ let header;
630
+ try {
631
+ const fd = openSync(filePath, "r");
632
+ const buf = Buffer.alloc(HEADER_SIZE);
633
+ const bytesRead = readSync(fd, buf, 0, HEADER_SIZE, 0);
634
+ closeSync(fd);
635
+ header = buf.toString("utf-8", 0, bytesRead);
636
+ } catch {
637
+ return null;
638
+ }
639
+ const id = extractJsonField(header, "id");
640
+ const provider = extractJsonField(header, "provider");
641
+ const model = extractJsonField(header, "model");
642
+ const created = extractJsonField(header, "created");
643
+ const updated = extractJsonField(header, "updated");
644
+ const title = extractJsonField(header, "title");
645
+ if (id && provider && model) {
646
+ let messageCount = 0;
647
+ try {
648
+ const full = readFileSync(filePath, "utf-8");
649
+ const matches = full.match(/"role"\s*:/g);
650
+ messageCount = matches ? matches.length : 0;
651
+ } catch {
652
+ }
653
+ return {
654
+ id,
655
+ provider,
656
+ model,
657
+ messageCount,
658
+ created: safeDate(created),
659
+ updated: safeDate(updated),
660
+ title: title || void 0
661
+ };
662
+ }
663
+ const data = JSON.parse(readFileSync(filePath, "utf-8"));
664
+ return {
665
+ id: data.id,
666
+ provider: data.provider,
667
+ model: data.model,
668
+ messageCount: data.messages?.length ?? 0,
669
+ created: safeDate(data.created),
670
+ updated: safeDate(data.updated),
671
+ title: data.title
672
+ };
673
+ }
674
+ deleteSession(id) {
675
+ const filePath = join(this.historyDir, `${id}.json`);
676
+ if (!existsSync(filePath)) return false;
677
+ try {
678
+ unlinkSync(filePath);
679
+ if (this._current && this._current.id === id) {
680
+ this._current = null;
681
+ }
682
+ return true;
683
+ } catch {
684
+ return false;
685
+ }
686
+ }
687
+ /**
688
+ * 从当前 session 分叉创建新 session。
689
+ *
690
+ * 先保存原始 session(保留完整历史),然后创建分叉(截取到 messageCount),
691
+ * 将分叉设为当前 session 并保存。
692
+ *
693
+ * @param messageCount 复制的消息数量
694
+ * @param title 可选的新标题
695
+ * @returns 新的分叉 session
696
+ */
697
+ async forkSession(messageCount, title) {
698
+ if (!this._current) {
699
+ throw new Error("No active session to fork");
700
+ }
701
+ await this.save();
702
+ const forked = Session.fork(this._current, uuidv4(), messageCount, title);
703
+ this._current = forked;
704
+ await this.save();
705
+ return forked;
706
+ }
707
+ /**
708
+ * 跨 session 全文搜索。
709
+ * 遍历所有历史 JSON 文件,逐条匹配消息内容(不区分大小写),
710
+ * 每个 session 最多返回 3 条匹配片段,全局最多 maxResults 个 session。
711
+ */
712
+ searchMessages(query, maxResults = 20) {
713
+ if (!existsSync(this.historyDir)) return [];
714
+ const q = query.toLowerCase();
715
+ const files = readdirSync(this.historyDir).filter((f) => f.endsWith(".json")).map((f) => join(this.historyDir, f));
716
+ const results = [];
717
+ for (const filePath of files) {
718
+ if (results.length >= maxResults) break;
719
+ try {
720
+ const data = JSON.parse(readFileSync(filePath, "utf-8"));
721
+ const messages = data.messages ?? [];
722
+ const matches = [];
723
+ for (const msg of messages) {
724
+ if (matches.length >= 3) break;
725
+ let text = "";
726
+ if (typeof msg.content === "string") {
727
+ text = msg.content;
728
+ } else if (Array.isArray(msg.content)) {
729
+ text = msg.content.filter((p) => p.type === "text").map((p) => p.text ?? "").join("");
730
+ }
731
+ const lowerText = text.toLowerCase();
732
+ const idx = lowerText.indexOf(q);
733
+ if (idx !== -1) {
734
+ const start = Math.max(0, idx - 30);
735
+ const end = Math.min(text.length, idx + query.length + 60);
736
+ const snippet = (start > 0 ? "\u2026" : "") + text.slice(start, end).replace(/\n/g, " ") + (end < text.length ? "\u2026" : "");
737
+ matches.push({ role: msg.role, snippet });
738
+ }
739
+ }
740
+ if (matches.length > 0) {
741
+ results.push({
742
+ sessionMeta: {
743
+ id: data.id,
744
+ provider: data.provider,
745
+ model: data.model,
746
+ messageCount: messages.length,
747
+ created: safeDate(data.created),
748
+ updated: safeDate(data.updated),
749
+ title: data.title
750
+ },
751
+ matches
752
+ });
753
+ }
754
+ } catch (err) {
755
+ process.stderr.write(
756
+ `[Warning] Skipping corrupted session file "${filePath}": ${err instanceof Error ? err.message : String(err)}
757
+ `
758
+ );
759
+ }
760
+ }
761
+ return results.sort((a, b) => b.sessionMeta.updated.getTime() - a.sessionMeta.updated.getTime());
762
+ }
763
+ };
764
+
765
+ export {
766
+ getContentText,
767
+ SessionManager
768
+ };