jinzd-ai-cli 0.4.153 → 0.4.155
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/{batch-DBRN4MCC.js → batch-LS3IJVBK.js} +2 -2
- package/dist/{chat-index-LUQWWLKO.js → chat-index-IF4EINLQ.js} +2 -1
- package/dist/{chunk-D62BVFP7.js → chunk-B3LFGPU2.js} +1 -1
- package/dist/{chunk-EIIMBVXN.js → chunk-CIZQZ7CC.js} +23 -787
- package/dist/{chunk-TURORFH2.js → chunk-D6GJTJQH.js} +1 -1
- package/dist/{chunk-OP3I24WL.js → chunk-E5ICQT3P.js} +4 -4
- package/dist/{chunk-UWW3EWER.js → chunk-IBBYW6PM.js} +1 -1
- package/dist/{chunk-OT2HLGSO.js → chunk-JOJRBV2K.js} +1 -1
- package/dist/{chunk-LJPB4ZER.js → chunk-JXSWY54M.js} +1 -1
- package/dist/{chunk-EYJQJZJ6.js → chunk-NFRTSL3N.js} +1 -1
- package/dist/{chunk-M4GJOBWN.js → chunk-O6MLS5QO.js} +63 -23
- package/dist/chunk-SLSWPBK3.js +120 -0
- package/dist/chunk-TOTEUETI.js +768 -0
- package/dist/{chunk-RXM76HB7.js → chunk-U5MY24UZ.js} +3 -117
- package/dist/{ci-UEEUSELV.js → ci-34ZQH43L.js} +2 -2
- package/dist/{constants-43EVHE2E.js → constants-DQ5VJOGS.js} +1 -1
- package/dist/{doctor-cli-ZT674MCQ.js → doctor-cli-TSCI4ORL.js} +4 -4
- package/dist/electron-server.js +2 -2
- package/dist/{hub-MDQNJOMV.js → hub-ZILVZWI2.js} +81 -7
- package/dist/{hub-server-VPXCBWLA.js → hub-server-OH7AYQIW.js} +1 -1
- package/dist/index.js +32 -22
- package/dist/persist-3EBOLHFZ.js +52 -0
- package/dist/{run-tests-DCT5LWBB.js → run-tests-5CJRMOMI.js} +1 -1
- package/dist/{run-tests-EYZ2JZ4X.js → run-tests-5KWCHBQS.js} +2 -2
- package/dist/{server-OIYBFKS2.js → server-35OQV62B.js} +16 -13
- package/dist/{server-MQWFO2GJ.js → server-DVIP7NLW.js} +6 -5
- package/dist/{task-orchestrator-BGQBNKAI.js → task-orchestrator-AXSS7ROD.js} +6 -5
- 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
|
+
};
|