minimal-agent 0.5.3 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minimal-agent",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "description": "最小化 Agent 系统 —— 10 工具 + 插件系统 + workflow DSL + 自动压缩 + OpenAI 兼容 + Ink TUI;NodeNext + tsc 原地编译,dev 用 Bun .ts、install 用 Node .js(学习/教学用)",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Bill Wang <leiwang0359@gmail.com>",
@@ -0,0 +1,117 @@
1
+ /**
2
+ * ============================================================
3
+ * src/context/archive.ts —— /new 自动归档历史对话
4
+ * ------------------------------------------------------------
5
+ * 做的事:
6
+ * `/new` 清空上下文前,先把当前对话历史落盘到 archive 目录,
7
+ * 供后续 `/list-archive`、`/restore <id>` 查看 / 找回。
8
+ *
9
+ * 文件路径:
10
+ * <MINIMAL_AGENT_HOME ?? ~>/.minimal-agent/archive/<cwd-hash>/<ISO-timestamp>.json
11
+ * - cwd-hash:当前工作目录 sha1 前 12 位(避免不同项目 archive 串台)
12
+ * - timestamp:new Date().toISOString().replace(/:/g, '-')(Windows 不允许冒号)
13
+ *
14
+ * 存储格式:
15
+ * { updatedAt: number; messages: Message[] }
16
+ *
17
+ * 抉择:
18
+ * - 仅含 system 消息(空对话)跳过写入,返回空字符串
19
+ * - 失败由调用方 try/catch 吞掉,本模块不做 stderr(保持纯函数语义)
20
+ * - 损坏文件在 listArchives 中静默 skip
21
+ * - 通过 MINIMAL_AGENT_HOME 环境变量让测试切到临时目录,避免污染真实 ~/.minimal-agent
22
+ * ============================================================
23
+ */
24
+ import { createHash } from 'node:crypto';
25
+ import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
26
+ import { homedir } from 'node:os';
27
+ import { join } from 'node:path';
28
+ import { getWorkingDir } from '../bootstrap/workingDir.js';
29
+ /** 解析 archive 根目录,支持 MINIMAL_AGENT_HOME 测试覆盖 */
30
+ function agentHome() {
31
+ return process.env.MINIMAL_AGENT_HOME ?? homedir();
32
+ }
33
+ function archiveDir(cwd) {
34
+ const hash = createHash('sha1').update(cwd).digest('hex').slice(0, 12);
35
+ return join(agentHome(), '.minimal-agent', 'archive', hash);
36
+ }
37
+ /**
38
+ * 归档当前 messages 到 archive 目录。
39
+ *
40
+ * @returns archive id(成功)或空字符串(无内容跳过)
41
+ *
42
+ * 行为:
43
+ * - messages 为空 / 仅含 system role → 跳过,返回 ''
44
+ * - 写入失败抛错(调用方负责吞)
45
+ */
46
+ export async function archiveCurrent(messages) {
47
+ const nonSystem = messages.filter((m) => m.role !== 'system');
48
+ if (nonSystem.length === 0)
49
+ return '';
50
+ const dir = archiveDir(getWorkingDir());
51
+ await mkdir(dir, { recursive: true });
52
+ const id = new Date().toISOString().replace(/:/g, '-');
53
+ const path = join(dir, `${id}.json`);
54
+ await writeFile(path, JSON.stringify({ updatedAt: Date.now(), messages }), 'utf8');
55
+ return id;
56
+ }
57
+ /**
58
+ * 列出当前 cwd 下所有归档,最新优先(按文件 mtime 倒序)。
59
+ *
60
+ * 损坏 / 无法解析的文件会被静默跳过——不影响其它条目展示。
61
+ * 目录不存在视作空列表。
62
+ */
63
+ export async function listArchives() {
64
+ const dir = archiveDir(getWorkingDir());
65
+ let names;
66
+ try {
67
+ names = await readdir(dir);
68
+ }
69
+ catch {
70
+ return [];
71
+ }
72
+ const entries = [];
73
+ for (const name of names) {
74
+ if (!name.endsWith('.json'))
75
+ continue;
76
+ const id = name.slice(0, -'.json'.length);
77
+ const fpath = join(dir, name);
78
+ try {
79
+ const st = await stat(fpath);
80
+ const raw = await readFile(fpath, 'utf8');
81
+ const data = JSON.parse(raw);
82
+ if (!Array.isArray(data.messages))
83
+ continue;
84
+ const firstUser = data.messages.find((m) => m.role === 'user');
85
+ const preview = firstUser && typeof firstUser.content === 'string'
86
+ ? firstUser.content.replace(/\s+/g, ' ').slice(0, 80)
87
+ : '';
88
+ entries.push({
89
+ id,
90
+ updatedAt: st.mtimeMs,
91
+ msgCount: data.messages.length,
92
+ firstUserPreview: preview,
93
+ });
94
+ }
95
+ catch {
96
+ // 损坏文件静默 skip
97
+ }
98
+ }
99
+ return entries.sort((a, b) => b.updatedAt - a.updatedAt);
100
+ }
101
+ /**
102
+ * 按 id 恢复归档。
103
+ *
104
+ * @returns messages 数组(成功);找不到 / 损坏 / messages 非数组 → null
105
+ */
106
+ export async function restoreArchive(id) {
107
+ const dir = archiveDir(getWorkingDir());
108
+ const path = join(dir, `${id}.json`);
109
+ try {
110
+ const raw = await readFile(path, 'utf8');
111
+ const data = JSON.parse(raw);
112
+ return Array.isArray(data.messages) ? data.messages : null;
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ }
@@ -25,6 +25,7 @@
25
25
  import { mkdir, readFile, readdir, rmdir, unlink, writeFile } from 'node:fs/promises';
26
26
  import { dirname, join } from 'node:path';
27
27
  import { getWorkingDir } from '../bootstrap/workingDir.js';
28
+ import { archiveCurrent } from './archive.js';
28
29
  import { sessionFileFor } from './sessionPath.js';
29
30
  /** 上下文文件路径(按工作目录隔离,可用环境变量覆盖) */
30
31
  export function getContextPath() {
@@ -76,6 +77,21 @@ export async function saveContext(messages, file) {
76
77
  */
77
78
  export async function clearContext(file) {
78
79
  const target = file ?? getContextPath();
80
+ // 归档当前历史(仅含 system 的空对话会跳过);失败不阻塞 clear
81
+ // 显式传 file(HTTP 模式 / 测试覆盖路径)或 MINIMAL_AGENT_CONTEXT_FILE 激活时
82
+ // 跳过 archive:这两种语义下 caller 自管路径,不应污染默认 archive 目录
83
+ const skipArchive = file !== undefined || !!process.env.MINIMAL_AGENT_CONTEXT_FILE;
84
+ if (!skipArchive) {
85
+ try {
86
+ const current = await loadContext(target);
87
+ if (current && current.length > 0) {
88
+ await archiveCurrent(current);
89
+ }
90
+ }
91
+ catch (e) {
92
+ process.stderr.write(`[minimal-agent] archive 失败(继续 clear):${e.message}\n`);
93
+ }
94
+ }
79
95
  try {
80
96
  await unlink(target);
81
97
  }
@@ -6,10 +6,16 @@
6
6
  * 把当前工作目录编码为 ~/.minimal-agent/sessions/<encoded>.json
7
7
  * 的文件名,让不同项目目录的对话历史互不覆盖。
8
8
  *
9
- * 命名规则:
10
- * <sanitized-path>-<6-hex-hash>.json
9
+ * 命名规则(v2 加 sessionId 后):
10
+ * <sanitized-path>-<6-hex-hash>--<sessionId>.json
11
11
  * sanitized:路径归一化后只保留 [a-z0-9-],最长 80 字符
12
12
  * hash :原始路径 sha1 前 6 位,防 sanitize 后碰撞
13
+ * sessionId:v2 多 session 支持;默认 'default'
14
+ *
15
+ * 向后兼容:sessionId='default' 时,新文件名 `<sanitized>-<hash>--default.json`
16
+ * 与旧文件名 `<sanitized>-<hash>.json` 都可能存在。resolveSessionFile()
17
+ * 优先返新路径,新路径不存在但旧路径存在时回退到旧路径,保证老用户的
18
+ * last-context 无缝继承。
13
19
  *
14
20
  * 迁移:
15
21
  * 旧版 ~/.minimal-agent/last-context.json 启动时一次性 rename 到
@@ -20,8 +26,29 @@ import { createHash } from 'node:crypto';
20
26
  import { mkdir, rename, stat } from 'node:fs/promises';
21
27
  import { homedir } from 'node:os';
22
28
  import { dirname, join, resolve } from 'node:path';
23
- /** cwd 编码成会话文件绝对路径 */
24
- export function sessionFileFor(cwd) {
29
+ const DEFAULT_SESSION_ID = 'default';
30
+ /**
31
+ * 把 cwd + sessionId 编码成会话文件绝对路径。
32
+ *
33
+ * @param cwd 工作目录
34
+ * @param sessionId 多 session 支持的 id;缺省 'default'
35
+ *
36
+ * 注意:'default' 调用走新格式 `<base>--default.json`。如果磁盘上只有
37
+ * 旧格式 `<base>.json`(v1 历史文件),需要用 resolveSessionFile()
38
+ * 拿到实际存在的路径用于 load。
39
+ */
40
+ export function sessionFileFor(cwd, sessionId = DEFAULT_SESSION_ID) {
41
+ const normalized = resolve(cwd).replace(/\\/g, '/').toLowerCase();
42
+ const sanitized = normalized
43
+ .replace(/:/g, '')
44
+ .replace(/[^a-z0-9]+/g, '-')
45
+ .replace(/^-+|-+$/g, '')
46
+ .slice(0, 80);
47
+ const hash = createHash('sha1').update(normalized).digest('hex').slice(0, 6);
48
+ return join(homedir(), '.minimal-agent', 'sessions', `${sanitized}-${hash}--${sessionId}.json`);
49
+ }
50
+ /** 旧格式(v1,不带 `--<id>` 后缀)—— 仅 'default' session 可能存在 */
51
+ function legacySessionFileFor(cwd) {
25
52
  const normalized = resolve(cwd).replace(/\\/g, '/').toLowerCase();
26
53
  const sanitized = normalized
27
54
  .replace(/:/g, '')
@@ -31,6 +58,34 @@ export function sessionFileFor(cwd) {
31
58
  const hash = createHash('sha1').update(normalized).digest('hex').slice(0, 6);
32
59
  return join(homedir(), '.minimal-agent', 'sessions', `${sanitized}-${hash}.json`);
33
60
  }
61
+ /**
62
+ * 解析 cwd + sessionId 对应的实际存在的会话文件路径。
63
+ *
64
+ * - 新格式(带 `--<id>`)存在 → 返回新路径
65
+ * - 仅 sessionId='default' 且新路径不存在但旧路径(不带 `--`)存在 → 返回旧路径
66
+ * - 都不存在 → 返回新路径(让调用方按新格式 create)
67
+ */
68
+ export async function resolveSessionFile(cwd, sessionId = DEFAULT_SESSION_ID) {
69
+ const fresh = sessionFileFor(cwd, sessionId);
70
+ try {
71
+ await stat(fresh);
72
+ return fresh;
73
+ }
74
+ catch {
75
+ // 新路径不存在
76
+ }
77
+ if (sessionId === DEFAULT_SESSION_ID) {
78
+ const legacy = legacySessionFileFor(cwd);
79
+ try {
80
+ await stat(legacy);
81
+ return legacy;
82
+ }
83
+ catch {
84
+ // 旧路径也不存在
85
+ }
86
+ }
87
+ return fresh;
88
+ }
34
89
  /**
35
90
  * 一次性把旧版 ~/.minimal-agent/last-context.json 迁到当前 cwd 对应的
36
91
  * 会话文件。
@@ -0,0 +1,104 @@
1
+ /**
2
+ * ============================================================
3
+ * src/context/sessionRegistry.ts —— 同 cwd 多 session 注册表
4
+ * ------------------------------------------------------------
5
+ * 做的事:
6
+ * 一个工作目录下可以有多个并行 session(id = 任意字符串),
7
+ * 每次只激活一个。active 信息存 ~/.minimal-agent/active-sessions.json,
8
+ * 键是 cwd 的 sha1[:12],值是当前激活的 session id 字符串。
9
+ *
10
+ * 文件路径:
11
+ * <MINIMAL_AGENT_HOME ?? ~>/.minimal-agent/active-sessions.json
12
+ * <MINIMAL_AGENT_HOME ?? ~>/.minimal-agent/sessions/<cwd-hash>--<id>.json
13
+ * (新格式;旧格式 <cwd-hash>.json 视作 id='default' 兼容)
14
+ *
15
+ * 抉择:
16
+ * - active 文件 atomic write(写 .tmp + rename)—— 防止跨进程并发写时部分写入
17
+ * - sessionId 为字符串,'default' 是约定的默认值(无 active 时返回它)
18
+ * - listSessions 同时扫新旧格式,保证迁移期不丢
19
+ * ============================================================
20
+ */
21
+ import { createHash } from 'node:crypto';
22
+ import { mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises';
23
+ import { homedir } from 'node:os';
24
+ import { join } from 'node:path';
25
+ /** 解析根目录,支持 MINIMAL_AGENT_HOME 测试覆盖 */
26
+ function agentHome() {
27
+ return process.env.MINIMAL_AGENT_HOME ?? homedir();
28
+ }
29
+ function agentDir() {
30
+ return join(agentHome(), '.minimal-agent');
31
+ }
32
+ function activeFilePath() {
33
+ return join(agentDir(), 'active-sessions.json');
34
+ }
35
+ function sessionsDirPath() {
36
+ return join(agentDir(), 'sessions');
37
+ }
38
+ function cwdHash(cwd) {
39
+ return createHash('sha1').update(cwd).digest('hex').slice(0, 12);
40
+ }
41
+ async function loadActiveMap() {
42
+ try {
43
+ const raw = await readFile(activeFilePath(), 'utf8');
44
+ const data = JSON.parse(raw);
45
+ if (data && typeof data === 'object' && !Array.isArray(data)) {
46
+ return data;
47
+ }
48
+ return {};
49
+ }
50
+ catch {
51
+ return {};
52
+ }
53
+ }
54
+ /**
55
+ * 获取当前 cwd 的激活 session id。
56
+ * 无记录 → 返回 'default'。
57
+ */
58
+ export async function getActiveSession(cwd) {
59
+ const map = await loadActiveMap();
60
+ return map[cwdHash(cwd)] ?? 'default';
61
+ }
62
+ /**
63
+ * 设置当前 cwd 的激活 session id。
64
+ *
65
+ * 写入策略:先写 <target>.tmp,再原子 rename 到 target;
66
+ * 即使被中断也只会留临时文件,不破坏 target。
67
+ */
68
+ export async function setActiveSession(cwd, sessionId) {
69
+ const map = await loadActiveMap();
70
+ map[cwdHash(cwd)] = sessionId;
71
+ await mkdir(agentDir(), { recursive: true });
72
+ const target = activeFilePath();
73
+ const tmp = `${target}.tmp`;
74
+ await writeFile(tmp, JSON.stringify(map), 'utf8');
75
+ await rename(tmp, target);
76
+ }
77
+ /**
78
+ * 列出当前 cwd 下所有 session id(含 'default')。
79
+ *
80
+ * 扫描规则:
81
+ * - 新格式 `<hash>--<id>.json` → 提取 id
82
+ * - 旧格式 `<hash>.json` → 视作 'default'
83
+ * - 都没有 → 返回 ['default'](让 UI 默认能 list 出至少一项)
84
+ */
85
+ export async function listSessions(cwd) {
86
+ const dir = sessionsDirPath();
87
+ const hash = cwdHash(cwd);
88
+ let names;
89
+ try {
90
+ names = await readdir(dir);
91
+ }
92
+ catch {
93
+ return ['default'];
94
+ }
95
+ const prefix = `${hash}--`;
96
+ const ids = names
97
+ .filter((n) => n.startsWith(prefix) && n.endsWith('.json'))
98
+ .map((n) => n.slice(prefix.length, -'.json'.length));
99
+ const legacy = `${hash}.json`;
100
+ if (names.includes(legacy) && !ids.includes('default')) {
101
+ ids.unshift('default');
102
+ }
103
+ return ids.length > 0 ? ids : ['default'];
104
+ }