jinzd-ai-cli 0.4.117 → 0.4.119

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.
@@ -0,0 +1,1728 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ truncateForPersist
4
+ } from "./chunk-VYTV2VP7.js";
5
+ import {
6
+ APP_NAME,
7
+ CONFIG_DIR_NAME,
8
+ DEV_STATE_FILE_NAME,
9
+ MCP_CALL_TIMEOUT,
10
+ MCP_CONNECT_TIMEOUT,
11
+ MCP_PROTOCOL_VERSION,
12
+ MCP_TOOL_PREFIX,
13
+ VERSION
14
+ } from "./chunk-BS7EUZSO.js";
15
+ import {
16
+ redactJson
17
+ } from "./chunk-7ZJN4KLV.js";
18
+
19
+ // src/session/session-manager.ts
20
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, unlinkSync, renameSync, openSync, readSync, closeSync } from "fs";
21
+ import { join } from "path";
22
+ import { v4 as uuidv4 } from "uuid";
23
+
24
+ // src/core/types.ts
25
+ function getContentText(content) {
26
+ if (typeof content === "string") return content;
27
+ return content.filter((p) => p.type === "text").map((p) => p.text).join("");
28
+ }
29
+
30
+ // src/session/session.ts
31
+ function makeBranchId() {
32
+ return Math.random().toString(16).slice(2, 8);
33
+ }
34
+ function messagesEqual(a, b) {
35
+ if (a.role !== b.role) return false;
36
+ if (JSON.stringify(a.content) !== JSON.stringify(b.content)) return false;
37
+ if (JSON.stringify(a.toolCalls ?? null) !== JSON.stringify(b.toolCalls ?? null)) return false;
38
+ return true;
39
+ }
40
+ var Session = class _Session {
41
+ id;
42
+ provider;
43
+ model;
44
+ created;
45
+ updated;
46
+ messages = [];
47
+ title;
48
+ tokenUsage = {
49
+ inputTokens: 0,
50
+ outputTokens: 0,
51
+ cacheCreationTokens: 0,
52
+ cacheReadTokens: 0
53
+ };
54
+ checkpoints = [];
55
+ // ── B2 Branches (v0.4.74+) ──────────────────────────────────────
56
+ /**
57
+ * All branches in this session. The 'main' branch is auto-created and
58
+ * represents the linear conversation for pre-B2 sessions.
59
+ */
60
+ branches = [];
61
+ /** Currently active branch — its messages live in `this.messages`. */
62
+ activeBranchId = "main";
63
+ /**
64
+ * Stashed message arrays for INACTIVE branches. The active branch's
65
+ * messages are always in `this.messages`, never duplicated here.
66
+ */
67
+ _inactiveBranchMessages = /* @__PURE__ */ new Map();
68
+ constructor(id, provider, model) {
69
+ this.id = id;
70
+ this.provider = provider;
71
+ this.model = model;
72
+ this.created = /* @__PURE__ */ new Date();
73
+ this.updated = /* @__PURE__ */ new Date();
74
+ this.branches.push({
75
+ id: "main",
76
+ title: "main",
77
+ parentBranchId: null,
78
+ parentMessageIndex: 0,
79
+ created: this.created
80
+ });
81
+ }
82
+ /**
83
+ * 更新 session 关联的 provider 和 model(在 /provider 或 /model 切换时调用)。
84
+ * 保留对话历史和所有状态,仅更新元数据。
85
+ */
86
+ updateProvider(provider, model) {
87
+ this.provider = provider;
88
+ this.model = model;
89
+ this.updated = /* @__PURE__ */ new Date();
90
+ }
91
+ addMessage(message) {
92
+ this.messages.push(message);
93
+ this.updated = /* @__PURE__ */ new Date();
94
+ if (!this.title && message.role === "user") {
95
+ this.title = getContentText(message.content).slice(0, 50).replace(/\n/g, " ");
96
+ }
97
+ }
98
+ addTokenUsage(usage) {
99
+ this.tokenUsage.inputTokens += usage.inputTokens;
100
+ this.tokenUsage.outputTokens += usage.outputTokens;
101
+ this.tokenUsage.cacheCreationTokens += usage.cacheCreationTokens ?? 0;
102
+ this.tokenUsage.cacheReadTokens += usage.cacheReadTokens ?? 0;
103
+ }
104
+ clear() {
105
+ this.messages = [];
106
+ this.title = void 0;
107
+ this.tokenUsage = {
108
+ inputTokens: 0,
109
+ outputTokens: 0,
110
+ cacheCreationTokens: 0,
111
+ cacheReadTokens: 0
112
+ };
113
+ this.updated = /* @__PURE__ */ new Date();
114
+ }
115
+ /**
116
+ * 上下文压缩:用摘要消息替换旧消息,保留最近 keepLast 条。
117
+ *
118
+ * Tool-history-aware: if the cut point lands inside a tool round
119
+ * (assistant+toolCalls followed by tool results), expand to keep the
120
+ * entire round intact. This prevents orphaned tool results.
121
+ *
122
+ * 压缩后消息结构:
123
+ * [summaryMsg(user), ackMsg(assistant), ...最近 N 条原始消息]
124
+ *
125
+ * @returns 被删除的消息条数
126
+ */
127
+ compact(summaryMsg, ackMsg, keepLast) {
128
+ let cutIndex = this.messages.length - keepLast;
129
+ if (cutIndex <= 0) {
130
+ return 0;
131
+ }
132
+ while (cutIndex > 0 && this.messages[cutIndex]?.role === "tool") {
133
+ cutIndex--;
134
+ }
135
+ const preserved = this.messages.slice(cutIndex);
136
+ const removedCount = cutIndex;
137
+ this.messages = [summaryMsg, ackMsg, ...preserved];
138
+ this.updated = /* @__PURE__ */ new Date();
139
+ return removedCount;
140
+ }
141
+ /** 在当前消息位置创建检查点 */
142
+ createCheckpoint(name) {
143
+ this.checkpoints = this.checkpoints.filter((c) => c.name !== name);
144
+ this.checkpoints.push({
145
+ name,
146
+ messageIndex: this.messages.length,
147
+ timestamp: /* @__PURE__ */ new Date()
148
+ });
149
+ }
150
+ /** 恢复到指定检查点:截断消息到检查点位置,移除后续检查点 */
151
+ restoreCheckpoint(name) {
152
+ const cp = this.checkpoints.find((c) => c.name === name);
153
+ if (!cp) return false;
154
+ this.messages = this.messages.slice(0, cp.messageIndex);
155
+ this.checkpoints = this.checkpoints.filter((c) => c.messageIndex <= cp.messageIndex);
156
+ this.updated = /* @__PURE__ */ new Date();
157
+ return true;
158
+ }
159
+ listCheckpoints() {
160
+ return [...this.checkpoints];
161
+ }
162
+ deleteCheckpoint(name) {
163
+ const len = this.checkpoints.length;
164
+ this.checkpoints = this.checkpoints.filter((c) => c.name !== name);
165
+ return this.checkpoints.length < len;
166
+ }
167
+ // ── B2 Branch operations ────────────────────────────────────────
168
+ /** Deep-clone a messages array (matches `Session.fork` semantics). */
169
+ static cloneMessages(msgs) {
170
+ return msgs.map((m) => {
171
+ const cloned = { ...m };
172
+ if (Array.isArray(cloned.content)) {
173
+ cloned.content = cloned.content.map(
174
+ (part) => typeof part === "object" && part !== null ? { ...part } : part
175
+ );
176
+ }
177
+ if (cloned.toolCalls) {
178
+ cloned.toolCalls = cloned.toolCalls.map((tc) => ({ ...tc }));
179
+ }
180
+ return cloned;
181
+ });
182
+ }
183
+ /** List all branches (metadata only). */
184
+ listBranches() {
185
+ return this.branches.map((b) => ({ ...b }));
186
+ }
187
+ /** Current active branch metadata. */
188
+ getActiveBranch() {
189
+ const b = this.branches.find((b2) => b2.id === this.activeBranchId);
190
+ if (!b) {
191
+ this.activeBranchId = this.branches[0]?.id ?? "main";
192
+ return this.branches[0] ?? { id: "main", title: "main", parentBranchId: null, parentMessageIndex: 0, created: /* @__PURE__ */ new Date() };
193
+ }
194
+ return b;
195
+ }
196
+ /**
197
+ * Create a new branch by forking the active branch at message index
198
+ * `fromIndex`. Copies `messages[0..fromIndex]` into the new branch
199
+ * and switches to it. The original active branch is preserved intact
200
+ * in the stash.
201
+ *
202
+ * @returns new branch id
203
+ * @throws if fromIndex is out of range
204
+ */
205
+ createBranch(fromIndex, title) {
206
+ if (fromIndex < 0 || fromIndex > this.messages.length) {
207
+ throw new Error(
208
+ `createBranch: fromIndex ${fromIndex} out of range [0, ${this.messages.length}]`
209
+ );
210
+ }
211
+ this._inactiveBranchMessages.set(this.activeBranchId, this.messages);
212
+ const id = makeBranchId();
213
+ const meta = {
214
+ id,
215
+ title: title || `branch-${this.branches.length + 1}`,
216
+ parentBranchId: this.activeBranchId,
217
+ parentMessageIndex: fromIndex,
218
+ created: /* @__PURE__ */ new Date()
219
+ };
220
+ this.branches.push(meta);
221
+ this.messages = _Session.cloneMessages(this.messages.slice(0, fromIndex));
222
+ this.activeBranchId = id;
223
+ this.updated = /* @__PURE__ */ new Date();
224
+ return id;
225
+ }
226
+ /**
227
+ * Switch the active branch. Stashes current messages under the old
228
+ * active id and loads the target branch's messages into `this.messages`.
229
+ *
230
+ * @returns true if switched, false if id not found or already active
231
+ */
232
+ switchBranch(id) {
233
+ if (id === this.activeBranchId) return false;
234
+ if (!this.branches.some((b) => b.id === id)) return false;
235
+ this._inactiveBranchMessages.set(this.activeBranchId, this.messages);
236
+ const target = this._inactiveBranchMessages.get(id) ?? [];
237
+ this._inactiveBranchMessages.delete(id);
238
+ this.messages = target;
239
+ this.activeBranchId = id;
240
+ this.updated = /* @__PURE__ */ new Date();
241
+ return true;
242
+ }
243
+ /**
244
+ * Delete a branch by id. Cannot delete the active branch or the last
245
+ * remaining branch. If other branches list this one as parent, their
246
+ * parent pointer is retargeted to this branch's parent (transparent
247
+ * to callers — branches still form a valid forest).
248
+ *
249
+ * @returns true if deleted
250
+ */
251
+ deleteBranch(id) {
252
+ if (id === this.activeBranchId) return false;
253
+ if (this.branches.length <= 1) return false;
254
+ const idx = this.branches.findIndex((b) => b.id === id);
255
+ if (idx === -1) return false;
256
+ const deleted = this.branches[idx];
257
+ for (const b of this.branches) {
258
+ if (b.parentBranchId === id) {
259
+ b.parentBranchId = deleted.parentBranchId;
260
+ }
261
+ }
262
+ this.branches.splice(idx, 1);
263
+ this._inactiveBranchMessages.delete(id);
264
+ this.updated = /* @__PURE__ */ new Date();
265
+ return true;
266
+ }
267
+ /** Rename a branch (affects only display title). */
268
+ renameBranch(id, newTitle) {
269
+ const b = this.branches.find((b2) => b2.id === id);
270
+ if (!b) return false;
271
+ b.title = newTitle;
272
+ this.updated = /* @__PURE__ */ new Date();
273
+ return true;
274
+ }
275
+ /**
276
+ * Resolve a user-supplied branch reference (v0.4.81+).
277
+ *
278
+ * Accepts the 6-hex branch id, the branch's display title, or an unambiguous
279
+ * id prefix (≥2 chars). This exists because users naturally type the title
280
+ * they chose at `/branch new`, not the auto-generated id — every other
281
+ * `/branch <sub>` used to error on that with "not found".
282
+ *
283
+ * Resolution order:
284
+ * 1. Exact id match
285
+ * 2. Exact title match (unique)
286
+ * 3. Id prefix match (unique, length ≥ 2)
287
+ *
288
+ * @returns `{ ok: true, id }` on success; on failure `{ ok: false, reason, matches }`
289
+ * where `matches` lists candidates for ambiguous input so callers
290
+ * can render a helpful error.
291
+ */
292
+ resolveBranchRef(ref) {
293
+ if (!ref) return { ok: false, reason: "not-found", matches: [] };
294
+ const byId = this.branches.find((b) => b.id === ref);
295
+ if (byId) return { ok: true, id: byId.id };
296
+ const byTitle = this.branches.filter((b) => b.title === ref);
297
+ if (byTitle.length === 1) return { ok: true, id: byTitle[0].id };
298
+ if (byTitle.length > 1) return { ok: false, reason: "ambiguous", matches: byTitle };
299
+ if (ref.length >= 2) {
300
+ const byPrefix = this.branches.filter((b) => b.id.startsWith(ref));
301
+ if (byPrefix.length === 1) return { ok: true, id: byPrefix[0].id };
302
+ if (byPrefix.length > 1) return { ok: false, reason: "ambiguous", matches: byPrefix };
303
+ }
304
+ return { ok: false, reason: "not-found", matches: [] };
305
+ }
306
+ /** Messages of any branch (active or inactive) — read-only copy. */
307
+ getBranchMessages(id) {
308
+ if (id === this.activeBranchId) return this.messages.slice();
309
+ const m = this._inactiveBranchMessages.get(id);
310
+ return m ? m.slice() : null;
311
+ }
312
+ // ── B3 Branch diff + cherry-pick (v0.4.80+) ────────────────────────
313
+ /**
314
+ * Compare messages between two branches. Finds the longest common prefix
315
+ * by message equality (role + content + toolCalls shape) — NOT by fork
316
+ * point metadata, so user-edited histories still diff cleanly.
317
+ *
318
+ * @param sourceId Branch to compare
319
+ * @param targetId Branch to compare against (defaults to active branch)
320
+ * @returns BranchDiff, or null if either branch id is unknown
321
+ */
322
+ diffBranches(sourceId, targetId) {
323
+ const tgt = targetId ?? this.activeBranchId;
324
+ const src = this.getBranchMessages(sourceId);
325
+ const dst = this.getBranchMessages(tgt);
326
+ if (!src || !dst) return null;
327
+ let i = 0;
328
+ while (i < src.length && i < dst.length && messagesEqual(src[i], dst[i])) i++;
329
+ return {
330
+ sourceId,
331
+ targetId: tgt,
332
+ commonPrefix: i,
333
+ sourceOnly: src.slice(i),
334
+ targetOnly: dst.slice(i)
335
+ };
336
+ }
337
+ /**
338
+ * Copy a single message from another branch into the active branch
339
+ * (appended at the end). Timestamp is reset to now so it's clear when
340
+ * the cherry-pick happened.
341
+ *
342
+ * @returns the cloned message, or null if sourceId / msgIndex is invalid
343
+ */
344
+ cherryPickMessage(sourceId, msgIndex) {
345
+ const src = this.getBranchMessages(sourceId);
346
+ if (!src) return null;
347
+ if (msgIndex < 0 || msgIndex >= src.length) return null;
348
+ const orig = src[msgIndex];
349
+ const copy = {
350
+ ...orig,
351
+ timestamp: /* @__PURE__ */ new Date()
352
+ };
353
+ this.messages.push(copy);
354
+ this.updated = /* @__PURE__ */ new Date();
355
+ return copy;
356
+ }
357
+ getMeta() {
358
+ return {
359
+ id: this.id,
360
+ provider: this.provider,
361
+ model: this.model,
362
+ messageCount: this.messages.length,
363
+ created: this.created,
364
+ updated: this.updated,
365
+ title: this.title
366
+ };
367
+ }
368
+ toJSON() {
369
+ const serializeMessages = (msgs) => msgs.map((m) => {
370
+ const out = {
371
+ role: m.role,
372
+ content: m.content,
373
+ timestamp: m.timestamp.toISOString()
374
+ };
375
+ if (m.toolCalls) out.toolCalls = m.toolCalls;
376
+ if (m.reasoningContent !== void 0) out.reasoningContent = m.reasoningContent;
377
+ if (m.toolCallId) out.toolCallId = m.toolCallId;
378
+ if (m.toolName) out.toolName = m.toolName;
379
+ if (m.isError !== void 0) out.isError = m.isError;
380
+ return out;
381
+ });
382
+ const branchMessages = {};
383
+ for (const [id, msgs] of this._inactiveBranchMessages.entries()) {
384
+ branchMessages[id] = serializeMessages(msgs);
385
+ }
386
+ return {
387
+ id: this.id,
388
+ provider: this.provider,
389
+ model: this.model,
390
+ created: this.created.toISOString(),
391
+ updated: this.updated.toISOString(),
392
+ title: this.title,
393
+ tokenUsage: { ...this.tokenUsage },
394
+ checkpoints: this.checkpoints.map((c) => ({
395
+ name: c.name,
396
+ messageIndex: c.messageIndex,
397
+ timestamp: c.timestamp.toISOString()
398
+ })),
399
+ // B2 Branches (v0.4.74+). Omitted for sessions with only the default
400
+ // 'main' branch and no stashed messages (keeps file size identical
401
+ // to pre-B2 for the common case).
402
+ ...this.branches.length > 1 || this._inactiveBranchMessages.size > 0 ? {
403
+ activeBranchId: this.activeBranchId,
404
+ branches: this.branches.map((b) => ({
405
+ id: b.id,
406
+ title: b.title,
407
+ parentBranchId: b.parentBranchId,
408
+ parentMessageIndex: b.parentMessageIndex,
409
+ created: b.created.toISOString()
410
+ })),
411
+ branchMessages
412
+ } : {},
413
+ messages: serializeMessages(this.messages)
414
+ };
415
+ }
416
+ /**
417
+ * 从现有 session 分叉创建新 session。
418
+ *
419
+ * 复制 messages[0..messageCount],保留范围内的 checkpoints。
420
+ * 新 session 拥有独立 UUID,title 默认 "Fork of <原标题>"。
421
+ *
422
+ * @param original 原始 session
423
+ * @param newId 新 session 的 UUID
424
+ * @param messageCount 复制的消息数量(0 = 空 session,> messages.length 则取全部)
425
+ * @param newTitle 可选的新标题
426
+ */
427
+ static fork(original, newId, messageCount, newTitle) {
428
+ const forked = new _Session(newId, original.provider, original.model);
429
+ forked.title = newTitle ?? (original.title ? `Fork of ${original.title}` : void 0);
430
+ const clampedCount = Math.min(Math.max(messageCount, 0), original.messages.length);
431
+ forked.messages = original.messages.slice(0, clampedCount).map((m) => {
432
+ const cloned = { ...m };
433
+ if (Array.isArray(cloned.content)) {
434
+ cloned.content = cloned.content.map(
435
+ (part) => typeof part === "object" && part !== null ? { ...part } : part
436
+ );
437
+ }
438
+ return cloned;
439
+ });
440
+ forked.checkpoints = original.checkpoints.filter((c) => c.messageIndex <= clampedCount).map((c) => ({ ...c, timestamp: new Date(c.timestamp.getTime()) }));
441
+ forked.updated = /* @__PURE__ */ new Date();
442
+ return forked;
443
+ }
444
+ /**
445
+ * 从磁盘 JSON 数据恢复 Session 实例。
446
+ * 添加运行时校验:损坏或不兼容的历史文件会抛出明确错误,而非 TypeError 崩溃。
447
+ */
448
+ static fromJSON(data) {
449
+ const d = data;
450
+ if (!d || typeof d !== "object") {
451
+ throw new Error("Invalid session data: expected an object");
452
+ }
453
+ if (typeof d.id !== "string" || typeof d.provider !== "string" || typeof d.model !== "string") {
454
+ throw new Error("Invalid session data: missing or invalid id/provider/model fields");
455
+ }
456
+ if (!Array.isArray(d.messages)) {
457
+ throw new Error("Invalid session data: messages is not an array");
458
+ }
459
+ const session = new _Session(d.id, d.provider, d.model);
460
+ session.title = typeof d.title === "string" ? d.title : void 0;
461
+ const created = new Date(d.created);
462
+ const updated = new Date(d.updated);
463
+ session.created = isNaN(created.getTime()) ? /* @__PURE__ */ new Date() : created;
464
+ session.updated = isNaN(updated.getTime()) ? /* @__PURE__ */ new Date() : updated;
465
+ const tu = d.tokenUsage;
466
+ if (tu && typeof tu === "object") {
467
+ session.tokenUsage = {
468
+ inputTokens: typeof tu.inputTokens === "number" ? tu.inputTokens : 0,
469
+ outputTokens: typeof tu.outputTokens === "number" ? tu.outputTokens : 0,
470
+ cacheCreationTokens: typeof tu.cacheCreationTokens === "number" ? tu.cacheCreationTokens : 0,
471
+ cacheReadTokens: typeof tu.cacheReadTokens === "number" ? tu.cacheReadTokens : 0
472
+ };
473
+ }
474
+ if (Array.isArray(d.checkpoints)) {
475
+ session.checkpoints = d.checkpoints.map((c) => ({
476
+ name: String(c.name ?? ""),
477
+ messageIndex: typeof c.messageIndex === "number" ? c.messageIndex : 0,
478
+ timestamp: new Date(c.timestamp)
479
+ }));
480
+ }
481
+ const deserializeMessages = (arr) => arr.map((m) => {
482
+ const ts = new Date(m.timestamp);
483
+ const msg = {
484
+ role: m.role ?? "user",
485
+ content: Array.isArray(m.content) ? m.content : String(m.content ?? ""),
486
+ timestamp: isNaN(ts.getTime()) ? /* @__PURE__ */ new Date() : ts
487
+ };
488
+ if (Array.isArray(m.toolCalls)) msg.toolCalls = m.toolCalls;
489
+ if (typeof m.reasoningContent === "string") msg.reasoningContent = m.reasoningContent;
490
+ if (typeof m.toolCallId === "string") msg.toolCallId = m.toolCallId;
491
+ if (typeof m.toolName === "string") msg.toolName = m.toolName;
492
+ if (typeof m.isError === "boolean") msg.isError = m.isError;
493
+ return msg;
494
+ });
495
+ session.messages = deserializeMessages(d.messages);
496
+ if (Array.isArray(d.branches) && d.branches.length > 0) {
497
+ session.branches = d.branches.map((b) => {
498
+ const ts = new Date(b.created);
499
+ return {
500
+ id: String(b.id ?? "main"),
501
+ title: String(b.title ?? b.id ?? "main"),
502
+ parentBranchId: typeof b.parentBranchId === "string" ? b.parentBranchId : null,
503
+ parentMessageIndex: typeof b.parentMessageIndex === "number" ? b.parentMessageIndex : 0,
504
+ created: isNaN(ts.getTime()) ? /* @__PURE__ */ new Date() : ts
505
+ };
506
+ });
507
+ session.activeBranchId = typeof d.activeBranchId === "string" ? d.activeBranchId : session.branches[0]?.id ?? "main";
508
+ const bm = d.branchMessages;
509
+ if (bm && typeof bm === "object") {
510
+ for (const [id, arr] of Object.entries(bm)) {
511
+ if (id === session.activeBranchId) continue;
512
+ if (!Array.isArray(arr)) continue;
513
+ session._inactiveBranchMessages.set(
514
+ id,
515
+ deserializeMessages(arr)
516
+ );
517
+ }
518
+ }
519
+ }
520
+ return session;
521
+ }
522
+ };
523
+
524
+ // src/session/session-manager.ts
525
+ function safeDate(value) {
526
+ const d = new Date(value);
527
+ return isNaN(d.getTime()) ? /* @__PURE__ */ new Date(0) : d;
528
+ }
529
+ function extractJsonField(header, field) {
530
+ const re = new RegExp(`"${field}"\\s*:\\s*"([^"]*)"`, "i");
531
+ const m = header.match(re);
532
+ return m ? m[1] : void 0;
533
+ }
534
+ var SessionManager = class {
535
+ _current = null;
536
+ historyDir;
537
+ config;
538
+ /** Last save's redaction hit count — exposed for /security status reporting */
539
+ lastRedactionHits = 0;
540
+ constructor(config) {
541
+ this.config = config;
542
+ this.historyDir = config.getHistoryDir();
543
+ }
544
+ /**
545
+ * Build redaction options from config. Returns `{ enabled: false }` when
546
+ * `security.redactOnSave` is off or `security.mode` is 'off'.
547
+ */
548
+ redactOptionsForSave() {
549
+ const security = this.config.get("security");
550
+ if (!security || !security.redactOnSave || security.mode === "off") {
551
+ return { enabled: false };
552
+ }
553
+ return {
554
+ enabled: true,
555
+ customRegexes: security.customPatterns ?? []
556
+ };
557
+ }
558
+ get current() {
559
+ return this._current;
560
+ }
561
+ createSession(provider, model) {
562
+ const session = new Session(uuidv4(), provider, model);
563
+ this._current = session;
564
+ return session;
565
+ }
566
+ /**
567
+ * 直接设置当前会话(用于从内存缓存恢复未保存的会话)。
568
+ * 与 `loadSession` 不同,此方法不读取磁盘,也不抛出错误。
569
+ * Web 多 Tab 场景下,SessionHandler 会维护一份未保存会话的内存缓存,
570
+ * 切换 Tab 时通过此方法将缓存中的会话设为当前会话,避免"Session not found"。
571
+ */
572
+ setCurrent(session) {
573
+ this._current = session;
574
+ }
575
+ /** 清除当前会话引用(下次访问将触发 lazy 创建)。 */
576
+ clearCurrent() {
577
+ this._current = null;
578
+ }
579
+ async save() {
580
+ if (!this._current) return;
581
+ mkdirSync(this.historyDir, { recursive: true });
582
+ const filePath = join(this.historyDir, `${this._current.id}.json`);
583
+ const raw = this._current.toJSON();
584
+ const opts = this.redactOptionsForSave();
585
+ const { value: payload, hits } = redactJson(raw, opts);
586
+ this.lastRedactionHits = hits.length;
587
+ const tmpPath = filePath + ".tmp";
588
+ writeFileSync(tmpPath, JSON.stringify(payload, null, 2), "utf-8");
589
+ renameSync(tmpPath, filePath);
590
+ }
591
+ loadSession(id) {
592
+ const filePath = join(this.historyDir, `${id}.json`);
593
+ if (!existsSync(filePath)) {
594
+ throw new Error(`Session ${id} not found`);
595
+ }
596
+ let data;
597
+ try {
598
+ data = JSON.parse(readFileSync(filePath, "utf-8"));
599
+ } catch (err) {
600
+ throw new Error(`Session ${id} is corrupted: ${err instanceof Error ? err.message : String(err)}`);
601
+ }
602
+ const session = Session.fromJSON(data);
603
+ this._current = session;
604
+ return session;
605
+ }
606
+ listSessions() {
607
+ if (!existsSync(this.historyDir)) return [];
608
+ const files = readdirSync(this.historyDir).filter((f) => f.endsWith(".json"));
609
+ const metas = [];
610
+ for (const file of files) {
611
+ try {
612
+ const meta = this.readSessionMeta(join(this.historyDir, file));
613
+ if (meta) metas.push(meta);
614
+ } catch (err) {
615
+ process.stderr.write(
616
+ `[Warning] Skipping corrupted session file "${file}": ${err instanceof Error ? err.message : String(err)}
617
+ `
618
+ );
619
+ }
620
+ }
621
+ return metas.sort((a, b) => b.updated.getTime() - a.updated.getTime());
622
+ }
623
+ /**
624
+ * P1-B: Read only the first ~1KB of a session file to extract metadata fields.
625
+ * Session JSON format puts id/provider/model/created/updated/title before the
626
+ * large "messages" array, so a small header read suffices for metadata extraction.
627
+ * Falls back to full file read if header parsing fails.
628
+ */
629
+ readSessionMeta(filePath) {
630
+ const HEADER_SIZE = 1024;
631
+ let header;
632
+ try {
633
+ const fd = openSync(filePath, "r");
634
+ const buf = Buffer.alloc(HEADER_SIZE);
635
+ const bytesRead = readSync(fd, buf, 0, HEADER_SIZE, 0);
636
+ closeSync(fd);
637
+ header = buf.toString("utf-8", 0, bytesRead);
638
+ } catch {
639
+ return null;
640
+ }
641
+ const id = extractJsonField(header, "id");
642
+ const provider = extractJsonField(header, "provider");
643
+ const model = extractJsonField(header, "model");
644
+ const created = extractJsonField(header, "created");
645
+ const updated = extractJsonField(header, "updated");
646
+ const title = extractJsonField(header, "title");
647
+ if (id && provider && model) {
648
+ let messageCount = 0;
649
+ try {
650
+ const full = readFileSync(filePath, "utf-8");
651
+ const matches = full.match(/"role"\s*:/g);
652
+ messageCount = matches ? matches.length : 0;
653
+ } catch {
654
+ }
655
+ return {
656
+ id,
657
+ provider,
658
+ model,
659
+ messageCount,
660
+ created: safeDate(created),
661
+ updated: safeDate(updated),
662
+ title: title || void 0
663
+ };
664
+ }
665
+ const data = JSON.parse(readFileSync(filePath, "utf-8"));
666
+ return {
667
+ id: data.id,
668
+ provider: data.provider,
669
+ model: data.model,
670
+ messageCount: data.messages?.length ?? 0,
671
+ created: safeDate(data.created),
672
+ updated: safeDate(data.updated),
673
+ title: data.title
674
+ };
675
+ }
676
+ deleteSession(id) {
677
+ const filePath = join(this.historyDir, `${id}.json`);
678
+ if (!existsSync(filePath)) return false;
679
+ try {
680
+ unlinkSync(filePath);
681
+ if (this._current && this._current.id === id) {
682
+ this._current = null;
683
+ }
684
+ return true;
685
+ } catch {
686
+ return false;
687
+ }
688
+ }
689
+ /**
690
+ * 从当前 session 分叉创建新 session。
691
+ *
692
+ * 先保存原始 session(保留完整历史),然后创建分叉(截取到 messageCount),
693
+ * 将分叉设为当前 session 并保存。
694
+ *
695
+ * @param messageCount 复制的消息数量
696
+ * @param title 可选的新标题
697
+ * @returns 新的分叉 session
698
+ */
699
+ async forkSession(messageCount, title) {
700
+ if (!this._current) {
701
+ throw new Error("No active session to fork");
702
+ }
703
+ await this.save();
704
+ const forked = Session.fork(this._current, uuidv4(), messageCount, title);
705
+ this._current = forked;
706
+ await this.save();
707
+ return forked;
708
+ }
709
+ /**
710
+ * 跨 session 全文搜索。
711
+ * 遍历所有历史 JSON 文件,逐条匹配消息内容(不区分大小写),
712
+ * 每个 session 最多返回 3 条匹配片段,全局最多 maxResults 个 session。
713
+ */
714
+ searchMessages(query, maxResults = 20) {
715
+ if (!existsSync(this.historyDir)) return [];
716
+ const q = query.toLowerCase();
717
+ const files = readdirSync(this.historyDir).filter((f) => f.endsWith(".json")).map((f) => join(this.historyDir, f));
718
+ const results = [];
719
+ for (const filePath of files) {
720
+ if (results.length >= maxResults) break;
721
+ try {
722
+ const data = JSON.parse(readFileSync(filePath, "utf-8"));
723
+ const messages = data.messages ?? [];
724
+ const matches = [];
725
+ for (const msg of messages) {
726
+ if (matches.length >= 3) break;
727
+ let text = "";
728
+ if (typeof msg.content === "string") {
729
+ text = msg.content;
730
+ } else if (Array.isArray(msg.content)) {
731
+ text = msg.content.filter((p) => p.type === "text").map((p) => p.text ?? "").join("");
732
+ }
733
+ const lowerText = text.toLowerCase();
734
+ const idx = lowerText.indexOf(q);
735
+ if (idx !== -1) {
736
+ const start = Math.max(0, idx - 30);
737
+ const end = Math.min(text.length, idx + query.length + 60);
738
+ const snippet = (start > 0 ? "\u2026" : "") + text.slice(start, end).replace(/\n/g, " ") + (end < text.length ? "\u2026" : "");
739
+ matches.push({ role: msg.role, snippet });
740
+ }
741
+ }
742
+ if (matches.length > 0) {
743
+ results.push({
744
+ sessionMeta: {
745
+ id: data.id,
746
+ provider: data.provider,
747
+ model: data.model,
748
+ messageCount: messages.length,
749
+ created: safeDate(data.created),
750
+ updated: safeDate(data.updated),
751
+ title: data.title
752
+ },
753
+ matches
754
+ });
755
+ }
756
+ } catch (err) {
757
+ process.stderr.write(
758
+ `[Warning] Skipping corrupted session file "${filePath}": ${err instanceof Error ? err.message : String(err)}
759
+ `
760
+ );
761
+ }
762
+ }
763
+ return results.sort((a, b) => b.sessionMeta.updated.getTime() - a.sessionMeta.updated.getTime());
764
+ }
765
+ };
766
+
767
+ // src/mcp/client.ts
768
+ import { spawn } from "child_process";
769
+ var McpClient = class {
770
+ serverId;
771
+ config;
772
+ process = null;
773
+ nextId = 1;
774
+ // M8: wraps at MAX_SAFE_INTEGER via getNextId()
775
+ connected = false;
776
+ serverInfo = null;
777
+ /** stderr 收集(最多保留最后 2KB,用于错误报告) */
778
+ stderrBuffer = "";
779
+ /** 缓存已发现的工具列表 */
780
+ cachedTools = [];
781
+ /** 错误信息(连接失败时设置) */
782
+ errorMessage = null;
783
+ // ── JSON-RPC 请求/响应匹配 ──────────────────────────────────────
784
+ pendingRequests = /* @__PURE__ */ new Map();
785
+ /** stdout 残余缓冲区(处理不完整的 JSON 行) */
786
+ stdoutBuffer = "";
787
+ constructor(serverId, config) {
788
+ this.serverId = serverId;
789
+ this.config = config;
790
+ }
791
+ get isConnected() {
792
+ return this.connected;
793
+ }
794
+ get serverName() {
795
+ return this.serverInfo?.name ?? this.serverId;
796
+ }
797
+ get tools() {
798
+ return this.cachedTools;
799
+ }
800
+ // ══════════════════════════════════════════════════════════════════
801
+ // 连接与初始化
802
+ // ══════════════════════════════════════════════════════════════════
803
+ async connect() {
804
+ const timeout = this.config.timeout ?? MCP_CONNECT_TIMEOUT;
805
+ try {
806
+ this.process = spawn(this.config.command, this.config.args ?? [], {
807
+ stdio: ["pipe", "pipe", "pipe"],
808
+ env: { ...process.env, ...this.config.env },
809
+ // Windows 上 npx 等是 .cmd 脚本,需要 shell 模式
810
+ shell: process.platform === "win32",
811
+ // 不让子进程阻止父进程退出
812
+ detached: false
813
+ });
814
+ this.process.on("error", (err) => {
815
+ this.errorMessage = err.message;
816
+ this.connected = false;
817
+ this.rejectAllPending(new Error(`MCP server [${this.serverId}] process error: ${err.message}`));
818
+ });
819
+ this.process.on("exit", (code, signal) => {
820
+ this.connected = false;
821
+ const reason = signal ? `signal ${signal}` : `code ${code}`;
822
+ this.rejectAllPending(new Error(`MCP server [${this.serverId}] exited: ${reason}`));
823
+ });
824
+ this.process.stdout.setEncoding("utf-8");
825
+ this.process.stdout.on("data", (chunk) => this.handleStdoutData(chunk));
826
+ this.process.stderr.setEncoding("utf-8");
827
+ this.process.stderr.on("data", (chunk) => {
828
+ this.stderrBuffer += chunk;
829
+ if (this.stderrBuffer.length > 2048) {
830
+ this.stderrBuffer = this.stderrBuffer.slice(-2048);
831
+ }
832
+ });
833
+ const initResult = await this.withTimeout(
834
+ this.sendRequest("initialize", {
835
+ protocolVersion: MCP_PROTOCOL_VERSION,
836
+ capabilities: {},
837
+ clientInfo: { name: APP_NAME, version: VERSION }
838
+ }),
839
+ timeout,
840
+ "initialize handshake"
841
+ );
842
+ this.serverInfo = initResult.serverInfo;
843
+ this.sendNotification("notifications/initialized");
844
+ this.connected = true;
845
+ await this.refreshTools();
846
+ } catch (err) {
847
+ this.errorMessage = err instanceof Error ? err.message : String(err);
848
+ this.connected = false;
849
+ this.killProcess();
850
+ throw err;
851
+ }
852
+ }
853
+ // ══════════════════════════════════════════════════════════════════
854
+ // 工具操作
855
+ // ══════════════════════════════════════════════════════════════════
856
+ /** 刷新工具列表(tools/list) */
857
+ async refreshTools() {
858
+ this.ensureConnected();
859
+ const result = await this.withTimeout(
860
+ this.sendRequest("tools/list", {}),
861
+ MCP_CALL_TIMEOUT,
862
+ "tools/list"
863
+ );
864
+ this.cachedTools = result.tools ?? [];
865
+ return this.cachedTools;
866
+ }
867
+ /** 调用工具(tools/call) */
868
+ async callTool(name, args) {
869
+ this.ensureConnected();
870
+ return this.withTimeout(
871
+ this.sendRequest("tools/call", { name, arguments: args }),
872
+ MCP_CALL_TIMEOUT,
873
+ `tools/call(${name})`
874
+ );
875
+ }
876
+ // ══════════════════════════════════════════════════════════════════
877
+ // 关闭连接
878
+ // ══════════════════════════════════════════════════════════════════
879
+ async close() {
880
+ this.connected = false;
881
+ this.rejectAllPending(new Error("Client closing"));
882
+ this.killProcess();
883
+ }
884
+ /**
885
+ * 断线重连:清理旧状态后重新执行完整的 connect() 流程。
886
+ * 用于 MCP 服务器子进程意外退出后的自动或手动恢复。
887
+ */
888
+ async reconnect() {
889
+ this.connected = false;
890
+ this.rejectAllPending(new Error("Reconnecting"));
891
+ this.killProcess();
892
+ this.errorMessage = null;
893
+ this.stderrBuffer = "";
894
+ this.stdoutBuffer = "";
895
+ this.cachedTools = [];
896
+ await this.connect();
897
+ }
898
+ // ══════════════════════════════════════════════════════════════════
899
+ // 内部方法:JSON-RPC 通信
900
+ // ══════════════════════════════════════════════════════════════════
901
+ sendRequest(method, params) {
902
+ return new Promise((resolve, reject) => {
903
+ if (!this.process?.stdin?.writable) {
904
+ return reject(new Error(`MCP server [${this.serverId}] stdin not writable`));
905
+ }
906
+ const id = this.nextId++;
907
+ if (this.nextId > Number.MAX_SAFE_INTEGER - 1) this.nextId = 1;
908
+ const request = {
909
+ jsonrpc: "2.0",
910
+ id,
911
+ method,
912
+ ...params !== void 0 ? { params } : {}
913
+ };
914
+ let timer;
915
+ const cleanup = () => {
916
+ this.pendingRequests.delete(id);
917
+ clearTimeout(timer);
918
+ };
919
+ timer = setTimeout(() => {
920
+ cleanup();
921
+ reject(new Error(`MCP request [${method}] timed out (internal)`));
922
+ }, MCP_CALL_TIMEOUT * 2);
923
+ this.pendingRequests.set(id, {
924
+ resolve: (result) => {
925
+ cleanup();
926
+ resolve(result);
927
+ },
928
+ reject: (error) => {
929
+ cleanup();
930
+ reject(error);
931
+ },
932
+ timer
933
+ });
934
+ const json = JSON.stringify(request) + "\n";
935
+ this.process.stdin.write(json, (err) => {
936
+ if (err) {
937
+ cleanup();
938
+ reject(new Error(`MCP write error: ${err.message}`));
939
+ }
940
+ });
941
+ });
942
+ }
943
+ sendNotification(method, params) {
944
+ if (!this.process?.stdin?.writable) return;
945
+ const notification = {
946
+ jsonrpc: "2.0",
947
+ method,
948
+ ...params !== void 0 ? { params } : {}
949
+ };
950
+ this.process.stdin.write(JSON.stringify(notification) + "\n");
951
+ }
952
+ /**
953
+ * 处理 stdout 数据:按行分割,每行解析为 JSON-RPC 响应。
954
+ * 处理不完整行:残余数据保留在 stdoutBuffer 中。
955
+ */
956
+ handleStdoutData(chunk) {
957
+ this.stdoutBuffer += chunk;
958
+ if (this.stdoutBuffer.length > 1048576) {
959
+ process.stderr.write(`[mcp] stdout buffer exceeded 1MB \u2014 clearing
960
+ `);
961
+ this.stdoutBuffer = "";
962
+ return;
963
+ }
964
+ const lines = this.stdoutBuffer.split("\n");
965
+ this.stdoutBuffer = lines.pop() ?? "";
966
+ for (const line of lines) {
967
+ const trimmed = line.trim();
968
+ if (!trimmed) continue;
969
+ try {
970
+ const msg = JSON.parse(trimmed);
971
+ this.handleMessage(msg);
972
+ } catch {
973
+ }
974
+ }
975
+ }
976
+ /** 处理收到的 JSON-RPC 消息 */
977
+ handleMessage(msg) {
978
+ if ("id" in msg && typeof msg.id === "number") {
979
+ const pending = this.pendingRequests.get(msg.id);
980
+ if (!pending) return;
981
+ const response = msg;
982
+ if (response.error) {
983
+ pending.reject(new Error(
984
+ `MCP error [${response.error.code}]: ${response.error.message}`
985
+ ));
986
+ } else {
987
+ pending.resolve(response.result);
988
+ }
989
+ }
990
+ }
991
+ // ══════════════════════════════════════════════════════════════════
992
+ // 辅助方法
993
+ // ══════════════════════════════════════════════════════════════════
994
+ ensureConnected() {
995
+ if (!this.connected) {
996
+ throw new Error(
997
+ `MCP server [${this.serverId}] is not connected` + (this.errorMessage ? `: ${this.errorMessage}` : "")
998
+ );
999
+ }
1000
+ }
1001
+ /** Promise 超时包装 */
1002
+ withTimeout(promise, ms, label) {
1003
+ return new Promise((resolve, reject) => {
1004
+ const timer = setTimeout(() => {
1005
+ reject(new Error(`MCP [${this.serverId}] ${label} timed out after ${ms}ms`));
1006
+ }, ms);
1007
+ promise.then((val) => {
1008
+ clearTimeout(timer);
1009
+ resolve(val);
1010
+ }).catch((err) => {
1011
+ clearTimeout(timer);
1012
+ reject(err);
1013
+ });
1014
+ });
1015
+ }
1016
+ /** 拒绝所有挂起的请求。pending.reject() 内部的 cleanup() 已包含 clearTimeout。 */
1017
+ rejectAllPending(error) {
1018
+ for (const [, pending] of this.pendingRequests) {
1019
+ pending.reject(error);
1020
+ }
1021
+ this.pendingRequests.clear();
1022
+ }
1023
+ /** 杀掉子进程,并移除所有事件监听器防止僵尸引用 */
1024
+ killProcess() {
1025
+ if (this.process) {
1026
+ this.process.removeAllListeners("error");
1027
+ this.process.removeAllListeners("exit");
1028
+ this.process.stdout?.removeAllListeners("data");
1029
+ this.process.stderr?.removeAllListeners("data");
1030
+ try {
1031
+ this.process.stdin?.end();
1032
+ this.process.kill();
1033
+ } catch {
1034
+ }
1035
+ this.process = null;
1036
+ }
1037
+ }
1038
+ };
1039
+
1040
+ // src/mcp/manager.ts
1041
+ var McpManager = class {
1042
+ clients = /* @__PURE__ */ new Map();
1043
+ /**
1044
+ * 连接所有配置的 MCP 服务器(并发连接,单个失败不阻塞其他)。
1045
+ * 连接结果通过 getStatus() 查看。
1046
+ */
1047
+ async connectAll(servers) {
1048
+ const entries = Object.entries(servers);
1049
+ if (entries.length === 0) return;
1050
+ const promises = entries.map(async ([serverId, config]) => {
1051
+ const client = new McpClient(serverId, config);
1052
+ this.clients.set(serverId, client);
1053
+ try {
1054
+ await client.connect();
1055
+ process.stderr.write(`[mcp] \u2713 ${serverId}: connected (${client.serverName}, ${client.tools.length} tools)
1056
+ `);
1057
+ } catch (err) {
1058
+ const msg = err instanceof Error ? err.message : String(err);
1059
+ process.stderr.write(`[mcp] \u2717 ${serverId}: ${msg}
1060
+ `);
1061
+ }
1062
+ });
1063
+ await Promise.allSettled(promises);
1064
+ }
1065
+ /**
1066
+ * 获取所有已连接服务器的工具,转换为 ai-cli 的 Tool 接口。
1067
+ * 工具名格式:mcp__<serverId>__<toolName>
1068
+ */
1069
+ getAllTools() {
1070
+ const tools = [];
1071
+ for (const [serverId, client] of this.clients) {
1072
+ if (!client.isConnected) continue;
1073
+ for (const mcpTool of client.tools) {
1074
+ tools.push(this.wrapMcpTool(serverId, client, mcpTool));
1075
+ }
1076
+ }
1077
+ return tools;
1078
+ }
1079
+ /**
1080
+ * 获取连接状态摘要。
1081
+ */
1082
+ getStatus() {
1083
+ const statuses = [];
1084
+ for (const [serverId, client] of this.clients) {
1085
+ statuses.push({
1086
+ serverId,
1087
+ serverName: client.serverName,
1088
+ toolCount: client.tools.length,
1089
+ connected: client.isConnected,
1090
+ error: client.errorMessage ?? void 0
1091
+ });
1092
+ }
1093
+ return statuses;
1094
+ }
1095
+ /**
1096
+ * 获取已连接服务器数量。
1097
+ */
1098
+ getConnectedCount() {
1099
+ let count = 0;
1100
+ for (const client of this.clients.values()) {
1101
+ if (client.isConnected) count++;
1102
+ }
1103
+ return count;
1104
+ }
1105
+ /**
1106
+ * 获取所有 MCP 工具的总数。
1107
+ */
1108
+ getTotalToolCount() {
1109
+ let count = 0;
1110
+ for (const client of this.clients.values()) {
1111
+ if (client.isConnected) count += client.tools.length;
1112
+ }
1113
+ return count;
1114
+ }
1115
+ /**
1116
+ * 重连指定 MCP 服务器。
1117
+ * @returns 重连是否成功
1118
+ */
1119
+ async reconnectServer(serverId) {
1120
+ const client = this.clients.get(serverId);
1121
+ if (!client) return false;
1122
+ try {
1123
+ await client.reconnect();
1124
+ process.stderr.write(`[mcp] \u2713 ${serverId}: reconnected (${client.serverName}, ${client.tools.length} tools)
1125
+ `);
1126
+ return true;
1127
+ } catch (err) {
1128
+ const msg = err instanceof Error ? err.message : String(err);
1129
+ process.stderr.write(`[mcp] \u2717 ${serverId}: reconnect failed: ${msg}
1130
+ `);
1131
+ return false;
1132
+ }
1133
+ }
1134
+ /**
1135
+ * 重连所有已断开的 MCP 服务器。
1136
+ * @returns 成功重连的服务器数量
1137
+ */
1138
+ async reconnectAll() {
1139
+ let reconnected = 0;
1140
+ for (const [serverId, client] of this.clients) {
1141
+ if (!client.isConnected) {
1142
+ const ok = await this.reconnectServer(serverId);
1143
+ if (ok) reconnected++;
1144
+ }
1145
+ }
1146
+ return reconnected;
1147
+ }
1148
+ /**
1149
+ * 关闭所有 MCP 服务器连接。
1150
+ */
1151
+ async closeAll() {
1152
+ const promises = [...this.clients.entries()].map(
1153
+ ([id, c]) => c.close().catch((err) => {
1154
+ process.stderr.write(`[mcp] Failed to close ${id}: ${err instanceof Error ? err.message : err}
1155
+ `);
1156
+ })
1157
+ );
1158
+ await Promise.allSettled(promises);
1159
+ this.clients.clear();
1160
+ }
1161
+ // ══════════════════════════════════════════════════════════════════
1162
+ // 内部方法
1163
+ // ══════════════════════════════════════════════════════════════════
1164
+ /**
1165
+ * 将 MCP 工具包装为 ai-cli 的 Tool 接口。
1166
+ * execute() 方法委托给对应 McpClient.callTool()。
1167
+ */
1168
+ wrapMcpTool(serverId, client, mcpTool) {
1169
+ const toolName = `${MCP_TOOL_PREFIX}${serverId}__${mcpTool.name}`;
1170
+ const definition = this.convertDefinition(toolName, serverId, mcpTool);
1171
+ return {
1172
+ definition,
1173
+ execute: async (args) => {
1174
+ if (!client.isConnected) {
1175
+ process.stderr.write(`[mcp] ${serverId}: disconnected, attempting reconnect...
1176
+ `);
1177
+ try {
1178
+ await client.reconnect();
1179
+ process.stderr.write(`[mcp] \u2713 ${serverId}: reconnected (${client.tools.length} tools)
1180
+ `);
1181
+ } catch {
1182
+ throw new Error(
1183
+ `MCP server [${serverId}] is disconnected and reconnect failed` + (client.errorMessage ? `: ${client.errorMessage}` : "") + `. Use /mcp reconnect to retry, or restart ai-cli.`
1184
+ );
1185
+ }
1186
+ }
1187
+ try {
1188
+ const result = await client.callTool(mcpTool.name, args);
1189
+ if (result.isError) {
1190
+ const errorText = this.extractText(result.content);
1191
+ throw new Error(errorText || "MCP tool returned error with no message");
1192
+ }
1193
+ return this.extractText(result.content) || "(no output)";
1194
+ } catch (err) {
1195
+ throw new Error(`MCP [${serverId}/${mcpTool.name}]: ${err instanceof Error ? err.message : String(err)}`);
1196
+ }
1197
+ }
1198
+ };
1199
+ }
1200
+ /**
1201
+ * 转换 MCP 工具定义为 ai-cli 的 ToolDefinition。
1202
+ * MCP 使用 JSON Schema 的 inputSchema,ai-cli 使用 Record<string, ToolParameterSchema>。
1203
+ * M12 修复:递归转换嵌套 object/array schema,保留完整结构供 AI 正确传参。
1204
+ */
1205
+ convertDefinition(toolName, serverId, mcpTool) {
1206
+ const parameters = {};
1207
+ const props = mcpTool.inputSchema?.properties ?? {};
1208
+ const required = new Set(mcpTool.inputSchema?.required ?? []);
1209
+ for (const [key, schema] of Object.entries(props)) {
1210
+ parameters[key] = {
1211
+ ...this.convertSchema(schema),
1212
+ ...required.has(key) ? { required: true } : {}
1213
+ };
1214
+ }
1215
+ return {
1216
+ name: toolName,
1217
+ description: (mcpTool.description ?? mcpTool.name) + ` [MCP: ${serverId}]`,
1218
+ parameters
1219
+ };
1220
+ }
1221
+ /**
1222
+ * 递归转换单个 JSON Schema 属性为 ToolParameterSchema。
1223
+ * 支持嵌套 object(含 properties)和 array(含 items)。
1224
+ * 递归深度受 MCP 工具实际 schema 复杂度约束(通常 2-3 层)。
1225
+ */
1226
+ convertSchema(schema) {
1227
+ const type = this.normalizeType(String(schema["type"] ?? "string"));
1228
+ const result = {
1229
+ type,
1230
+ description: String(schema["description"] ?? ""),
1231
+ ...schema["enum"] ? { enum: schema["enum"] } : {}
1232
+ };
1233
+ if (type === "object" && schema["properties"]) {
1234
+ const props = schema["properties"];
1235
+ result.properties = {};
1236
+ for (const [k, v] of Object.entries(props)) {
1237
+ result.properties[k] = this.convertSchema(v);
1238
+ }
1239
+ }
1240
+ if (type === "array" && schema["items"]) {
1241
+ const items = schema["items"];
1242
+ result.items = this.convertSchema(items);
1243
+ }
1244
+ return result;
1245
+ }
1246
+ /**
1247
+ * 将 JSON Schema 类型映射到 ai-cli 的 ToolParameterType。
1248
+ * integer → number(ai-cli 不区分整数/浮点)
1249
+ */
1250
+ normalizeType(jsonSchemaType) {
1251
+ switch (jsonSchemaType) {
1252
+ case "string":
1253
+ return "string";
1254
+ case "number":
1255
+ case "integer":
1256
+ return "number";
1257
+ case "boolean":
1258
+ return "boolean";
1259
+ case "array":
1260
+ return "array";
1261
+ case "object":
1262
+ return "object";
1263
+ default:
1264
+ return "string";
1265
+ }
1266
+ }
1267
+ /**
1268
+ * 从 MCP 响应的 content blocks 中提取纯文本。
1269
+ * 只处理 type="text" 的块,其他类型(image 等)跳过。
1270
+ */
1271
+ extractText(content) {
1272
+ return content.filter((block) => block.type === "text" && block.text).map((block) => block.text).join("\n");
1273
+ }
1274
+ };
1275
+
1276
+ // src/skills/manager.ts
1277
+ import { existsSync as existsSync2, readdirSync as readdirSync2, mkdirSync as mkdirSync2, statSync } from "fs";
1278
+ import { join as join2 } from "path";
1279
+
1280
+ // src/skills/types.ts
1281
+ import { readFileSync as readFileSync2 } from "fs";
1282
+ import { basename } from "path";
1283
+ function parseSimpleYaml(yaml) {
1284
+ const result = {};
1285
+ for (const line of yaml.split("\n")) {
1286
+ const match = line.replace(/\r$/, "").match(/^(\w+)\s*:\s*(.+)$/);
1287
+ if (match) {
1288
+ result[match[1]] = match[2].trim().replace(/^['"]|['"]$/g, "");
1289
+ }
1290
+ }
1291
+ return result;
1292
+ }
1293
+ function parseYamlArray(value) {
1294
+ const bracketMatch = value.match(/^\[(.+)]$/);
1295
+ if (bracketMatch) {
1296
+ return bracketMatch[1].split(",").map((s) => s.trim().replace(/^['"]|['"]$/g, ""));
1297
+ }
1298
+ return [value.trim()];
1299
+ }
1300
+ function parseSkillFile(filePath) {
1301
+ let raw;
1302
+ try {
1303
+ raw = readFileSync2(filePath, "utf-8");
1304
+ } catch {
1305
+ return null;
1306
+ }
1307
+ const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
1308
+ if (!frontmatterMatch) {
1309
+ return {
1310
+ meta: {
1311
+ name: basename(filePath, ".md"),
1312
+ description: ""
1313
+ },
1314
+ content: raw.trim(),
1315
+ filePath
1316
+ };
1317
+ }
1318
+ const [, yaml, content] = frontmatterMatch;
1319
+ const parsed = parseSimpleYaml(yaml);
1320
+ return {
1321
+ meta: {
1322
+ name: parsed["name"] ?? basename(filePath, ".md"),
1323
+ description: parsed["description"] ?? "",
1324
+ tools: parsed["tools"] ? parseYamlArray(parsed["tools"]) : void 0
1325
+ },
1326
+ content: content.trim(),
1327
+ filePath
1328
+ };
1329
+ }
1330
+
1331
+ // src/skills/manager.ts
1332
+ var SKILL_CONTENT_WARN_CHARS_DEFAULT = 1e4;
1333
+ var SkillManager = class {
1334
+ skills = /* @__PURE__ */ new Map();
1335
+ activeSkill = null;
1336
+ skillsDir;
1337
+ /** 超大技能文件警告阈值,由调用方从 config 传入;0 = 静默,undefined = 用默认 10000 */
1338
+ warnThreshold;
1339
+ constructor(skillsDir, warnThreshold) {
1340
+ this.skillsDir = skillsDir;
1341
+ this.warnThreshold = warnThreshold ?? SKILL_CONTENT_WARN_CHARS_DEFAULT;
1342
+ }
1343
+ /** 发现并加载 skillsDir 下所有 .md 文件,返回加载数量 */
1344
+ loadSkills() {
1345
+ this.skills.clear();
1346
+ if (!existsSync2(this.skillsDir)) {
1347
+ try {
1348
+ mkdirSync2(this.skillsDir, { recursive: true });
1349
+ } catch {
1350
+ }
1351
+ return 0;
1352
+ }
1353
+ let entries;
1354
+ try {
1355
+ entries = readdirSync2(this.skillsDir);
1356
+ } catch {
1357
+ return 0;
1358
+ }
1359
+ for (const entry of entries) {
1360
+ let filePath;
1361
+ const fullPath = join2(this.skillsDir, entry);
1362
+ if (entry.endsWith(".md")) {
1363
+ filePath = fullPath;
1364
+ } else {
1365
+ try {
1366
+ if (statSync(fullPath).isDirectory()) {
1367
+ const skillMd = join2(fullPath, "SKILL.md");
1368
+ if (existsSync2(skillMd)) {
1369
+ filePath = skillMd;
1370
+ } else {
1371
+ continue;
1372
+ }
1373
+ } else {
1374
+ continue;
1375
+ }
1376
+ } catch {
1377
+ continue;
1378
+ }
1379
+ }
1380
+ const skill = parseSkillFile(filePath);
1381
+ if (skill) {
1382
+ if (skill.meta.name === "SKILL" && !entry.endsWith(".md")) {
1383
+ skill.meta.name = entry;
1384
+ }
1385
+ this.skills.set(skill.meta.name, skill);
1386
+ if (this.warnThreshold > 0 && skill.content.length > this.warnThreshold) {
1387
+ process.stderr.write(
1388
+ `\u26A0 Skill '${skill.meta.name}' is ${skill.content.length} chars (>${this.warnThreshold}). Only consumed when activated via /skill. Adjust with: /config set ui.skillSizeWarn <n|0>
1389
+ `
1390
+ );
1391
+ }
1392
+ }
1393
+ }
1394
+ return this.skills.size;
1395
+ }
1396
+ /** 列出所有已加载的技能 */
1397
+ listSkills() {
1398
+ return [...this.skills.values()];
1399
+ }
1400
+ /** 按 name 激活技能。返回被激活的 Skill,未找到返回 null。 */
1401
+ activate(name) {
1402
+ const skill = this.skills.get(name);
1403
+ if (!skill) return null;
1404
+ this.activeSkill = skill;
1405
+ return skill;
1406
+ }
1407
+ /** 停用当前技能 */
1408
+ deactivate() {
1409
+ this.activeSkill = null;
1410
+ }
1411
+ /** 获取当前激活的技能(null 表示无激活技能) */
1412
+ getActive() {
1413
+ return this.activeSkill;
1414
+ }
1415
+ /** 获取激活技能的 prompt 内容(null 表示无激活技能) */
1416
+ getActivePromptContent() {
1417
+ return this.activeSkill?.content ?? null;
1418
+ }
1419
+ /** 获取激活技能的工具白名单 Set(null 表示无限制) */
1420
+ getActiveToolFilter() {
1421
+ if (!this.activeSkill?.meta.tools) return null;
1422
+ return new Set(this.activeSkill.meta.tools);
1423
+ }
1424
+ /** 获取技能目录路径 */
1425
+ getSkillsDir() {
1426
+ return this.skillsDir;
1427
+ }
1428
+ };
1429
+
1430
+ // src/core/proxy.ts
1431
+ async function setupProxy(configProxy) {
1432
+ const proxyUrl = process.env.HTTPS_PROXY ?? process.env.HTTP_PROXY ?? process.env.https_proxy ?? process.env.http_proxy ?? configProxy;
1433
+ if (!proxyUrl) return;
1434
+ try {
1435
+ const { ProxyAgent, setGlobalDispatcher } = await import("undici");
1436
+ setGlobalDispatcher(new ProxyAgent({ uri: proxyUrl }));
1437
+ } catch {
1438
+ }
1439
+ }
1440
+
1441
+ // src/core/pricing.ts
1442
+ var PRICING_TABLE = {
1443
+ // ── Anthropic Claude ──────────────────────────────────────────
1444
+ "claude-opus-4-6": { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.5 },
1445
+ "claude-opus-4-5": { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.5 },
1446
+ "claude-sonnet-4-6": { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 },
1447
+ "claude-sonnet-4-5-20250929": { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 },
1448
+ "claude-haiku-4-5-20251001": { input: 1, output: 5, cacheWrite: 1.25, cacheRead: 0.1 },
1449
+ "claude-haiku-4-5": { input: 1, output: 5, cacheWrite: 1.25, cacheRead: 0.1 },
1450
+ // Legacy Claude 3.x families (prefix fallback handles minor date suffixes)
1451
+ "claude-3-5-sonnet": { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 },
1452
+ "claude-3-5-haiku": { input: 0.8, output: 4, cacheWrite: 1, cacheRead: 0.08 },
1453
+ "claude-3-opus": { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.5 },
1454
+ // ── OpenAI ────────────────────────────────────────────────────
1455
+ "gpt-4o": { input: 2.5, output: 10, cacheRead: 1.25 },
1456
+ "gpt-4o-mini": { input: 0.15, output: 0.6, cacheRead: 0.075 },
1457
+ "gpt-4-turbo": { input: 10, output: 30 },
1458
+ "gpt-4": { input: 30, output: 60 },
1459
+ "gpt-4.1": { input: 2, output: 8, cacheRead: 0.5 },
1460
+ "gpt-4.1-mini": { input: 0.4, output: 1.6, cacheRead: 0.1 },
1461
+ "gpt-4.1-nano": { input: 0.1, output: 0.4, cacheRead: 0.025 },
1462
+ "o1": { input: 15, output: 60, cacheRead: 7.5 },
1463
+ "o1-mini": { input: 3, output: 12, cacheRead: 1.5 },
1464
+ "o3": { input: 10, output: 40, cacheRead: 2.5 },
1465
+ "o3-mini": { input: 1.1, output: 4.4, cacheRead: 0.55 },
1466
+ // ── Google Gemini ─────────────────────────────────────────────
1467
+ "gemini-2.5-pro": { input: 1.25, output: 10 },
1468
+ "gemini-2.5-flash": { input: 0.3, output: 2.5 },
1469
+ "gemini-2.0-flash": { input: 0.1, output: 0.4 },
1470
+ "gemini-1.5-pro": { input: 1.25, output: 5 },
1471
+ "gemini-1.5-flash": { input: 0.075, output: 0.3 },
1472
+ // ── DeepSeek ──────────────────────────────────────────────────
1473
+ // V4 family (2026-04-23+):1M context, Thinking / Non-Thinking dual-mode, 384K max output
1474
+ "deepseek-v4-pro": { input: 1.74, output: 3.48, cacheRead: 0.145 },
1475
+ "deepseek-v4-flash": { input: 0.14, output: 0.28, cacheRead: 0.028 },
1476
+ // Legacy aliases:retires 2026-07-24 UTC 15:59,官方 route 到 V4 Flash
1477
+ "deepseek-chat": { input: 0.14, output: 0.28, cacheRead: 0.028 },
1478
+ "deepseek-reasoner": { input: 0.14, output: 0.28, cacheRead: 0.028 },
1479
+ "deepseek-v3": { input: 0.27, output: 1.1, cacheRead: 0.07 },
1480
+ // ── Moonshot Kimi ─────────────────────────────────────────────
1481
+ "moonshot-v1-8k": { input: 0.17, output: 0.17 },
1482
+ "moonshot-v1-32k": { input: 0.33, output: 0.33 },
1483
+ "moonshot-v1-128k": { input: 0.83, output: 0.83 },
1484
+ "kimi-k2": { input: 0.6, output: 2.5 },
1485
+ "kimi-latest": { input: 0.6, output: 2.5 },
1486
+ // ── Zhipu GLM ─────────────────────────────────────────────────
1487
+ "glm-4-plus": { input: 0.7, output: 0.7 },
1488
+ "glm-4": { input: 0.14, output: 0.14 },
1489
+ "glm-4-flash": { input: 0, output: 0 },
1490
+ "glm-4.5": { input: 0.29, output: 1.14 },
1491
+ "glm-4.6": { input: 0.6, output: 2.2 },
1492
+ "glm-4.6v": { input: 0.6, output: 2.2 },
1493
+ "glm-5": { input: 0.85, output: 2.85 },
1494
+ "glm-5.1": { input: 0.95, output: 3.15 },
1495
+ "glm-5.1-reasoning": { input: 1.4, output: 4.4 },
1496
+ "glm-5.1-air": { input: 0.4, output: 1.2 },
1497
+ "glm-z1": { input: 0.5, output: 1.5 },
1498
+ "glm-z1-air": { input: 0.2, output: 0.6 },
1499
+ "glm-z1-flash": { input: 0, output: 0 }
1500
+ // ── OpenRouter (pass-through — actual cost depends on underlying model) ──
1501
+ // Left empty; callers should resolve via underlying model ID.
1502
+ // ── Ollama (local, zero cost) ─────────────────────────────────
1503
+ // Handled via provider check below.
1504
+ };
1505
+ var FREE_PROVIDERS = /* @__PURE__ */ new Set(["ollama"]);
1506
+ function getPricing(provider, model) {
1507
+ if (FREE_PROVIDERS.has(provider.toLowerCase())) {
1508
+ return { input: 0, output: 0 };
1509
+ }
1510
+ const key = model.toLowerCase();
1511
+ if (PRICING_TABLE[key]) return PRICING_TABLE[key];
1512
+ const keys = Object.keys(PRICING_TABLE).sort((a, b) => b.length - a.length);
1513
+ for (const k of keys) {
1514
+ if (key.startsWith(k)) return PRICING_TABLE[k];
1515
+ }
1516
+ return null;
1517
+ }
1518
+ function computeCost(provider, model, usage) {
1519
+ const p = getPricing(provider, model);
1520
+ if (!p) return null;
1521
+ const input = usage.inputTokens * p.input;
1522
+ const output = usage.outputTokens * p.output;
1523
+ const cacheWrite = (usage.cacheCreationTokens ?? 0) * (p.cacheWrite ?? p.input);
1524
+ const cacheRead = (usage.cacheReadTokens ?? 0) * (p.cacheRead ?? p.input);
1525
+ return (input + output + cacheWrite + cacheRead) / 1e6;
1526
+ }
1527
+ function formatCost(amount) {
1528
+ if (amount === 0) return "$0.0000";
1529
+ if (amount < 0.01) return `$${amount.toFixed(4)}`;
1530
+ if (amount < 1) return `$${amount.toFixed(3)}`;
1531
+ return `$${amount.toFixed(2)}`;
1532
+ }
1533
+
1534
+ // src/session/tool-history.ts
1535
+ var SESSION_SIZE_LIMIT = 2 * 1024 * 1024;
1536
+ function persistToolRound(session, toolCalls, toolResults, opts) {
1537
+ session.addMessage({
1538
+ role: "assistant",
1539
+ content: opts?.assistantContent ?? "",
1540
+ toolCalls,
1541
+ reasoningContent: opts?.reasoningContent,
1542
+ timestamp: /* @__PURE__ */ new Date()
1543
+ });
1544
+ for (let i = 0; i < toolCalls.length; i++) {
1545
+ const tc = toolCalls[i];
1546
+ const tr = toolResults[i];
1547
+ if (!tr) continue;
1548
+ session.addMessage({
1549
+ role: "tool",
1550
+ content: truncateForPersist(tr.content),
1551
+ toolCallId: tr.callId,
1552
+ toolName: tc.name,
1553
+ isError: tr.isError,
1554
+ timestamp: /* @__PURE__ */ new Date()
1555
+ });
1556
+ }
1557
+ }
1558
+ function trimOldToolOutput(messages, keepRecentRounds = 10) {
1559
+ const roundStarts = [];
1560
+ for (let i = 0; i < messages.length; i++) {
1561
+ if (messages[i].role === "assistant" && messages[i].toolCalls?.length) {
1562
+ roundStarts.push(i);
1563
+ }
1564
+ }
1565
+ if (roundStarts.length <= keepRecentRounds) return 0;
1566
+ const cutoffRoundIdx = roundStarts.length - keepRecentRounds;
1567
+ let trimmed = 0;
1568
+ for (let r = 0; r < cutoffRoundIdx; r++) {
1569
+ const start = roundStarts[r];
1570
+ const end = r + 1 < roundStarts.length ? roundStarts[r + 1] : messages.length;
1571
+ const assistantMsg = messages[start];
1572
+ if (typeof assistantMsg.content === "string" && assistantMsg.content.length > 200) {
1573
+ assistantMsg.content = assistantMsg.content.slice(0, 100) + "\u2026 [trimmed for size]";
1574
+ trimmed++;
1575
+ }
1576
+ for (let j = start + 1; j < end; j++) {
1577
+ const msg = messages[j];
1578
+ if (msg.role === "tool") {
1579
+ const status = msg.isError ? "\u2717 error" : "\u2713 ok";
1580
+ const name = msg.toolName ?? "unknown";
1581
+ const currentContent = typeof msg.content === "string" ? msg.content : "";
1582
+ if (currentContent.length > 200) {
1583
+ msg.content = `[${name}: ${status}] (output trimmed for size \u2014 ${currentContent.length} chars)`;
1584
+ trimmed++;
1585
+ }
1586
+ }
1587
+ }
1588
+ }
1589
+ return trimmed;
1590
+ }
1591
+ function cloneMessages(messages) {
1592
+ return messages.map((m) => ({ ...m }));
1593
+ }
1594
+ function autoTrimSessionIfNeeded(session, sizeLimit = SESSION_SIZE_LIMIT) {
1595
+ const originalSize = JSON.stringify(session.toJSON()).length;
1596
+ if (originalSize <= sizeLimit) return false;
1597
+ const snapshotLen = session.messages.length;
1598
+ let working = cloneMessages(session.messages.slice(0, snapshotLen));
1599
+ let committedSize = originalSize;
1600
+ let anyTrim = false;
1601
+ let keepRecent = 10;
1602
+ while (keepRecent >= 2) {
1603
+ const trimmedCount = trimOldToolOutput(working, keepRecent);
1604
+ if (trimmedCount === 0) break;
1605
+ anyTrim = true;
1606
+ const originalMessages = session.messages;
1607
+ session.messages = [...working, ...originalMessages.slice(snapshotLen)];
1608
+ const newSize = JSON.stringify(session.toJSON()).length;
1609
+ session.messages = originalMessages;
1610
+ committedSize = newSize;
1611
+ if (newSize <= sizeLimit) break;
1612
+ keepRecent = Math.max(2, Math.floor(keepRecent / 2));
1613
+ if (keepRecent < 2) break;
1614
+ }
1615
+ if (!anyTrim) return false;
1616
+ const appended = session.messages.slice(snapshotLen);
1617
+ session.messages.splice(0, session.messages.length, ...working, ...appended);
1618
+ return committedSize < originalSize;
1619
+ }
1620
+
1621
+ // src/repl/dev-state.ts
1622
+ import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2, unlinkSync as unlinkSync2, mkdirSync as mkdirSync3 } from "fs";
1623
+ import { join as join3 } from "path";
1624
+ import { homedir } from "os";
1625
+ var DEV_STATE_MAX_CHARS = 6e3;
1626
+ var SNAPSHOT_PROMPT = `You are about to be replaced by a different AI model. Please generate a structured development state snapshot so the next model can continue seamlessly.
1627
+
1628
+ CRITICAL: Be SPECIFIC and DETAILED. Include exact values, file paths, format requirements, and constraints \u2014 vague summaries are useless to the next model.
1629
+
1630
+ Output ONLY the snapshot in the following exact format (no preamble, no explanation):
1631
+
1632
+ ## Development State Snapshot
1633
+
1634
+ ### Current Task
1635
+ [1-2 sentence summary of the primary task/goal the user is working on]
1636
+
1637
+ ### Key Parameters & Constraints
1638
+ - [List ALL specific parameters, values, format requirements discovered during this conversation]
1639
+ - [Include exact numbers, dimensions, scoring rules, naming conventions, etc.]
1640
+ - [Example: "Exam duration 90 minutes, total score 200, 40 single-choice x2 pts + 20 multi-choice x4 pts + 20 true/false x2 pts"]
1641
+ - [Example: "File naming format YYYYMMDD-NN-mock-difficulty.md, saved to exam_papers/ directory"]
1642
+
1643
+ ### Completed Steps
1644
+ - [List each completed step with specific details (file paths, key outcomes)]
1645
+
1646
+ ### In-Progress Work
1647
+ - [List any work that was started but not finished]
1648
+
1649
+ ### Critical Reference Files
1650
+ - [List file paths that the next model MUST read before doing any work]
1651
+ - [Include brief description of each file's purpose]
1652
+ - [Example: "Exam2025.md \u2014 reference format for official past exams (90min/200pts/question type distribution)"]
1653
+ - [Example: "style-guide.md \u2014 style guidelines (character setup/scenario building/current events)"]
1654
+
1655
+ ### Modified/Created Files
1656
+ - [List any files that were created or modified, with brief notes on content]
1657
+
1658
+ ### Key Decisions & Context
1659
+ - [Important decisions, user preferences, or constraints established]
1660
+
1661
+ ### Next Steps
1662
+ - [What should be done next to continue this work]
1663
+ - [Include specific instructions the next model should follow]
1664
+
1665
+ ### Important Notes
1666
+ - [Any warnings, caveats, or critical context the next model needs to know]
1667
+ - [Things that went wrong or should be avoided]
1668
+
1669
+ If any section has no content, write "(none)" for that section. Be thorough \u2014 the next model may have access to our conversation messages, but the detailed tool call results (file contents, command outputs) are NOT preserved. This snapshot is the primary source of specific details and context.`;
1670
+ function sessionHasMeaningfulContent(messages) {
1671
+ if (messages.length < 2) return false;
1672
+ const hasUser = messages.some((m) => m.role === "user");
1673
+ const hasAssistant = messages.some((m) => m.role === "assistant");
1674
+ return hasUser && hasAssistant;
1675
+ }
1676
+ function getDevStatePath() {
1677
+ return join3(homedir(), CONFIG_DIR_NAME, DEV_STATE_FILE_NAME);
1678
+ }
1679
+ function saveDevState(content) {
1680
+ const configDir = join3(homedir(), CONFIG_DIR_NAME);
1681
+ if (!existsSync3(configDir)) {
1682
+ mkdirSync3(configDir, { recursive: true });
1683
+ }
1684
+ let trimmed = content.trim();
1685
+ if (trimmed.length > DEV_STATE_MAX_CHARS) {
1686
+ trimmed = trimmed.slice(0, DEV_STATE_MAX_CHARS);
1687
+ const lastNewline = trimmed.lastIndexOf("\n");
1688
+ if (lastNewline > 0) {
1689
+ trimmed = trimmed.slice(0, lastNewline);
1690
+ }
1691
+ trimmed += "\n\n[...truncated]";
1692
+ }
1693
+ writeFileSync2(getDevStatePath(), trimmed, "utf-8");
1694
+ }
1695
+ function loadDevState() {
1696
+ const path = getDevStatePath();
1697
+ if (!existsSync3(path)) return null;
1698
+ const content = readFileSync3(path, "utf-8").trim();
1699
+ return content || null;
1700
+ }
1701
+ function clearDevState() {
1702
+ const path = getDevStatePath();
1703
+ if (existsSync3(path)) {
1704
+ try {
1705
+ unlinkSync2(path);
1706
+ } catch {
1707
+ }
1708
+ }
1709
+ }
1710
+
1711
+ export {
1712
+ getContentText,
1713
+ SessionManager,
1714
+ getPricing,
1715
+ computeCost,
1716
+ formatCost,
1717
+ parseSimpleYaml,
1718
+ persistToolRound,
1719
+ autoTrimSessionIfNeeded,
1720
+ SNAPSHOT_PROMPT,
1721
+ sessionHasMeaningfulContent,
1722
+ saveDevState,
1723
+ loadDevState,
1724
+ clearDevState,
1725
+ McpManager,
1726
+ SkillManager,
1727
+ setupProxy
1728
+ };