mocode-ai 0.1.0

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,219 @@
1
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { config } from '../config/index.js';
4
+ import { truncateDisplay } from '../ui/render.js';
5
+ let turnIdCounter = 0;
6
+ let currentTurnId = 0;
7
+ let turns = [];
8
+ let snapshots = [];
9
+ const MUTATION_TOOLS = new Set(['write_file', 'edit_file']);
10
+ /** 把任意 content 拍平成字符串(OpenAI 可能是 string / 多模态数组)。 */
11
+ function toText(content) {
12
+ if (content == null)
13
+ return '';
14
+ if (typeof content === 'string')
15
+ return content;
16
+ try {
17
+ return JSON.stringify(content);
18
+ }
19
+ catch {
20
+ return String(content);
21
+ }
22
+ }
23
+ /** 规整成 cwd 相对路径(快照存相对,跨 /resume 同项目可识别;resolve 已归一 ./ 和 ..)。 */
24
+ function toRel(p) {
25
+ try {
26
+ const rel = path.relative(process.cwd(), path.resolve(p));
27
+ return rel === '' ? p : rel;
28
+ }
29
+ catch {
30
+ return p;
31
+ }
32
+ }
33
+ /** agent:runAgent 入口调——开新轮次。firstLine 已由调用方截断到 40。 */
34
+ export function beginTurn(firstLine) {
35
+ turnIdCounter += 1;
36
+ currentTurnId = turnIdCounter;
37
+ turns.push({ turnId: currentTurnId, firstLine });
38
+ }
39
+ /** tools/registry:write_file/edit_file 执行前调——记 before 快照(在 tool.execute 之前读)。 */
40
+ export function recordMutation(p) {
41
+ const rel = toRel(p);
42
+ let before;
43
+ try {
44
+ before = readFileSync(path.resolve(p), 'utf8');
45
+ }
46
+ catch {
47
+ before = null; // 文件不存在(新建)
48
+ }
49
+ snapshots.push({ turnId: currentTurnId, path: rel, before });
50
+ }
51
+ /** 列出当前可回滚的轮次(1-based 序号由调用方显示)。 */
52
+ export function listTurns() {
53
+ return turns.slice();
54
+ }
55
+ /** history 里第 (n+1) 条 user 消息的下标(= 截断点);无则 history.length。 */
56
+ function findCutoffIndex(n, history) {
57
+ let seen = 0;
58
+ for (let i = 0; i < history.length; i++) {
59
+ if (history[i].role === 'user') {
60
+ seen += 1;
61
+ if (seen === n + 1)
62
+ return i;
63
+ }
64
+ }
65
+ return history.length;
66
+ }
67
+ /**
68
+ * 规划回滚到第 n 轮(1-based):算截断点 + 被删轮次里涉及的文件改动(按 path 去重)。
69
+ * 调用方据 changes 逐 path 问保留 / 撤销;snapshotAvailable=false 的项无法撤销。
70
+ */
71
+ export function planRollback(n, history) {
72
+ const cutoffTurnId = turns[n - 1]?.turnId ?? 0;
73
+ const cutoffIndex = findCutoffIndex(n, history);
74
+ const order = [];
75
+ const map = new Map();
76
+ for (let i = cutoffIndex; i < history.length; i++) {
77
+ const tcs = history[i].tool_calls;
78
+ if (!Array.isArray(tcs))
79
+ continue;
80
+ for (const tc of tcs) {
81
+ const name = tc?.function?.name ?? '';
82
+ if (!MUTATION_TOOLS.has(name))
83
+ continue;
84
+ const argRaw = tc?.function?.arguments ?? '';
85
+ let p = '';
86
+ try {
87
+ p = String(JSON.parse(argRaw).path ?? '');
88
+ }
89
+ catch {
90
+ p = '';
91
+ }
92
+ if (!p)
93
+ continue;
94
+ const rel = toRel(p);
95
+ let fc = map.get(rel);
96
+ if (!fc) {
97
+ fc = { path: rel, ops: [], snapshotAvailable: false };
98
+ map.set(rel, fc);
99
+ order.push(rel);
100
+ }
101
+ fc.ops.push(name);
102
+ }
103
+ }
104
+ const changes = order.map((rel) => {
105
+ const fc = map.get(rel);
106
+ fc.snapshotAvailable = snapshots.some((s) => s.turnId > cutoffTurnId && s.path === rel);
107
+ return fc;
108
+ });
109
+ return { n, cutoffIndex, cutoffTurnId, changes };
110
+ }
111
+ /**
112
+ * 执行回滚:原地截断 history 到 cutoffIndex + 按选择恢复文件 + 裁剪 turns/snapshots。
113
+ * revertPaths 为选「撤销」的相对路径集合。返回删除消息数 + 撤销文件列表。
114
+ */
115
+ export function applyRollback(plan, history, revertPaths) {
116
+ const deletedMsgs = history.length - plan.cutoffIndex;
117
+ history.length = plan.cutoffIndex; // 原地截断(保 history[0] system + 可能的 index-1 摘要)
118
+ const revertedFiles = [];
119
+ for (const rel of revertPaths) {
120
+ // turnId > cutoff 的最小 turnId 快照 = 所选轮末状态
121
+ let pick = null;
122
+ for (const s of snapshots) {
123
+ if (s.path !== rel)
124
+ continue;
125
+ if (s.turnId <= plan.cutoffTurnId)
126
+ continue;
127
+ if (!pick || s.turnId < pick.turnId)
128
+ pick = s;
129
+ }
130
+ if (!pick)
131
+ continue; // 无快照(不应发生:repl 已据 snapshotAvailable 过滤)
132
+ const full = path.resolve(rel);
133
+ try {
134
+ if (pick.before === null) {
135
+ unlinkSync(full);
136
+ }
137
+ else {
138
+ writeFileSync(full, pick.before, 'utf8');
139
+ }
140
+ revertedFiles.push(rel);
141
+ }
142
+ catch {
143
+ // 恢复失败不阻断回滚(文件可能被外部删 / 锁)
144
+ }
145
+ }
146
+ // 裁剪:保 turnId ≤ cutoff(删掉被回滚掉的轮次及其快照)
147
+ turns = turns.filter((t) => t.turnId <= plan.cutoffTurnId);
148
+ snapshots = snapshots.filter((s) => s.turnId <= plan.cutoffTurnId);
149
+ return { deletedMsgs, revertedFiles };
150
+ }
151
+ /** compact 摘要成功后调:按存活轮次数裁剪(M = 新 history 里 user 消息数)。 */
152
+ export function pruneAfterCompaction(history) {
153
+ const m = history.filter((msg) => msg.role === 'user').length;
154
+ turns = m >= turns.length ? turns : turns.slice(-m);
155
+ const alive = new Set(turns.map((t) => t.turnId));
156
+ snapshots = snapshots.filter((s) => alive.has(s.turnId));
157
+ }
158
+ /** /clear 调:清空全部状态。 */
159
+ export function resetState() {
160
+ turns = [];
161
+ snapshots = [];
162
+ turnIdCounter = 0;
163
+ currentTurnId = 0;
164
+ }
165
+ /**
166
+ * 无 snapshots 文件时(/resume 旧会话)从 history 重建 turns(扫 user 消息,1..M,
167
+ * 无快照 → 那些轮次的文件改动不可撤销)。turnIdCounter = M,后续新轮次从 M+1 续。
168
+ */
169
+ export function rebuildFromHistory(history) {
170
+ const out = [];
171
+ for (let i = 0; i < history.length; i++) {
172
+ if (history[i].role !== 'user')
173
+ continue;
174
+ const first = toText(history[i].content).split('\n')[0] ?? '';
175
+ out.push({ turnId: out.length + 1, firstLine: truncateDisplay(first, 40) });
176
+ }
177
+ turns = out;
178
+ snapshots = [];
179
+ turnIdCounter = out.length;
180
+ currentTurnId = 0;
181
+ }
182
+ function snapshotsPath(id) {
183
+ return path.join(config.sessionDir, `${id}.snapshots.json`);
184
+ }
185
+ /** 随 saveSession 调:把 turns + snapshots 落盘(turns 为空则跳过,不写空文件)。 */
186
+ export function persistSnapshots(id) {
187
+ if (turns.length === 0)
188
+ return;
189
+ try {
190
+ mkdirSync(config.sessionDir, { recursive: true });
191
+ writeFileSync(snapshotsPath(id), JSON.stringify({ version: 1, turns, snapshots }), 'utf8');
192
+ }
193
+ catch {
194
+ // 落盘失败不阻断(回滚仅失去跨重启能力)
195
+ }
196
+ }
197
+ /**
198
+ * /resume / --resume 加载会话后调:读回 turns + snapshots。成功返 true(状态已覆盖);
199
+ * 失败 / 无文件返 false,调用方应改调 rebuildFromHistory(history) 兜底。
200
+ */
201
+ export function loadSnapshots(id) {
202
+ const p = snapshotsPath(id);
203
+ if (!existsSync(p))
204
+ return false;
205
+ try {
206
+ const rec = JSON.parse(readFileSync(p, 'utf8'));
207
+ if (!rec || !Array.isArray(rec.turns) || !Array.isArray(rec.snapshots)) {
208
+ return false;
209
+ }
210
+ turns = rec.turns;
211
+ snapshots = rec.snapshots;
212
+ turnIdCounter = turns.reduce((mx, t) => Math.max(mx, t.turnId), 0);
213
+ currentTurnId = 0;
214
+ return true;
215
+ }
216
+ catch {
217
+ return false;
218
+ }
219
+ }
@@ -0,0 +1,277 @@
1
+ import { chat, estimateMessagesTokens, estimateToolSchemaTokens, estimateTokens, } from '../llm/index.js';
2
+ import { config } from '../config/index.js';
3
+ import { MAX_HISTORY_RESULT, MAX_OLD_TOOL_STUB, MAX_SKILL_RESULT } from '../tools/constants.js';
4
+ import { ui } from '../ui/theme.js';
5
+ import { Spinner } from '../ui/spinner.js';
6
+ import * as layout from '../ui/layout.js';
7
+ import { pruneAfterCompaction } from '../rollback/index.js';
8
+ /** 跨模块共享的上下文状态:agent 写 lastUsage,compact 写 lastEstimate,repl 的 /context 读。 */
9
+ export const contextState = {
10
+ lastEstimate: 0,
11
+ };
12
+ /** 中截:text 太长时保 head + 标记 + tail,总长 ≤ max。 */
13
+ export function truncateMid(text, max) {
14
+ if (text.length <= max)
15
+ return text;
16
+ const removed = text.length - max;
17
+ const marker = `…[已截断 ${removed} 字符]…`;
18
+ let remain = max - marker.length;
19
+ if (remain <= 0)
20
+ return marker.slice(0, Math.max(0, max));
21
+ const head = Math.ceil(remain * 0.6);
22
+ const tail = remain - head;
23
+ const out = text.slice(0, head) + marker + text.slice(text.length - tail);
24
+ return out.length > max ? out.slice(0, max) : out;
25
+ }
26
+ /**
27
+ * push-time 第一层:工具结果进 history 前裁到 MAX_HISTORY_RESULT。
28
+ * 显示层(summarizeToolResult)仍用原 output,不受影响。
29
+ */
30
+ export function capToolResultForHistory(name, output) {
31
+ if (name === 'use_skill') {
32
+ // skill 正文是指令,须尽量完整;超长才截断。用尾截(保头部指令、弃尾部),
33
+ // 不用 truncateMid 的中截——中截会劈断指令流、丢失开头步骤。
34
+ if (output.length <= MAX_SKILL_RESULT)
35
+ return output;
36
+ const removed = output.length - MAX_SKILL_RESULT;
37
+ const marker = `…[skill 正文过长,已截断尾部 ${removed} 字符]…`;
38
+ const remain = MAX_SKILL_RESULT - marker.length;
39
+ if (remain <= 0)
40
+ return marker.slice(0, MAX_SKILL_RESULT);
41
+ return output.slice(0, remain) + marker;
42
+ }
43
+ if (output.length <= MAX_HISTORY_RESULT)
44
+ return output;
45
+ return truncateMid(output, MAX_HISTORY_RESULT);
46
+ }
47
+ // ── 内部:消息内容拍平 / group 划分 ──────────────────────────────────────
48
+ function toText(content) {
49
+ if (content == null)
50
+ return '';
51
+ if (typeof content === 'string')
52
+ return content;
53
+ try {
54
+ return JSON.stringify(content);
55
+ }
56
+ catch {
57
+ return String(content);
58
+ }
59
+ }
60
+ /** 从尾向头划分 group;history[0](system)排除。连续 tool 归到前导 assistant。 */
61
+ function groupFromEnd(history) {
62
+ const groups = [];
63
+ let i = history.length - 1;
64
+ while (i >= 1) {
65
+ const m = history[i];
66
+ if (m.role === 'tool') {
67
+ const tools = [];
68
+ while (i >= 1 && history[i].role === 'tool') {
69
+ tools.unshift(history[i]);
70
+ i--;
71
+ }
72
+ if (i >= 1 &&
73
+ history[i].role === 'assistant' &&
74
+ history[i].tool_calls) {
75
+ groups.unshift({ assistant: history[i], tools });
76
+ i--;
77
+ }
78
+ else {
79
+ // 孤儿 tool(正常不应出现):各自成组,不丢
80
+ for (const t of tools)
81
+ groups.unshift({ assistant: null, tools: [t] });
82
+ }
83
+ }
84
+ else {
85
+ groups.unshift({ assistant: m, tools: [] });
86
+ i--;
87
+ }
88
+ }
89
+ return groups;
90
+ }
91
+ function groupTokens(g) {
92
+ let t = 0;
93
+ if (g.assistant) {
94
+ const body = toText(g.assistant.content);
95
+ const tcs = g.assistant.tool_calls;
96
+ let extra = body;
97
+ if (tcs)
98
+ for (const tc of tcs)
99
+ extra += tc?.function?.arguments ?? '';
100
+ t += 4 + estimateTokens(extra);
101
+ }
102
+ for (const tool of g.tools) {
103
+ t += 6 + estimateTokens(toText(tool.content));
104
+ }
105
+ return t;
106
+ }
107
+ function flattenGroups(groups) {
108
+ const out = [];
109
+ for (const g of groups) {
110
+ if (g.assistant)
111
+ out.push(g.assistant);
112
+ for (const t of g.tools)
113
+ out.push(t);
114
+ }
115
+ return out;
116
+ }
117
+ // ── 默认摘要器:复用 chat(),空 handlers 不打印 ──────────────────────────
118
+ async function defaultSummarize(older, focus) {
119
+ let transcript = older
120
+ .map((m) => {
121
+ const role = m.role;
122
+ let line = `${role}: ${toText(m.content)}`;
123
+ const tcs = m.tool_calls;
124
+ if (tcs) {
125
+ for (const tc of tcs) {
126
+ line += `\n [tool_call ${tc?.function?.name}] ${tc?.function?.arguments ?? ''}`;
127
+ }
128
+ }
129
+ return line;
130
+ })
131
+ .join('\n');
132
+ // 防摘要提示本身溢出:超 60% 窗口就先中截到 50%
133
+ if (estimateTokens(transcript) >
134
+ Math.floor(config.contextWindowTokens * 0.6)) {
135
+ transcript = truncateMid(transcript, Math.floor(config.contextWindowTokens * 0.5));
136
+ }
137
+ const sysMsg = {
138
+ role: 'system',
139
+ content: '你是会话摘要器。只输出摘要正文,不超过 300 字,保留:用户核心请求、已读写/改动的文件及关键变更、执行过的关键命令及结果要点、已做决策、当前任务进度与下一步、未决问题。不要复述全部细节。',
140
+ };
141
+ const userMsg = {
142
+ role: 'user',
143
+ content: focus
144
+ ? `请将以下会话历史压缩成摘要,重点保留与「${focus}」相关的事实/决策/文件改动:\n\n${transcript}\n\n摘要:`
145
+ : `请将以下会话历史压缩成摘要:\n\n${transcript}\n\n摘要:`,
146
+ };
147
+ const spinner = new Spinner((msg, frame) => layout.setStatus(msg, frame ?? undefined));
148
+ spinner.start('压缩中');
149
+ try {
150
+ const r = await chat([sysMsg, userMsg], {}); // 空 handlers:不打印、不外显流式
151
+ // 推理模型可能只返 reasoning_content(content 为 null),或幻觉出 tool_calls → 视为失败
152
+ if (r.toolCalls.length > 0 || !r.content)
153
+ return null;
154
+ return r.content;
155
+ }
156
+ finally {
157
+ spinner.stop();
158
+ }
159
+ }
160
+ // ── 对外:compactHistory / maybeCompact ───────────────────────────────────
161
+ /**
162
+ * 压缩 history(原地)。手动 /compact 与自动 maybeCompact 都走这里。
163
+ * 不检查阈值——调用方(maybeCompact)决定是否调;/compact 直接调以强制压缩。
164
+ */
165
+ export async function compactHistory(history, opts) {
166
+ const schemaTokens = estimateToolSchemaTokens();
167
+ const estimateBefore = estimateMessagesTokens(history) + schemaTokens;
168
+ contextState.lastEstimate = estimateBefore;
169
+ const groups = groupFromEnd(history);
170
+ // 保近期:从尾向前累积直到预算花完(至少保 1 组),永不劈开 group。
171
+ const keepBudget = Math.floor(opts.window * 0.4);
172
+ const kept = [];
173
+ let keptTokens = 0;
174
+ for (let k = groups.length - 1; k >= 0; k--) {
175
+ const g = groups[k];
176
+ if (kept.length >= 1 && keptTokens + groupTokens(g) > keepBudget)
177
+ break;
178
+ kept.unshift(g);
179
+ keptTokens += groupTokens(g);
180
+ }
181
+ const oldGroups = groups.slice(0, groups.length - kept.length);
182
+ const noop = {
183
+ compacted: false,
184
+ summarized: false,
185
+ estimateBefore,
186
+ estimateAfter: estimateBefore,
187
+ reason: 'noop',
188
+ };
189
+ if (oldGroups.length === 0) {
190
+ // 没有旧区可压缩
191
+ if (estimateBefore >= opts.threshold * opts.window) {
192
+ layout.contentWrite(` ${ui.yellow}●${ui.reset} ${ui.yellow}上下文已超阈但无可压缩项,建议 /clear 或缩短输入。${ui.reset}\n`);
193
+ return { ...noop, reason: 'too-large' };
194
+ }
195
+ return noop;
196
+ }
197
+ // 第一层:微压缩——旧区 tool 结果原地截短(保 tool_call_id,无 LLM 调用)
198
+ let microcompactDone = false;
199
+ for (const g of oldGroups) {
200
+ for (const t of g.tools) {
201
+ const c = t.content;
202
+ if (typeof c === 'string' && c.length > MAX_OLD_TOOL_STUB) {
203
+ t.content = truncateMid(c, MAX_OLD_TOOL_STUB);
204
+ microcompactDone = true;
205
+ }
206
+ }
207
+ }
208
+ // 第二层:摘要——把旧区(微压缩后)压成一条 system 摘要
209
+ const older = flattenGroups(oldGroups);
210
+ const summarizeFn = opts.summarize ?? defaultSummarize;
211
+ let summary = null;
212
+ try {
213
+ summary = await summarizeFn(older, opts.focus);
214
+ }
215
+ catch {
216
+ summary = null; // 摘要失败 → 回退仅微压缩,不崩
217
+ }
218
+ if (summary) {
219
+ const summaryMsg = {
220
+ role: 'system',
221
+ content: `# 会话摘要\n${summary}`,
222
+ };
223
+ // 原地重建:[systemPrompt, summaryMsg, ...kept]
224
+ const systemMsg = history[0];
225
+ const rebuilt = [systemMsg, summaryMsg, ...flattenGroups(kept)];
226
+ history.length = 0;
227
+ history.push(...rebuilt);
228
+ pruneAfterCompaction(history); // 摘要删了旧轮次 → 按存活轮次裁剪回滚日志
229
+ const estimateAfter = estimateMessagesTokens(history) + schemaTokens;
230
+ contextState.lastEstimate = estimateAfter;
231
+ contextState.lastUsage = undefined; // 压缩后旧 usage 失效,/context 改用估算
232
+ layout.contentWrite(` ${ui.brightMagenta}●${ui.reset} ${ui.cyan}压缩上下文${ui.reset} ${ui.dim}${estimateBefore} → ${estimateAfter} tokens${ui.reset}\n`);
233
+ // 抖动保护:压缩后仍超阈 → 提示 /clear,不死循环
234
+ if (estimateAfter >= opts.threshold * opts.window) {
235
+ layout.contentWrite(` ${ui.yellow}●${ui.reset} ${ui.yellow}压缩后仍超阈,可能存在超大单条;建议 /clear。${ui.reset}\n`);
236
+ }
237
+ return {
238
+ compacted: true,
239
+ summarized: true,
240
+ estimateBefore,
241
+ estimateAfter,
242
+ reason: 'summarize',
243
+ };
244
+ }
245
+ // 摘要失败:回退仅微压缩(tool content 已原地改),结构不动
246
+ const estimateAfter = estimateMessagesTokens(history) + schemaTokens;
247
+ contextState.lastEstimate = estimateAfter;
248
+ contextState.lastUsage = undefined; // 结构虽未变,但 token 数已变,旧 usage 失效
249
+ if (microcompactDone) {
250
+ layout.contentWrite(` ${ui.brightMagenta}●${ui.reset} ${ui.cyan}微压缩旧工具结果${ui.reset} ${ui.dim}${estimateBefore} → ${estimateAfter} tokens${ui.reset}\n`);
251
+ return {
252
+ compacted: true,
253
+ summarized: false,
254
+ estimateBefore,
255
+ estimateAfter,
256
+ reason: 'microcompact',
257
+ };
258
+ }
259
+ return noop;
260
+ }
261
+ /**
262
+ * 自动压缩门槛:agent 每步调 chat() 前调用。
263
+ * 用全量启发式估算(始终可用、安全侧、无 stale-usage 问题);超阈则压缩。
264
+ */
265
+ export async function maybeCompact(history) {
266
+ const schemaTokens = estimateToolSchemaTokens();
267
+ const est = estimateMessagesTokens(history) + schemaTokens;
268
+ contextState.lastEstimate = est;
269
+ if (!config.autoCompact)
270
+ return;
271
+ if (est < config.compactThreshold * config.contextWindowTokens)
272
+ return;
273
+ await compactHistory(history, {
274
+ window: config.contextWindowTokens,
275
+ threshold: config.compactThreshold,
276
+ });
277
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * session/ 入口:上下文压缩 + 会话落盘。
3
+ * - compact.ts:三层压缩(push-time 上限 / 微压缩 / 摘要)+ 自动门槛
4
+ * - persist.ts:history 序列化到磁盘 + --resume / /resume
5
+ * 依赖方向:session → {llm(摘要复用 chat), config, ui};llm 不反向依赖 session。
6
+ */
7
+ export { compactHistory, maybeCompact, capToolResultForHistory, truncateMid, contextState, } from './compact.js';
8
+ export { newSessionId, saveSession, loadSession, listSessions, sessionDir, } from './persist.js';
@@ -0,0 +1,109 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { config } from '../config/index.js';
4
+ import { truncateDisplay } from '../ui/render.js';
5
+ /** 会话目录(确保存在)。 */
6
+ export function sessionDir() {
7
+ mkdirSync(config.sessionDir, { recursive: true });
8
+ return config.sessionDir;
9
+ }
10
+ /** 新会话 id:YYYYMMDD-HHmmss(运行时 Date 可用)。 */
11
+ export function newSessionId() {
12
+ const d = new Date();
13
+ const p = (n) => String(n).padStart(2, '0');
14
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
15
+ }
16
+ /** id(YYYYMMDD-HHmmss)→ ISO 字符串,稳定可排序。解析失败回退原 id。 */
17
+ function idToIso(id) {
18
+ const m = /^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/.exec(id);
19
+ if (!m)
20
+ return id;
21
+ return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}`;
22
+ }
23
+ function toText(content) {
24
+ if (content == null)
25
+ return '';
26
+ if (typeof content === 'string')
27
+ return content;
28
+ try {
29
+ return JSON.stringify(content);
30
+ }
31
+ catch {
32
+ return String(content);
33
+ }
34
+ }
35
+ function firstUserOf(history) {
36
+ for (const m of history) {
37
+ if (m.role === 'user') {
38
+ const text = toText(m.content).replace(/\n/g, ' ').trim();
39
+ return truncateDisplay(text, 40);
40
+ }
41
+ }
42
+ return '';
43
+ }
44
+ function sessionPath(id) {
45
+ return path.join(config.sessionDir, `${id}.json`);
46
+ }
47
+ /** 保存会话到磁盘(history.length<=1 时跳过写盘,只返 meta)。 */
48
+ export function saveSession(history, id) {
49
+ const meta = {
50
+ id,
51
+ createdAt: idToIso(id),
52
+ model: config.model,
53
+ firstUser: history.length > 1 ? firstUserOf(history) : '',
54
+ };
55
+ if (history.length <= 1)
56
+ return meta; // 仅 system,不落盘
57
+ sessionDir();
58
+ const record = { ...meta, history };
59
+ writeFileSync(sessionPath(id), JSON.stringify(record), 'utf8');
60
+ return meta;
61
+ }
62
+ /** 加载会话;不存在 / 损坏返 null(不抛)。 */
63
+ export function loadSession(id) {
64
+ const p = sessionPath(id);
65
+ if (!existsSync(p))
66
+ return null;
67
+ try {
68
+ const raw = readFileSync(p, 'utf8');
69
+ const rec = JSON.parse(raw);
70
+ if (!rec || !Array.isArray(rec.history))
71
+ return null;
72
+ return {
73
+ id: rec.id,
74
+ createdAt: rec.createdAt ?? idToIso(rec.id ?? id),
75
+ model: rec.model ?? '',
76
+ firstUser: rec.firstUser ?? '',
77
+ history: rec.history,
78
+ };
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
84
+ /** 列出所有会话,按 createdAt 降序。损坏文件跳过。 */
85
+ export function listSessions() {
86
+ if (!existsSync(config.sessionDir))
87
+ return [];
88
+ const out = [];
89
+ for (const f of readdirSync(config.sessionDir)) {
90
+ if (!f.endsWith('.json'))
91
+ continue;
92
+ try {
93
+ const rec = JSON.parse(readFileSync(path.join(config.sessionDir, f), 'utf8'));
94
+ if (rec && typeof rec.id === 'string') {
95
+ out.push({
96
+ id: rec.id,
97
+ createdAt: rec.createdAt ?? idToIso(rec.id),
98
+ model: rec.model ?? '',
99
+ firstUser: rec.firstUser ?? '',
100
+ });
101
+ }
102
+ }
103
+ catch {
104
+ // 跳过损坏文件
105
+ }
106
+ }
107
+ out.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
108
+ return out;
109
+ }