@jpssff/vanor 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.
Files changed (45) hide show
  1. package/README-cn.md +166 -0
  2. package/README.md +120 -0
  3. package/base/config.js +162 -0
  4. package/base/core/compaction.js +58 -0
  5. package/base/core/harness.js +246 -0
  6. package/base/core/loop.js +72 -0
  7. package/base/core/prompt.js +126 -0
  8. package/base/core/session.js +255 -0
  9. package/base/events.js +54 -0
  10. package/base/i18n/index.js +80 -0
  11. package/base/i18n/locales/en.js +254 -0
  12. package/base/i18n/locales/zh-CN.js +252 -0
  13. package/base/llm/index.js +119 -0
  14. package/base/llm/providers/anthropic.js +147 -0
  15. package/base/llm/providers/openai.js +155 -0
  16. package/base/llm/sse.js +27 -0
  17. package/base/llm/trace.js +64 -0
  18. package/base/logger.js +57 -0
  19. package/base/memory/index.js +139 -0
  20. package/base/security/index.js +77 -0
  21. package/base/skills/loader.js +297 -0
  22. package/base/test/cli.test.js +91 -0
  23. package/base/test/config.test.js +63 -0
  24. package/base/test/core.test.js +154 -0
  25. package/base/test/i18n.test.js +32 -0
  26. package/base/test/loop.test.js +97 -0
  27. package/base/test/memory.test.js +47 -0
  28. package/base/test/message.test.js +38 -0
  29. package/base/test/session.test.js +324 -0
  30. package/base/test/skills.test.js +236 -0
  31. package/base/test/statusbar.test.js +143 -0
  32. package/base/test/tools.test.js +127 -0
  33. package/base/test/trace.test.js +62 -0
  34. package/base/test/tui.test.js +242 -0
  35. package/base/test/utils.test.js +35 -0
  36. package/base/tools/builtin.js +221 -0
  37. package/base/tools/index.js +157 -0
  38. package/base/transport/cli.js +417 -0
  39. package/base/transport/message.js +81 -0
  40. package/base/transport/statusbar.js +117 -0
  41. package/base/transport/tui.js +397 -0
  42. package/base/utils.js +150 -0
  43. package/docs/TECH_DESIGN.md +544 -0
  44. package/index.js +175 -0
  45. package/package.json +33 -0
@@ -0,0 +1,246 @@
1
+ // AgentHarness:编排会话、phase、配置快照,串联 LLM / 工具 / 记忆 / 技能。
2
+
3
+ import fs from "node:fs";
4
+ import { createLLM } from "../llm/index.js";
5
+ import { createSecurity } from "../security/index.js";
6
+ import { createMemory } from "../memory/index.js";
7
+ import { loadConfig, saveUiLanguage, startupIssues } from "../config.js";
8
+ import { createI18n, isSupportedLanguageSetting } from "../i18n/index.js";
9
+ import { EVENTS } from "../events.js";
10
+ import { createSkillsTool, loadSkills, defaultSkillDirs } from "../skills/loader.js";
11
+ import { defaultRegistry, createToolContext } from "../tools/index.js";
12
+ import { userMessage } from "../transport/message.js";
13
+ import { buildSystemPrompt } from "./prompt.js";
14
+ import { latestSession, saveLatestSessionId, Session } from "./session.js";
15
+ import { runLoop } from "./loop.js";
16
+ import { compact } from "./compaction.js";
17
+
18
+ export function createHarness({ config, paths, logger, channel = "cli", sessionId, restoreLatest = false }) {
19
+ let configState = config;
20
+ let i18n = createI18n(configState);
21
+ let security = createSecurity({ ...configState, i18n });
22
+ let llm = createLLM(configState, logger);
23
+ let memory = createMemory(paths, configState, logger);
24
+ let registry;
25
+ let skills = [];
26
+ let configMtime = configFileMtime();
27
+
28
+ function configFileMtime() {
29
+ try {
30
+ return fs.statSync(paths.config).mtimeMs;
31
+ } catch {
32
+ return 0;
33
+ }
34
+ }
35
+
36
+ function skillDirs() {
37
+ return defaultSkillDirs(paths, security.workspaceRoot);
38
+ }
39
+ function reloadSkills() {
40
+ skills = loadSkills(skillDirs(), logger);
41
+ return skills;
42
+ }
43
+
44
+ function rebuildRuntime(nextConfig) {
45
+ configState = nextConfig;
46
+ i18n = createI18n(configState);
47
+ security = createSecurity({ ...configState, i18n });
48
+ llm = createLLM(configState, logger);
49
+ memory = createMemory(paths, configState, logger);
50
+ registry = defaultRegistry(i18n);
51
+ registry.register(memory.tool);
52
+ registry.register(createSkillsTool(paths, logger, { getSkills: () => skills, reloadSkills, getI18n: () => i18n }));
53
+ reloadSkills();
54
+ }
55
+
56
+ rebuildRuntime(configState);
57
+
58
+ let model = configState.llm.defaultModel;
59
+ let phase = "idle";
60
+ let restored = false;
61
+ let requestedSessionId = sessionId;
62
+ const sessionPaths = () => ({ ...paths, workspaceRoot: security.workspaceRoot });
63
+ if (!requestedSessionId && restoreLatest) requestedSessionId = latestSession(paths, security.workspaceRoot)?.id;
64
+
65
+ let session = requestedSessionId ? Session.load(sessionPaths(), logger, requestedSessionId) : null;
66
+ if (session) {
67
+ restored = true;
68
+ if (!session.model) session.model = model;
69
+ model = session.model || model;
70
+ session.resume();
71
+ } else {
72
+ session = new Session(sessionPaths(), logger, { model }).start();
73
+ }
74
+
75
+ function reloadConfig({ force = false } = {}) {
76
+ const mtime = configFileMtime();
77
+ if (!force && mtime === configMtime) return { reloaded: false, reason: "unchanged" };
78
+
79
+ const { config: nextConfig } = loadConfig(paths.config);
80
+ const nextI18n = createI18n(nextConfig);
81
+ const issues = startupIssues(nextConfig, nextI18n.t);
82
+ if (issues.length) {
83
+ return { reloaded: false, error: nextI18n.t("config.errors.invalidConfig", { issues: issues.join("; ") }), issues };
84
+ }
85
+
86
+ const previousDefault = configState.llm?.defaultModel;
87
+ const nextDefault = nextConfig.llm?.defaultModel;
88
+ rebuildRuntime(nextConfig);
89
+ configMtime = mtime;
90
+
91
+ let nextModel = model;
92
+ if (!nextModel || nextModel === previousDefault) nextModel = nextDefault;
93
+ try {
94
+ llm.resolveModel(nextModel);
95
+ } catch {
96
+ nextModel = nextDefault;
97
+ }
98
+ if (nextModel && nextModel !== model) {
99
+ model = nextModel;
100
+ session.setModel(model);
101
+ }
102
+ logger?.info?.(EVENTS.config.change, { model, configPath: paths.config, hotReload: true });
103
+ return { reloaded: true, model, config: configState, i18n };
104
+ }
105
+
106
+ function buildSystem() {
107
+ reloadSkills();
108
+ return buildSystemPrompt({
109
+ memory,
110
+ skills,
111
+ summary: session.summary,
112
+ workspaceRoot: security.workspaceRoot,
113
+ channel,
114
+ i18n,
115
+ instance: {
116
+ model,
117
+ provider: model.includes("/") ? model.slice(0, model.indexOf("/")) : "",
118
+ language: i18n.language,
119
+ languageSource: i18n.source,
120
+ languageConfigured: i18n.configured,
121
+ configPath: paths.config,
122
+ paths,
123
+ sessionId: session.id,
124
+ sessionRestored: restored,
125
+ approval: security.approval,
126
+ skillDirs: skillDirs(),
127
+ skillsCount: skills.length,
128
+ },
129
+ });
130
+ }
131
+
132
+ /**
133
+ * 运行一轮对话。
134
+ * @param {string} userText
135
+ * @param {object} handlers { onText, onAssistant, onToolCalls, onToolResult, onUsage, onError, confirm }
136
+ * @param {AbortSignal} [signal]
137
+ */
138
+ async function runTurn(userText, handlers = {}, signal) {
139
+ if (phase !== "idle") throw new Error(i18n.t("harness.busy", { phase }));
140
+ phase = "turn";
141
+ try {
142
+ const reload = reloadConfig();
143
+ if (reload.error) handlers.onError?.(new Error(reload.error));
144
+ session.addMessage(userMessage(userText));
145
+ const toolCtx = createToolContext({
146
+ security,
147
+ logger,
148
+ signal,
149
+ confirm: handlers.confirm,
150
+ i18n,
151
+ });
152
+ const deps = {
153
+ llm,
154
+ registry,
155
+ toolCtx,
156
+ config: configState,
157
+ model,
158
+ tools: registry.schemas(),
159
+ buildSystem,
160
+ logger,
161
+ };
162
+ return await runLoop(session, deps, handlers, signal);
163
+ } finally {
164
+ phase = "idle";
165
+ }
166
+ }
167
+
168
+ function newSession() {
169
+ session = new Session(sessionPaths(), logger, { model }).start();
170
+ restored = false;
171
+ return session;
172
+ }
173
+
174
+ function setModel(m) {
175
+ llm.resolveModel(m); // 非法则抛错
176
+ model = m;
177
+ session.setModel(model);
178
+ return model;
179
+ }
180
+
181
+ function setLanguage(language) {
182
+ if (!isSupportedLanguageSetting(language)) {
183
+ throw new Error(i18n.t("cli.language.invalid", { language }));
184
+ }
185
+ saveUiLanguage(paths.config, language);
186
+ const result = reloadConfig({ force: true });
187
+ if (result.error) throw new Error(result.error);
188
+ return i18n;
189
+ }
190
+
191
+ async function compactNow() {
192
+ await compact(session, { llm, model, logger });
193
+ }
194
+
195
+ function saveState() {
196
+ saveLatestSessionId(paths, session.id, security.workspaceRoot);
197
+ }
198
+
199
+ return {
200
+ runTurn,
201
+ newSession,
202
+ setModel,
203
+ setLanguage,
204
+ compactNow,
205
+ saveState,
206
+ reloadConfig,
207
+ reloadSkills,
208
+ get model() {
209
+ return model;
210
+ },
211
+ get phase() {
212
+ return phase;
213
+ },
214
+ get session() {
215
+ return session;
216
+ },
217
+ get sessionRestored() {
218
+ return restored;
219
+ },
220
+ get memory() {
221
+ return memory;
222
+ },
223
+ get skills() {
224
+ return skills;
225
+ },
226
+ get registry() {
227
+ return registry;
228
+ },
229
+ get security() {
230
+ return security;
231
+ },
232
+ get i18n() {
233
+ return i18n;
234
+ },
235
+ get language() {
236
+ return i18n.language;
237
+ },
238
+ get llm() {
239
+ return llm;
240
+ },
241
+ get config() {
242
+ return configState;
243
+ },
244
+ paths,
245
+ };
246
+ }
@@ -0,0 +1,72 @@
1
+ // AgentLoop:单轮内 LLM ↔ 工具的迭代循环。
2
+
3
+ import { EVENTS } from "../events.js";
4
+ import { assistantMessage } from "../transport/message.js";
5
+ import { buildMessages } from "./prompt.js";
6
+ import { runToolCalls } from "../tools/index.js";
7
+ import { shouldCompact, compact } from "./compaction.js";
8
+
9
+ /**
10
+ * 运行一轮对话直至无工具调用 / 达上限 / 中断 / 出错。
11
+ * @param {import('./session.js').Session} session
12
+ * @param {object} deps { llm, registry, toolCtx, config, model, tools, buildSystem, logger }
13
+ * @param {object} handlers { onText, onAssistant, onToolCalls, onToolResult, onUsage, onError }
14
+ * @param {AbortSignal} [signal]
15
+ * @returns {Promise<{assistant?, aborted?, error?, maxIterations?}>}
16
+ */
17
+ export async function runLoop(session, deps, handlers = {}, signal) {
18
+ const { llm, registry, toolCtx, config, model, tools, buildSystem, logger } = deps;
19
+ const maxIter = config.agent?.maxIterations ?? 25;
20
+ logger.info(EVENTS.agent.turnStart, { model, sessionId: session.id });
21
+
22
+ for (let i = 0; i < maxIter; i++) {
23
+ if (signal?.aborted) {
24
+ logger.info(EVENTS.agent.turnAborted, { iteration: i });
25
+ return { aborted: true };
26
+ }
27
+
28
+ const messages = buildMessages(buildSystem(), session.messages);
29
+ let text = "";
30
+ const toolCalls = [];
31
+
32
+ try {
33
+ for await (const chunk of llm.stream({ model, messages, tools, signal })) {
34
+ if (chunk.type === "text") {
35
+ text += chunk.delta;
36
+ handlers.onText?.(chunk.delta);
37
+ } else if (chunk.type === "tool_call") {
38
+ toolCalls.push(chunk.call);
39
+ } else if (chunk.type === "usage") {
40
+ session.addUsage(chunk.usage);
41
+ handlers.onUsage?.(session.usage);
42
+ }
43
+ }
44
+ } catch (e) {
45
+ if (e.name === "AbortError") {
46
+ logger.info(EVENTS.agent.turnAborted, { iteration: i });
47
+ return { aborted: true };
48
+ }
49
+ logger.error(EVENTS.llm.error, { error: e.message });
50
+ handlers.onError?.(e);
51
+ return { error: e };
52
+ }
53
+
54
+ const assistant = assistantMessage(text, toolCalls);
55
+ session.addMessage(assistant);
56
+ handlers.onAssistant?.(assistant);
57
+
58
+ if (!toolCalls.length) {
59
+ logger.info(EVENTS.agent.turnEnd, { iterations: i + 1 });
60
+ return { assistant };
61
+ }
62
+
63
+ handlers.onToolCalls?.(toolCalls);
64
+ const results = await runToolCalls(toolCalls, registry, toolCtx, handlers.onToolResult);
65
+ for (const r of results) session.addMessage(r);
66
+
67
+ if (shouldCompact(session, config)) await compact(session, deps);
68
+ }
69
+
70
+ logger.warn(EVENTS.agent.turnMaxIterations, { maxIter });
71
+ return { maxIterations: true };
72
+ }
@@ -0,0 +1,126 @@
1
+ // 系统 prompt 组装:人设 + 环境 + 记忆快照 + 技能清单 + 历史摘要。
2
+
3
+ import os from "node:os";
4
+ import { createI18n } from "../i18n/index.js";
5
+ import { nowIso } from "../utils.js";
6
+ import { renderSkills } from "../skills/loader.js";
7
+ import { systemMessage } from "../transport/message.js";
8
+
9
+ const OS_NAMES = { darwin: "macOS", linux: "Linux", win32: "Windows" };
10
+
11
+ /** 组装详细运行环境描述(含 Node.js 运行时信息)。 */
12
+ function tzOffset() {
13
+ const offMin = -new Date().getTimezoneOffset(); // 东区为正
14
+ const sign = offMin >= 0 ? "+" : "-";
15
+ const abs = Math.abs(offMin);
16
+ return `UTC${sign}${String(Math.floor(abs / 60)).padStart(2, "0")}:${String(abs % 60).padStart(2, "0")}`;
17
+ }
18
+
19
+ function timeZoneName() {
20
+ try {
21
+ return globalThis.Intl?.DateTimeFormat?.().resolvedOptions?.().timeZone || process.env.TZ || "Local";
22
+ } catch {
23
+ return process.env.TZ || "Local";
24
+ }
25
+ }
26
+
27
+ function runtimeEnv(workspaceRoot, t) {
28
+ const osName = OS_NAMES[process.platform] || process.platform;
29
+ const shell = process.env.SHELL || (process.platform === "win32" ? "powershell" : "sh");
30
+ const tz = timeZoneName();
31
+ return [
32
+ t("prompt.runtimeTitle"),
33
+ t("prompt.os", { name: osName, platform: process.platform, arch: process.arch, release: os.release() }),
34
+ t("prompt.node", { version: process.version }),
35
+ t("prompt.shell", { shell }),
36
+ t("prompt.workspace", { workspace: workspaceRoot }),
37
+ t("prompt.time", { time: nowIso(), timezone: tz, offset: tzOffset() }),
38
+ ].join("\n");
39
+ }
40
+
41
+ function providerName(model) {
42
+ const s = String(model || "");
43
+ const slash = s.indexOf("/");
44
+ return slash > 0 ? s.slice(0, slash) : "";
45
+ }
46
+
47
+ function vanorContext(ctx = {}, t) {
48
+ const lines = [t("prompt.contextTitle")];
49
+ if (ctx.model) lines.push(t("prompt.currentModel", { model: ctx.model }));
50
+ const provider = ctx.provider || providerName(ctx.model);
51
+ if (provider) lines.push(t("prompt.provider", { provider }));
52
+ if (ctx.language) {
53
+ lines.push(t("prompt.uiLanguage", { language: ctx.language, configured: ctx.languageConfigured || "auto", source: ctx.languageSource || "" }));
54
+ }
55
+ if (ctx.configPath) lines.push(t("prompt.configPath", { path: ctx.configPath }));
56
+ if (ctx.sessionId) {
57
+ lines.push(t("prompt.session", { id: ctx.sessionId, restored: ctx.sessionRestored ? t("prompt.restored") : "" }));
58
+ }
59
+ if (ctx.approval) lines.push(t("prompt.approval", { approval: ctx.approval }));
60
+ if (ctx.paths) {
61
+ const p = ctx.paths;
62
+ if (p.root) lines.push(t("prompt.runtimeRoot", { path: p.root }));
63
+ if (p.sessions) lines.push(t("prompt.sessionsDir", { path: p.sessions }));
64
+ if (p.memory) lines.push(t("prompt.memoryDir", { path: p.memory }));
65
+ if (p.skills) lines.push(t("prompt.localSkillsDir", { path: p.skills }));
66
+ if (p.logs) lines.push(t("prompt.logsDir", { path: p.logs }));
67
+ }
68
+ if (ctx.skillDirs?.length) lines.push(t("prompt.skillDirs", { dirs: ctx.skillDirs.join("; ") }));
69
+ if (Number.isFinite(ctx.skillsCount)) lines.push(t("prompt.skillsCount", { count: ctx.skillsCount }));
70
+ lines.push(t("prompt.answerContext"));
71
+ lines.push(t("prompt.noSecrets"));
72
+ return lines.join("\n");
73
+ }
74
+
75
+ /** 读取当前终端能力:宽度、行数、TERM 类型、颜色支持。 */
76
+ function terminalInfo() {
77
+ return {
78
+ cols: process.stdout.columns || 80,
79
+ rows: process.stdout.rows || 24,
80
+ term: process.env.TERM || "unknown",
81
+ color: Boolean(process.stdout.isTTY) && !process.env.NO_COLOR,
82
+ trueColor: /truecolor|24bit/i.test(process.env.COLORTERM || ""),
83
+ };
84
+ }
85
+
86
+ /** 构造系统 prompt 文本。 */
87
+ export function buildSystemPrompt({ memory, skills, summary, workspaceRoot, channel = "cli", terminal, instance, i18n, language }) {
88
+ const currentI18n = i18n || createI18n(language || "en");
89
+ const { t } = currentI18n;
90
+ const parts = [t("prompt.persona"), t("prompt.language")];
91
+ parts.push(runtimeEnv(workspaceRoot, t));
92
+ if (instance) parts.push(vanorContext(instance, t));
93
+ const guide = channel === "cli" ? t("prompt.channelCli") : t("prompt.channelDefault");
94
+ parts.push(t("prompt.communication", { guide }));
95
+
96
+ if (channel === "cli") {
97
+ const term = terminal || terminalInfo();
98
+ const color = term.color
99
+ ? currentI18n.t("prompt.colorSupported", { trueColor: term.trueColor ? currentI18n.t("prompt.trueColor") : "" })
100
+ : currentI18n.t("prompt.colorUnsupported");
101
+ parts.push(
102
+ currentI18n.t("prompt.terminal", {
103
+ term: term.term,
104
+ cols: term.cols,
105
+ rows: term.rows,
106
+ color,
107
+ ansi: term.color ? currentI18n.t("prompt.ansiHint") : "",
108
+ }),
109
+ );
110
+ }
111
+
112
+ const mem = memory.snapshot();
113
+ if (mem) parts.push(mem);
114
+
115
+ const sk = renderSkills(skills, currentI18n);
116
+ if (sk) parts.push(sk);
117
+
118
+ if (summary) parts.push(t("prompt.historySummary", { summary }));
119
+
120
+ return parts.join("\n\n");
121
+ }
122
+
123
+ /** 构造发往 LLM 的完整消息序列。 */
124
+ export function buildMessages(systemText, sessionMessages) {
125
+ return [systemMessage(systemText), ...sessionMessages];
126
+ }
@@ -0,0 +1,255 @@
1
+ // 会话:运行时上下文(messages + summary + usage)与 JSONL 持久化。
2
+
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { EVENTS } from "../events.js";
6
+ import { ensureDir, genId, nowIso, readJsonSafe, writeJson } from "../utils.js";
7
+
8
+ function parseJsonLine(line) {
9
+ try {
10
+ return JSON.parse(line);
11
+ } catch {
12
+ return null;
13
+ }
14
+ }
15
+
16
+ function trimIncompleteTail(messages) {
17
+ const out = [...messages];
18
+ for (;;) {
19
+ const last = out[out.length - 1];
20
+ if (!last) break;
21
+ if (last.role === "user") {
22
+ out.pop();
23
+ continue;
24
+ }
25
+ if (last.role === "assistant" && last.toolCalls?.length) {
26
+ out.pop();
27
+ continue;
28
+ }
29
+ if (last.role === "tool") {
30
+ while (out[out.length - 1]?.role === "tool") out.pop();
31
+ if (out[out.length - 1]?.role === "assistant" && out[out.length - 1]?.toolCalls?.length) out.pop();
32
+ continue;
33
+ }
34
+ break;
35
+ }
36
+ return out;
37
+ }
38
+
39
+ function stateFile(paths) {
40
+ if (paths.state) return paths.state;
41
+ if (paths.root) return path.join(paths.root, "state.json");
42
+ return null;
43
+ }
44
+
45
+ export function readSessionState(paths) {
46
+ const file = stateFile(paths);
47
+ return file ? readJsonSafe(file, {}) || {} : {};
48
+ }
49
+
50
+ export function writeSessionState(paths, patch) {
51
+ const file = stateFile(paths);
52
+ if (!file) return;
53
+ const prev = readSessionState(paths);
54
+ writeJson(file, { ...prev, ...patch, updatedAt: nowIso() });
55
+ }
56
+
57
+ export function saveLatestSessionId(paths, id, workspaceRoot) {
58
+ if (!id) return;
59
+ const patch = { latestSessionId: id };
60
+ if (workspaceRoot) {
61
+ const prev = readSessionState(paths);
62
+ patch.workspaces = {
63
+ ...(prev.workspaces || {}),
64
+ [workspaceRoot]: { latestSessionId: id, updatedAt: nowIso() },
65
+ };
66
+ }
67
+ writeSessionState(paths, patch);
68
+ }
69
+
70
+ export class Session {
71
+ constructor(paths, logger, { id, model } = {}) {
72
+ this.paths = paths;
73
+ this.logger = logger;
74
+ this.id = id || genId("sess");
75
+ this.model = model || "";
76
+ this.file = path.join(paths.sessions, `${this.id}.jsonl`);
77
+ this.messages = [];
78
+ this.summary = "";
79
+ this.usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
80
+ this.restored = false;
81
+ this.workspaceRoot = paths.workspaceRoot || "";
82
+ }
83
+
84
+ start() {
85
+ ensureDir(this.paths.sessions);
86
+ this._append({ type: "meta", ts: nowIso(), key: "start", value: { model: this.model } });
87
+ saveLatestSessionId(this.paths, this.id, this.workspaceRoot);
88
+ this.logger?.info(EVENTS.session.start, { id: this.id, model: this.model });
89
+ return this;
90
+ }
91
+
92
+ static load(paths, logger, id) {
93
+ if (!id) return null;
94
+ const file = path.join(paths.sessions, `${id}.jsonl`);
95
+ if (!fs.existsSync(file)) return null;
96
+
97
+ const session = new Session(paths, logger, { id });
98
+ session.file = file;
99
+ session.restored = true;
100
+
101
+ const text = fs.readFileSync(file, "utf8");
102
+ for (const line of text.split("\n")) {
103
+ if (!line.trim()) continue;
104
+ const entry = parseJsonLine(line);
105
+ if (!entry) continue; // 单行损坏不影响整体恢复
106
+
107
+ if (entry.type === "meta" && entry.key === "start") {
108
+ session.model = entry.value?.model || session.model;
109
+ } else if (entry.type === "meta" && entry.key === "model") {
110
+ session.model = entry.value?.model || entry.value || session.model;
111
+ } else if (entry.type === "message" && entry.message?.role) {
112
+ session.messages.push(entry.message);
113
+ } else if (entry.type === "compaction") {
114
+ if (entry.summary) session.summary = session.summary ? `${session.summary}\n${entry.summary}` : entry.summary;
115
+ const replaced = Number(entry.replaced || 0);
116
+ if (replaced > 0) session.messages = session.messages.slice(Math.min(replaced, session.messages.length));
117
+ } else if (entry.type === "usage") {
118
+ session.usage.promptTokens += entry.usage?.promptTokens || 0;
119
+ session.usage.completionTokens += entry.usage?.completionTokens || 0;
120
+ session.usage.totalTokens += entry.usage?.totalTokens || 0;
121
+ }
122
+ }
123
+
124
+ session.messages = trimIncompleteTail(session.messages);
125
+ return session;
126
+ }
127
+
128
+ resume() {
129
+ ensureDir(this.paths.sessions);
130
+ this._append({ type: "meta", ts: nowIso(), key: "resume", value: { model: this.model } });
131
+ saveLatestSessionId(this.paths, this.id, this.workspaceRoot);
132
+ this.logger?.info(EVENTS.session.resume, { id: this.id, model: this.model, messages: this.messages.length });
133
+ return this;
134
+ }
135
+
136
+ setModel(model) {
137
+ this.model = model || "";
138
+ this._append({ type: "meta", ts: nowIso(), key: "model", value: { model: this.model } });
139
+ }
140
+
141
+ _append(entry) {
142
+ try {
143
+ fs.appendFileSync(this.file, JSON.stringify(entry) + "\n");
144
+ } catch {
145
+ // 持久化失败不影响主流程
146
+ }
147
+ }
148
+
149
+ addMessage(message) {
150
+ this.messages.push(message);
151
+ this._append({ type: "message", ts: nowIso(), message });
152
+ return message;
153
+ }
154
+
155
+ addUsage(usage) {
156
+ if (!usage) return;
157
+ this.usage.promptTokens += usage.promptTokens || 0;
158
+ this.usage.completionTokens += usage.completionTokens || 0;
159
+ this.usage.totalTokens += usage.totalTokens || 0;
160
+ this._append({ type: "usage", ts: nowIso(), usage });
161
+ }
162
+
163
+ /** 估算当前上下文 token 数(约 4 字符 / token)。 */
164
+ estimatedTokens() {
165
+ let chars = this.summary.length;
166
+ for (const m of this.messages) {
167
+ chars += (m.content || "").length;
168
+ if (m.toolCalls) chars += JSON.stringify(m.toolCalls).length;
169
+ }
170
+ return Math.ceil(chars / 4);
171
+ }
172
+
173
+ /** compaction 后替换内存中的消息(磁盘历史保留)。 */
174
+ replaceRecent(recent) {
175
+ this.messages = recent;
176
+ }
177
+
178
+ addCompaction({ summary, replaced }) {
179
+ this._append({ type: "compaction", ts: nowIso(), summary, replaced });
180
+ this.logger?.info(EVENTS.session.compact, { id: this.id, replaced });
181
+ }
182
+ }
183
+
184
+ function sessionInfo(paths, id) {
185
+ if (!id) return null;
186
+ const file = path.join(paths.sessions, `${id}.jsonl`);
187
+ if (!fs.existsSync(file)) return null;
188
+ const stat = fs.statSync(file);
189
+ let model = "";
190
+ let messageCount = 0;
191
+ const text = fs.readFileSync(file, "utf8");
192
+ for (const line of text.split("\n")) {
193
+ if (!line.trim()) continue;
194
+ const e = parseJsonLine(line);
195
+ if (!e) continue;
196
+ if (e.type === "meta" && e.key === "start") model = e.value?.model || "";
197
+ if (e.type === "meta" && e.key === "model") model = e.value?.model || e.value || model;
198
+ if (e.type === "message") messageCount++;
199
+ }
200
+ return { id, file, model, messageCount, mtime: stat.mtimeMs };
201
+ }
202
+
203
+ /** 列出历史会话(用于 vanor sessions)。 */
204
+ export function listSessions(paths) {
205
+ if (!fs.existsSync(paths.sessions)) return [];
206
+ return fs
207
+ .readdirSync(paths.sessions)
208
+ .filter((f) => f.endsWith(".jsonl"))
209
+ .map((f) => {
210
+ const file = path.join(paths.sessions, f);
211
+ const stat = fs.statSync(file);
212
+ let model = "";
213
+ let messageCount = 0;
214
+ const text = fs.readFileSync(file, "utf8");
215
+ for (const line of text.split("\n")) {
216
+ if (!line.trim()) continue;
217
+ const e = parseJsonLine(line);
218
+ if (!e) continue;
219
+ if (e.type === "meta" && e.key === "start") model = e.value?.model || "";
220
+ if (e.type === "message") messageCount++;
221
+ }
222
+ return { id: f.replace(/\.jsonl$/, ""), file, model, messageCount, mtime: stat.mtimeMs };
223
+ })
224
+ .sort((a, b) => b.mtime - a.mtime);
225
+ }
226
+
227
+ /** 列出某个会话中所有用户消息(从 JSONL 读取完整历史,不受内存 compaction 影响)。 */
228
+ export function listUserMessages(paths, id) {
229
+ if (!id) return [];
230
+ const file = path.join(paths.sessions, `${id}.jsonl`);
231
+ if (!fs.existsSync(file)) return [];
232
+ const messages = [];
233
+ const text = fs.readFileSync(file, "utf8");
234
+ for (const line of text.split("\n")) {
235
+ if (!line.trim()) continue;
236
+ const entry = parseJsonLine(line);
237
+ if (entry?.type !== "message" || entry.message?.role !== "user") continue;
238
+ messages.push({
239
+ id: entry.message.id || "",
240
+ time: entry.message.meta?.timestamp || entry.ts || "",
241
+ content: entry.message.content || "",
242
+ });
243
+ }
244
+ return messages;
245
+ }
246
+
247
+ export function latestSession(paths, workspaceRoot) {
248
+ const state = readSessionState(paths);
249
+ const stateId = workspaceRoot
250
+ ? state.workspaces?.[workspaceRoot]?.latestSessionId || state.latestSessionId
251
+ : state.latestSessionId;
252
+ const fromState = sessionInfo(paths, stateId);
253
+ if (fromState) return fromState;
254
+ return listSessions(paths)[0] || null;
255
+ }