zen-gitsync 2.17.23 → 2.17.25

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 (80) hide show
  1. package/README.md +10 -4
  2. package/package.json +8 -1
  3. package/scripts/run-tests.cjs +5 -0
  4. package/src/cli/ai/agent.js +20 -0
  5. package/src/cli/ai/termui.js +73 -1
  6. package/src/cli/ai/termui.test.js +51 -0
  7. package/src/cli/ai/tools.js +114 -0
  8. package/src/cli/ai/tools.test.js +65 -1
  9. package/src/cli/ai/turn.js +3 -0
  10. package/src/config.js +34 -3
  11. package/src/memory/library.js +273 -0
  12. package/src/memory/library.test.js +187 -0
  13. package/src/memory/store.js +199 -0
  14. package/src/memory/store.test.js +137 -0
  15. package/src/memory/templates.js +113 -0
  16. package/src/paths.js +18 -0
  17. package/src/ui/public/assets/{AgentEngineSelector-DLJ8He83.js → AgentEngineSelector-BArUIELz.js} +1 -1
  18. package/src/ui/public/assets/{AgentView-DnN-vCMi.css → AgentView-B3LhhRsy.css} +1 -1
  19. package/src/ui/public/assets/{AgentView-CQ18nw02.js → AgentView-LpT8A9Fo.js} +1 -1
  20. package/src/ui/public/assets/{AppVersionBadge-1pyUzRer.js → AppVersionBadge-DNXQm_Si.js} +2 -2
  21. package/src/ui/public/assets/{BranchSelector-DrsrZiWJ.js → BranchSelector-DZPTz5Ql.js} +1 -1
  22. package/src/ui/public/assets/{CommitForm-B5p-7P7m.js → CommitForm-6avyu1Kv.js} +1 -1
  23. package/src/ui/public/assets/{CommonDialog-BEm2cK1X.js → CommonDialog-CRXyMlp5.js} +1 -1
  24. package/src/ui/public/assets/EditorView-BIHL8I9g.js +1 -0
  25. package/src/ui/public/assets/{EditorView-C6HFYeEM.css → EditorView-DqxBkn6c.css} +1 -1
  26. package/src/ui/public/assets/{FileDiffViewer-CYxcLw10.js → FileDiffViewer-BEYcCYs5.js} +1 -1
  27. package/src/ui/public/assets/{FlowExecutionViewer-DTmlBLXQ.js → FlowExecutionViewer-DKRHDqbG.js} +1 -1
  28. package/src/ui/public/assets/{FlowOrchestrationWorkspace-Da_UmTRT.js → FlowOrchestrationWorkspace-BnCsF4z4.js} +1 -1
  29. package/src/ui/public/assets/{IconButton-BShiC2AP.js → IconButton-D4df4t9A.js} +1 -1
  30. package/src/ui/public/assets/{LogList-5M5JBhlB.js → LogList-DXqB4JJv.js} +1 -1
  31. package/src/ui/public/assets/{MindmapView-BUV_w14M.js → MindmapView-Ddc55wzp.js} +1 -1
  32. package/src/ui/public/assets/{MonitorView-D9N4QH5Y.js → MonitorView-DeERavq-.js} +1 -1
  33. package/src/ui/public/assets/{ProjectStartupButton-DYYQ-O7P.js → ProjectStartupButton-DaLU4iS4.js} +1 -1
  34. package/src/ui/public/assets/{RecentDirectoriesChat-BtWa_HMY.js → RecentDirectoriesChat-DVtsf7lW.js} +1 -1
  35. package/src/ui/public/assets/RecentDirectoriesChat-o4xK1Koc.css +1 -0
  36. package/src/ui/public/assets/{RemoteManagerDialog-C4QIJK_F.js → RemoteManagerDialog-C3cfDsZi.js} +1 -1
  37. package/src/ui/public/assets/{RemoteRepoCard-BmDh538v.js → RemoteRepoCard-D1Hso5Fq.js} +1 -1
  38. package/src/ui/public/assets/{SourceMapView-Ceamya-N.js → SourceMapView-Dwn5wy1o.js} +1 -1
  39. package/src/ui/public/assets/{SvgIcon-xtz4vvc9.js → SvgIcon-diKLLWK9.js} +1 -1
  40. package/src/ui/public/assets/{TemplateManager-DhKTZ4EM.js → TemplateManager-D9gyZbHP.js} +1 -1
  41. package/src/ui/public/assets/{UserInputNode-DfeCGlEO.js → UserInputNode-JNtVWcbE.js} +1 -1
  42. package/src/ui/public/assets/WorkbenchView-CJCxYK-K.css +1 -0
  43. package/src/ui/public/assets/WorkbenchView-DxaAjZAA.js +6 -0
  44. package/src/ui/public/assets/_plugin-vue_export-helper-C7IvbEC3.js +19 -0
  45. package/src/ui/public/assets/agentConversations-nN-yDz8q.js +5 -0
  46. package/src/ui/public/assets/configStore-C-htWfWm.js +1 -0
  47. package/src/ui/public/assets/{gitStore-ByP9lpuT.js → gitStore-DYpd8vZK.js} +1 -1
  48. package/src/ui/public/assets/index-BZFKDSGv.css +1 -0
  49. package/src/ui/public/assets/index-eq1OUcdk.js +29 -0
  50. package/src/ui/public/assets/{previewSandbox-DSzZ_Rky.js → previewSandbox-Z707CZD9.js} +1 -1
  51. package/src/ui/public/assets/toolsStore-DFu4Alwg.js +1 -0
  52. package/src/ui/public/assets/vendor-BY5ZDlW1.css +1 -0
  53. package/src/ui/public/assets/{vendor-CyWsJd6S.js → vendor-BjomZKSS.js} +28 -120
  54. package/src/ui/public/index.html +14 -14
  55. package/src/ui/server/index.js +4 -0
  56. package/src/ui/server/routes/config.js +11 -2
  57. package/src/ui/server/routes/fileOpen.js +78 -13
  58. package/src/ui/server/routes/memory.js +125 -0
  59. package/src/ui/server/routes/workbench/agentChat.js +35 -3
  60. package/src/ui/server/routes/workbench/agentChatShared.test.js +49 -0
  61. package/src/ui/server/routes/workbench/executorModels.js +329 -0
  62. package/src/ui/server/routes/workbench/executorModels.test.js +326 -0
  63. package/src/ui/server/routes/workbench/index.js +45 -2
  64. package/src/ui/server/routes/workbench/jobActivity.js +6 -0
  65. package/src/ui/server/routes/workbench/memoryContext.js +125 -0
  66. package/src/ui/server/routes/workbench/memoryContext.test.js +79 -0
  67. package/src/ui/server/routes/workbench/orchestratorStore.js +4 -1
  68. package/src/ui/server/routes/workbench/runExternalTurn.js +6 -1
  69. package/src/ui/server/routes/workbench/taskRunner.js +48 -5
  70. package/src/ui/public/assets/EditorView-fuMrS9P7.js +0 -1
  71. package/src/ui/public/assets/RecentDirectoriesChat-vHT8jMxW.css +0 -1
  72. package/src/ui/public/assets/WorkbenchView-D9l3i4Qj.css +0 -1
  73. package/src/ui/public/assets/WorkbenchView-DX2epPD9.js +0 -6
  74. package/src/ui/public/assets/_plugin-vue_export-helper-C_bQVzeb.js +0 -19
  75. package/src/ui/public/assets/agentConversations-BUPj8vzH.js +0 -5
  76. package/src/ui/public/assets/configStore-DJH7OlB_.js +0 -1
  77. package/src/ui/public/assets/index-CFX8sK4w.js +0 -29
  78. package/src/ui/public/assets/index-DVJITdth.css +0 -1
  79. package/src/ui/public/assets/toolsStore-B49mCaQ_.js +0 -1
  80. package/src/ui/public/assets/vendor-BzMymUBi.css +0 -1
package/src/config.js CHANGED
@@ -98,6 +98,19 @@ export function normalizeNotifyOnTaskDone(value) {
98
98
  return typeof value === 'boolean' ? value : null;
99
99
  }
100
100
 
101
+ /**
102
+ * 规范化「任务完成提示音」开关。语义与 normalizeNotifyOnTaskDone 完全一致
103
+ * (只收布尔值,`'false'` 这种字符串不当真值用 —— 理由同上)。
104
+ *
105
+ * 为什么和总开关分开成两个键:有人要"发系统通知但别出声"(开会 / 图书馆 / 夜里),
106
+ * 也有人反过来只要"叮"一声、不想要通知卡片和权限询问。合成一个开关两边都别扭。
107
+ * 从属关系在消费端:总开关关着时整个提示链路都不走,提示音自然也不会响
108
+ * (见 useTaskNotifier.announce 的调用点)。
109
+ */
110
+ export function normalizeNotifySoundOnTaskDone(value) {
111
+ return typeof value === 'boolean' ? value : null;
112
+ }
113
+
101
114
  // 默认配置
102
115
  const defaultConfig = {
103
116
  defaultCommitMessage: "submit",
@@ -153,6 +166,11 @@ const defaultConfig = {
153
166
  // 默认关等于大部分人永远不知道有它;浏览器通知权限在页面内首次点击时自动申请
154
167
  // (见 App.vue 的 onUserGestureForNotifyPermission),所以默认开不会静默失效。
155
168
  notifyOnTaskDone: true,
169
+ // 任务完成提示音(全局,默认开):跑完 / 出错误各响一声,主动停止不响。
170
+ // 从属于上面的 notifyOnTaskDone —— 总开关关着时整条提示链路都不走,声音也不会响。
171
+ // 单独一个键是为了"要通知但别出声"和"要声音但不要通知卡片"这两种人都能配。
172
+ // 音源是 CC0 资源(src/ui/client/public/sounds/),见同目录 CREDITS.txt。
173
+ notifySoundOnTaskDone: true,
156
174
  // UI 状态(跨项目共享,存到顶层 ui 对象)
157
175
  // 之前散落在 localStorage,因随机端口启动而失效,迁到文件持久化
158
176
  ui: {
@@ -474,7 +492,9 @@ async function loadConfig() {
474
492
  aiMaxToolIterations: normalizeAiMaxToolIterations(raw.aiMaxToolIterations)
475
493
  ?? defaultConfig.aiMaxToolIterations,
476
494
  taskExecutor: normalizeTaskExecutor(raw.taskExecutor) ?? defaultConfig.taskExecutor,
477
- notifyOnTaskDone: normalizeNotifyOnTaskDone(raw.notifyOnTaskDone) ?? defaultConfig.notifyOnTaskDone
495
+ notifyOnTaskDone: normalizeNotifyOnTaskDone(raw.notifyOnTaskDone) ?? defaultConfig.notifyOnTaskDone,
496
+ notifySoundOnTaskDone: normalizeNotifySoundOnTaskDone(raw.notifySoundOnTaskDone)
497
+ ?? defaultConfig.notifySoundOnTaskDone
478
498
  };
479
499
  }
480
500
 
@@ -494,7 +514,9 @@ async function loadConfig() {
494
514
  ?? defaultConfig.aiMaxToolIterations,
495
515
  taskExecutor: normalizeTaskExecutor(raw?.taskExecutor) ?? defaultConfig.taskExecutor,
496
516
  // 同 taskExecutor:全局配置,始终取顶层,防止被项目配置里的旧值覆盖
497
- notifyOnTaskDone: normalizeNotifyOnTaskDone(raw?.notifyOnTaskDone) ?? defaultConfig.notifyOnTaskDone
517
+ notifyOnTaskDone: normalizeNotifyOnTaskDone(raw?.notifyOnTaskDone) ?? defaultConfig.notifyOnTaskDone,
518
+ notifySoundOnTaskDone: normalizeNotifySoundOnTaskDone(raw?.notifySoundOnTaskDone)
519
+ ?? defaultConfig.notifySoundOnTaskDone
498
520
  };
499
521
  }
500
522
 
@@ -542,7 +564,7 @@ async function saveConfig(config) {
542
564
 
543
565
  // 分离全局设置和项目设置
544
566
  // models / ui 也是全局配置(跨项目共享),和 theme/locale 一样存到顶层
545
- const { theme, locale, models, ui, aiMaxToolIterations, taskExecutor, notifyOnTaskDone, ...projectConfig } = config;
567
+ const { theme, locale, models, ui, aiMaxToolIterations, taskExecutor, notifyOnTaskDone, notifySoundOnTaskDone, ...projectConfig } = config;
546
568
 
547
569
  // 保存全局设置到根级别
548
570
  if (theme !== undefined) {
@@ -572,6 +594,10 @@ async function saveConfig(config) {
572
594
  if (normalizedNotify !== null) {
573
595
  raw.notifyOnTaskDone = normalizedNotify;
574
596
  }
597
+ const normalizedNotifySound = normalizeNotifySoundOnTaskDone(notifySoundOnTaskDone);
598
+ if (normalizedNotifySound !== null) {
599
+ raw.notifySoundOnTaskDone = normalizedNotifySound;
600
+ }
575
601
 
576
602
  // 写入当前项目配置(在 defaultConfig 基础上合并,但不清空顶层其它键)
577
603
  const existingProjectConfig = (raw.projects[key] && typeof raw.projects[key] === 'object') ? raw.projects[key] : {};
@@ -771,6 +797,11 @@ export default {
771
797
  TASK_EXECUTORS,
772
798
  // 任务执行结束提示开关规范化(同上)
773
799
  normalizeNotifyOnTaskDone,
800
+ // 任务完成提示音开关规范化(同上)。⚠️ 新增规范化函数务必同步加进这个对象字面量 ——
801
+ // server 侧 `import config from '../../config.js'` 拿的是**这个默认导出对象**,
802
+ // 只在下面 `export {}` 里命名导出是不够的:漏加会变成 undefined 调用 → 路由 500,
803
+ // 而且因为有 try/catch 兜底,前端只会看到"保存失败",不知道是哪个函数缺了。
804
+ normalizeNotifySoundOnTaskDone,
774
805
  };
775
806
 
776
807
  // 命名导出 — 用于测试与外部复用
@@ -0,0 +1,273 @@
1
+ // Copyright 2026 xz333221
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ //
15
+ // 记忆库的**浏览 / 删除**能力。store.js 只管"写的时候路径对不对",
16
+ // 这里管"界面看到什么、能删什么"。
17
+ //
18
+ // ── 身份是怎么表达的 ────────────────────────────────────────────────
19
+ // 记忆条目的定位是 `{ scope, file }`:
20
+ // scope = `'global'` → `MEMORY_DIR/GLOBAL.md`
21
+ // scope = `'global-index'` → `MEMORY_DIR/INDEX.md`
22
+ // scope = 任意 slug → `MEMORY_DIR/projects/<slug>/lessons/<file>`
23
+ // **前端永远只传 scope + file 两个字符串,不传绝对路径** ——
24
+ // 这是本文件最重要的安全设计:路径由服务端从 scope 重新拼,
25
+ // 前端传什么都只能落到"某个 slug 下的某个 lessons/*.md",
26
+ // 不存在拼出 `~/.claude/settings.json` 的可能。
27
+ // (对比 mindmap.js:那边让前端传 path,所以得靠 validatePath + pathGuard 双重拦;
28
+ // 这里是结构性防��� —— 越界参数压根不参与路径拼接。)
29
+ //
30
+ // ⚠️ 因此下面所有对 `scope` / `file` 的合法性检查都**不是可选的加固**,
31
+ // 而是这条安全边界的组成部分。改这个文件时别把它们当冗余删掉。
32
+
33
+ import fs from 'node:fs/promises';
34
+ import path from 'node:path';
35
+ import { MEMORY_DIR, MEMORY_GLOBAL_FILE, MEMORY_INDEX_FILE, MEMORY_PROJECTS_DIR } from '../paths.js';
36
+
37
+ /** 单条正文的大小上限:超过就不给读(界面也渲染不动) */
38
+ export const ENTRY_MAX_BYTES = 64 * 1024;
39
+
40
+ /** 列表条目数上限:避免一个 slug 下堆了上千文件时把响应撑爆 */
41
+ export const LIST_MAX_ENTRIES = 500;
42
+
43
+ /** scope 的两种特殊值(其余都当 slug 处理) */
44
+ export const SCOPE_GLOBAL = 'global';
45
+ export const SCOPE_GLOBAL_INDEX = 'global-index';
46
+
47
+ const LESSON_EXT = '.md';
48
+
49
+ /** slug 形状:可读部分 + 6 位十六进制哈希。**必须严格校验**(见文件头) */
50
+ const SLUG_RE = /^[a-z0-9_][a-z0-9_-]{0,120}-[0-9a-f]{6}$/;
51
+
52
+ /** lesson 文件名:kebab-case,不允许点号/斜杠/上跳(`.` 与 `..` 靠这条挡掉) */
53
+ const FILE_RE = /^[a-z0-9][a-z0-9._-]{0,79}\.md$/i;
54
+
55
+ export function isValidScope(scope) {
56
+ return scope === SCOPE_GLOBAL || scope === SCOPE_GLOBAL_INDEX || (typeof scope === 'string' && SLUG_RE.test(scope));
57
+ }
58
+
59
+ export function isValidFile(file) {
60
+ return typeof file === 'string' && FILE_RE.test(file);
61
+ }
62
+
63
+ /**
64
+ * `{scope,file}` → 绝对路径。**不合法就返回空串**(调用方决定报什么错)。
65
+ *
66
+ * 注意它只可能落在三处:全局正文、全局索引、某 slug 的 lessons/ 下。
67
+ * 别的路径**在这个函数里无法被构造出来**。
68
+ */
69
+ export function resolveEntryPath(scope, file) {
70
+ if (!isValidScope(scope) || !isValidFile(file)) return '';
71
+ // ⚠️ 这里必须用 endsWith 而不是 ===:全局两篇是**固定文件名**(GLOBAL.md / INDEX.md),
72
+ // 但 isValidFile 只保证"是个合法的 .md 文件名",不保证叫这两个名字。
73
+ // 写 === '.md' 会让所有全局条目一律解析失败(第一版就是这么写错的,测出来的)。
74
+ if (scope === SCOPE_GLOBAL) return file.toLowerCase() === 'global.md' ? MEMORY_GLOBAL_FILE : '';
75
+ if (scope === SCOPE_GLOBAL_INDEX) return file.toLowerCase() === 'index.md' ? MEMORY_INDEX_FILE : '';
76
+ return path.join(MEMORY_PROJECTS_DIR, scope, 'lessons', file);
77
+ }
78
+
79
+ async function statOrNull(p) {
80
+ try { return await fs.stat(p); } catch { return null; }
81
+ }
82
+
83
+ /**
84
+ * 列出一个 scope 下的条目。
85
+ *
86
+ * @param {string} scope 'global' | 'global-index' | <slug>
87
+ * @returns {Promise<Array<{file, title, size, mtime, indexed: boolean, summary: string}>>}
88
+ * mtime 倒序(最新在前),与 ExecutionLogManager 的列表口径一致。
89
+ */
90
+ export async function listEntries(scope) {
91
+ if (scope === SCOPE_GLOBAL || scope === SCOPE_GLOBAL_INDEX) {
92
+ const abs = scope === SCOPE_GLOBAL ? MEMORY_GLOBAL_FILE : MEMORY_INDEX_FILE;
93
+ const st = await statOrNull(abs);
94
+ if (!st || !st.isFile()) return [];
95
+ return [{
96
+ file: path.basename(abs),
97
+ title: scope === SCOPE_GLOBAL ? 'GLOBAL.md' : 'INDEX.md',
98
+ size: st.size,
99
+ mtime: st.mtimeMs,
100
+ // 全局两篇是"索引本身",条目字段不适用
101
+ indexed: true,
102
+ summary: '',
103
+ }];
104
+ }
105
+
106
+ if (!isValidScope(scope)) return [];
107
+ const dir = path.join(MEMORY_PROJECTS_DIR, scope, 'lessons');
108
+ let names;
109
+ try {
110
+ names = await fs.readdir(dir, { withFileTypes: true });
111
+ } catch {
112
+ return [];
113
+ }
114
+
115
+ // 索引里出现过的文件名 → 这条经验是有索引的(没索引的属于"孤儿",界面要标出来)
116
+ const indexed = await readIndexedFiles(scope);
117
+
118
+ const out = [];
119
+ for (const e of names) {
120
+ if (!e.isFile()) continue;
121
+ if (e.name.startsWith('.')) continue;
122
+ if (!isValidFile(e.name)) continue;
123
+ if (out.length >= LIST_MAX_ENTRIES) break;
124
+ const st = await statOrNull(path.join(dir, e.name));
125
+ if (!st) continue;
126
+ out.push({
127
+ file: e.name,
128
+ title: e.name.slice(0, -LESSON_EXT.length),
129
+ size: st.size,
130
+ mtime: st.mtimeMs,
131
+ indexed: indexed.has(e.name),
132
+ summary: '',
133
+ });
134
+ }
135
+ out.sort((a, b) => b.mtime - a.mtime);
136
+ return out;
137
+ }
138
+
139
+ /** 从项目 INDEX.md 里抽出被索引到的 lesson 文件名集合。解析失败时返回空集。 */
140
+ async function readIndexedFiles(scope) {
141
+ const set = new Set();
142
+ try {
143
+ const text = await fs.readFile(path.join(MEMORY_PROJECTS_DIR, scope, 'INDEX.md'), 'utf8');
144
+ for (const m of text.matchAll(/\((lessons\/[^)]+)\)/g)) {
145
+ const name = m[1].slice('lessons/'.length);
146
+ if (isValidFile(name)) set.add(name);
147
+ }
148
+ } catch { /* 索引读不到就当作"都没有索引",界面会提示补索引 */ }
149
+ return set;
150
+ }
151
+
152
+ /** 从 INDEX.md 的一行里取出标题与摘要,供界面直接显示(省得读正文) */
153
+ export function parseIndexLine(line) {
154
+ const m = /^\s*-\s*\[([^\]]+)\]\([^)]+\)\s*(?:[—-]\s*(.*))?$/.exec(line);
155
+ if (!m) return null;
156
+ return { title: m[1].trim(), summary: (m[2] || '').trim() };
157
+ }
158
+
159
+ /**
160
+ * 列出全部 scope(给界面的项目切换器用)。
161
+ *
162
+ * slug → 展示名:优先用项目 INDEX.md 头里记的仓库路径(那才是人能认出的东西),
163
+ * 退化时显示 slug 本体。**绝不在这里暴露绝对路径之外的东西** ——
164
+ * 仓库路径本来就存在 INDEX.md 里,是给用户看的,不是秘密。
165
+ */
166
+ export async function listScopes() {
167
+ const out = [];
168
+ for (const [scope, label] of [[SCOPE_GLOBAL, 'GLOBAL'], [SCOPE_GLOBAL_INDEX, 'INDEX']]) {
169
+ const st = await statOrNull(scope === SCOPE_GLOBAL ? MEMORY_GLOBAL_FILE : MEMORY_INDEX_FILE);
170
+ if (st && st.isFile()) out.push({ scope, label, repoPath: '', count: 1, mtime: st.mtimeMs });
171
+ }
172
+
173
+ let names = [];
174
+ try { names = await fs.readdir(MEMORY_PROJECTS_DIR, { withFileTypes: true }); } catch { names = []; }
175
+
176
+ for (const e of names) {
177
+ if (!e.isDirectory() || !isValidScope(e.name)) continue;
178
+ const entries = await listEntries(e.name);
179
+ const idx = path.join(MEMORY_PROJECTS_DIR, e.name, 'INDEX.md');
180
+ let repoPath = '';
181
+ try {
182
+ const m = /^>\s*仓库:\s*`(.+?)`/m.exec(await fs.readFile(idx, 'utf8'));
183
+ if (m) repoPath = m[1];
184
+ } catch { /* 头没写就退化显示 slug */ }
185
+ out.push({
186
+ scope: e.name,
187
+ label: repoPath || e.name,
188
+ repoPath,
189
+ count: entries.length,
190
+ mtime: entries.length ? entries[0].mtime : 0,
191
+ });
192
+ }
193
+ // 最新的在前;全局两篇永远排最上(它们是"所有项目都适用"的那部分)
194
+ out.sort((a, b) => (a.scope === SCOPE_GLOBAL || a.scope === SCOPE_GLOBAL_INDEX ? -1 : 1)
195
+ - (b.scope === SCOPE_GLOBAL || b.scope === SCOPE_GLOBAL_INDEX ? -1 : 1) || b.mtime - a.mtime);
196
+ return out;
197
+ }
198
+
199
+ /** 读一条正文;不存在返回 {missing:true},过大返回 {tooLarge:true}。都**不抛错**。 */
200
+ export async function readEntry(scope, file) {
201
+ const abs = resolveEntryPath(scope, file);
202
+ if (!abs) return { error: 'invalid' };
203
+ let st;
204
+ try { st = await fs.stat(abs); } catch { return { missing: true }; }
205
+ if (!st.isFile()) return { missing: true };
206
+ if (st.size > ENTRY_MAX_BYTES) return { tooLarge: true, size: st.size };
207
+ try {
208
+ return { content: await fs.readFile(abs, 'utf8'), size: st.size, mtime: st.mtimeMs };
209
+ } catch {
210
+ return { missing: true };
211
+ }
212
+ }
213
+
214
+ /**
215
+ * 删一条。
216
+ *
217
+ * ⚠️ 删 lesson 会**连带删掉它在 INDEX.md 里的那一行** —— 留着就是一条指向
218
+ * 死链的索引(Agent 命中它会去读一个不存在的文件)。这是"删除"语义完整的
219
+ * 一部分,不是副作用。`archive/` 里的同名文件不动。
220
+ *
221
+ * @returns {Promise<{removed: boolean, indexLineRemoved: boolean, alreadyGone: boolean}>}
222
+ */
223
+ export async function deleteEntry(scope, file) {
224
+ const abs = resolveEntryPath(scope, file);
225
+ if (!abs) return { error: 'invalid' };
226
+
227
+ let alreadyGone = false;
228
+ try {
229
+ await fs.unlink(abs);
230
+ } catch (err) {
231
+ if (err.code === 'ENOENT') alreadyGone = true; // 幂等:本来就没有也算成功
232
+ else throw err;
233
+ }
234
+
235
+ let indexLineRemoved = false;
236
+ if (isValidScope(scope) && scope !== SCOPE_GLOBAL && scope !== SCOPE_GLOBAL_INDEX) {
237
+ indexLineRemoved = await removeIndexLine(scope, file);
238
+ }
239
+ return { removed: !alreadyGone, indexLineRemoved, alreadyGone };
240
+ }
241
+
242
+ /** 从项目 INDEX.md 里删掉指向某个 lesson 的那一行。读不到/没这行都算无事发生。 */
243
+ async function removeIndexLine(scope, file) {
244
+ const idx = path.join(MEMORY_PROJECTS_DIR, scope, 'INDEX.md');
245
+ let text;
246
+ try { text = await fs.readFile(idx, 'utf8'); } catch { return false; }
247
+ const needle = `(lessons/${file})`;
248
+ if (!text.includes(needle)) return false;
249
+ // 逐行过滤而不是 replace:needle 只在行首的链接形态里出现,
250
+ // 行级过滤天然不会误伤正文里恰好提到同一路径的段落。
251
+ const kept = text.split(/\r?\n/).filter((l) => !l.includes(needle));
252
+ if (kept.length === text.split(/\r?\n/).length) return false;
253
+ await writeTextAtomic(idx, kept.join('\n'));
254
+ return true;
255
+ }
256
+
257
+ /** 原子写:tmp + rename。直接 writeFile 会和读者(正在渲染的面板)撞出半截内容。 */
258
+ async function writeTextAtomic(file, content) {
259
+ const tmp = `${file}.${process.pid}.tmp`;
260
+ try {
261
+ await fs.writeFile(tmp, content, 'utf8');
262
+ await fs.rename(tmp, file);
263
+ } catch (err) {
264
+ await fs.unlink(tmp).catch(() => {});
265
+ throw err;
266
+ }
267
+ }
268
+
269
+ /** 记忆库是否已就位(目录在不在) */
270
+ export async function memoryDirExists() {
271
+ const st = await statOrNull(MEMORY_DIR);
272
+ return !!(st && st.isDirectory());
273
+ }
@@ -0,0 +1,187 @@
1
+ // library.js 的单测。
2
+ //
3
+ // ⚠️ paths.js 在**模块加载时**由 os.homedir() 求值,USERPROFILE/HOME 必须
4
+ // 在 import 之前改(Windows 上还要删 HOMEDRIVE/HOMEPATH),否则会写进用户真实的
5
+ // ~/.zen-gitsync/memory。
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import fsp from 'node:fs/promises';
9
+ import { test, beforeEach, after } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+
12
+ const SANDBOX = await fsp.mkdtemp(path.join(os.tmpdir(), 'zen-memory-lib-'));
13
+ for (const k of ['HOMEDRIVE', 'HOMEPATH']) delete process.env[k];
14
+ process.env.USERPROFILE = SANDBOX;
15
+ process.env.HOME = SANDBOX;
16
+
17
+ const { MEMORY_DIR } = await import('../paths.js');
18
+ const lib = await import('./library.js');
19
+ const { projectSlug } = await import('./store.js');
20
+
21
+ after(() => fsp.rm(SANDBOX, { recursive: true, force: true }));
22
+
23
+ const SLUG = projectSlug('C:\\ws\\zen-gitsync');
24
+ const PROJ_DIR = path.join(MEMORY_DIR, 'projects', SLUG);
25
+
26
+ /** 铺一个最小可用的库:全局两篇 + 一个项目里两条有索引 + 一条孤儿经验 */
27
+ async function seed() {
28
+ await fsp.mkdir(path.join(PROJ_DIR, 'lessons'), { recursive: true });
29
+ await fsp.writeFile(path.join(MEMORY_DIR, 'INDEX.md'), '# 记忆索引 · 全局\n', 'utf8');
30
+ await fsp.writeFile(path.join(MEMORY_DIR, 'GLOBAL.md'), '# 跨项目经验\n', 'utf8');
31
+ await fsp.writeFile(
32
+ path.join(PROJ_DIR, 'INDEX.md'),
33
+ '# 记忆索引 · 本项目\n\n## 主题\n- [双落盘路径](lessons/jobs-persistence.md) — jobs.json 只在终态 flush\n- [探针假绿](lessons/probe.md) — 别信恒真断言\n',
34
+ 'utf8'
35
+ );
36
+ await fsp.writeFile(path.join(PROJ_DIR, 'lessons', 'jobs-persistence.md'), '# 双落盘路径\n\n内容\n', 'utf8');
37
+ await fsp.writeFile(path.join(PROJ_DIR, 'lessons', 'probe.md'), '# 探针假绿\n\n内容\n', 'utf8');
38
+ // 一条**没有索引行**的孤儿经验:界面上要能标出来
39
+ await fsp.writeFile(path.join(PROJ_DIR, 'lessons', 'orphan.md'), '# 孤儿\n', 'utf8');
40
+ }
41
+
42
+ beforeEach(seed);
43
+
44
+ // ── 形状校验:这是本文件的安全边界,不是可选加固 ─────────────────────
45
+ test('isValidScope:只认两种特殊值与「可读部分+6位哈希」', () => {
46
+ assert.equal(lib.isValidScope('global'), true);
47
+ assert.equal(lib.isValidScope('global-index'), true);
48
+ assert.equal(lib.isValidScope(SLUG), true);
49
+ assert.equal(lib.isValidScope(SLUG.replace(/-[0-9a-f]{6}$/, '')), false, '缺哈希后缀');
50
+ assert.equal(lib.isValidScope(SLUG.replace(/-[0-9a-f]{6}$/, '-ZZZZZZ')), false, '哈希必须十六进制');
51
+ assert.equal(lib.isValidScope(''), false);
52
+ assert.equal(lib.isValidScope(null), false);
53
+ assert.equal(lib.isValidScope('../..'), false);
54
+ assert.equal(lib.isValidScope('a/../b-abcdef'), false);
55
+ });
56
+
57
+ test('isValidFile:kebab 文件名 + .md,挡掉 . 与 ..', () => {
58
+ assert.equal(lib.isValidFile('jobs-persistence.md'), true);
59
+ assert.equal(lib.isValidFile('a_b-c.1.md'), true);
60
+ assert.equal(lib.isValidFile('..'), false);
61
+ assert.equal(lib.isValidFile('../evil.md'), false);
62
+ assert.equal(lib.isValidFile('a/b.md'), false);
63
+ assert.equal(lib.isValidFile('a\\b.md'), false);
64
+ assert.equal(lib.isValidFile('evil.txt'), false);
65
+ assert.equal(lib.isValidFile('.hidden.md'), false);
66
+ assert.equal(lib.isValidFile(''), false);
67
+ assert.equal(lib.isValidFile('x'.repeat(200) + '.md'), false);
68
+ });
69
+
70
+ // ── ⚠️ 越界:拼不出 MEMORY_DIR 之外的任何路径 ────────────────────────
71
+ test('⚠️ resolveEntryPath:非法入参一律返回空串(结构性防越界)', () => {
72
+ const evil = [
73
+ ['../../.claude/settings.json', 'settings.json'],
74
+ ['global', '../config.json'],
75
+ [SLUG, '../../../config.json'],
76
+ [SLUG, '../../../../.ssh/id_rsa'],
77
+ ];
78
+ for (const [scope, file] of evil) {
79
+ assert.equal(lib.resolveEntryPath(scope, file), '', `${scope}/${file} 不该解析出路径`);
80
+ }
81
+ });
82
+
83
+ test('resolveEntryPath:合法入参只落在三处', () => {
84
+ assert.equal(lib.resolveEntryPath('global', 'GLOBAL.md'), path.join(MEMORY_DIR, 'GLOBAL.md'));
85
+ assert.equal(lib.resolveEntryPath('global-index', 'INDEX.md'), path.join(MEMORY_DIR, 'INDEX.md'));
86
+ assert.equal(
87
+ lib.resolveEntryPath(SLUG, 'probe.md'),
88
+ path.join(MEMORY_DIR, 'projects', SLUG, 'lessons', 'probe.md')
89
+ );
90
+ // global 只认 GLOBAL.md 本体,别的文件名不给
91
+ assert.equal(lib.resolveEntryPath('global', 'INDEX.md'), '');
92
+ });
93
+
94
+ test('⚠️ resolveEntryPath 的产物必须在 MEMORY_DIR 之内(回归守卫)', () => {
95
+ for (const scope of ['global', 'global-index', SLUG]) {
96
+ for (const file of ['a.md', 'probe.md', 'UPPER.MD']) {
97
+ const abs = lib.resolveEntryPath(scope, file);
98
+ if (!abs) continue;
99
+ const rel = path.relative(MEMORY_DIR, abs);
100
+ assert.ok(rel && !rel.startsWith('..') && !path.isAbsolute(rel),
101
+ `${abs} 逃出了 MEMORY_DIR`);
102
+ }
103
+ }
104
+ });
105
+
106
+ // ── 列表 ────────────────────────────────────────────────────────────
107
+ test('listEntries:列出 lessons 并标出哪些有索引', async () => {
108
+ const list = await lib.listEntries(SLUG);
109
+ const byName = Object.fromEntries(list.map(e => [e.file, e]));
110
+ assert.equal(list.length, 3);
111
+ assert.equal(byName['jobs-persistence.md'].indexed, true);
112
+ assert.equal(byName['probe.md'].indexed, true);
113
+ assert.equal(byName['orphan.md'].indexed, false, '没有索引行的要标出来');
114
+ assert.equal(byName['jobs-persistence.md'].title, 'jobs-persistence');
115
+ });
116
+
117
+ test('listEntries:slug 不存在 / 非法时返回空数组而不是抛错', async () => {
118
+ assert.deepEqual(await lib.listEntries('no-such-slug-abcdef'), []);
119
+ assert.deepEqual(await lib.listEntries('../evil'), []);
120
+ });
121
+
122
+ test('listScopes:全局两篇 + 项目,且项目带仓库路径', async () => {
123
+ const scopes = await lib.listScopes();
124
+ const s = scopes.find(x => x.scope === SLUG);
125
+ assert.ok(s, '项目 scope 应在列表里');
126
+ assert.equal(s.count, 3);
127
+ const labels = scopes.map(x => x.scope);
128
+ assert.ok(labels.includes('global'));
129
+ assert.ok(labels.includes('global-index'));
130
+ assert.ok(['global', 'global-index'].includes(scopes[0].scope), '全局两篇永远排最上');
131
+ });
132
+
133
+ test('parseIndexLine:从索引行里取出标题与摘要', () => {
134
+ assert.deepEqual(lib.parseIndexLine('- [双落盘路径](lessons/x.md) — 只在终态 flush'),
135
+ { title: '双落盘路径', summary: '只在终态 flush' });
136
+ assert.deepEqual(lib.parseIndexLine('- [探针](lessons/y.md)'), { title: '探针', summary: '' });
137
+ assert.equal(lib.parseIndexLine('## 主题'), null);
138
+ assert.equal(lib.parseIndexLine('随便一行'), null);
139
+ });
140
+
141
+ // ── 读 ──────────────────────────────────────────────────────────────
142
+ test('readEntry:正常读 / 不存在 / 超大 / 非法', async () => {
143
+ const ok = await lib.readEntry(SLUG, 'probe.md');
144
+ assert.match(ok.content, /探针假绿/);
145
+ assert.equal((await lib.readEntry(SLUG, 'nope.md')).missing, true);
146
+ assert.equal((await lib.readEntry('../evil', 'x.md')).error, 'invalid');
147
+ });
148
+
149
+ // ── 删:连带清索引行(不留死链)──────────────────────────────────
150
+ test('deleteEntry:删文件并连带删掉 INDEX.md 里那一行', async () => {
151
+ const r = await lib.deleteEntry(SLUG, 'probe.md');
152
+ assert.equal(r.removed, true);
153
+ assert.equal(r.indexLineRemoved, true);
154
+ await assert.rejects(fsp.access(path.join(PROJ_DIR, 'lessons', 'probe.md')));
155
+ const idx = await fsp.readFile(path.join(PROJ_DIR, 'INDEX.md'), 'utf8');
156
+ assert.ok(!idx.includes('probe.md'), '索引行必须一起删 —— 留着就是死链');
157
+ assert.ok(idx.includes('jobs-persistence.md'), '别的行不能被误伤');
158
+ });
159
+
160
+ test('deleteEntry:重复删是幂等的(alreadyGone)', async () => {
161
+ await lib.deleteEntry(SLUG, 'probe.md');
162
+ const again = await lib.deleteEntry(SLUG, 'probe.md');
163
+ assert.equal(again.alreadyGone, true);
164
+ assert.equal(again.removed, false);
165
+ });
166
+
167
+ test('deleteEntry:非法入参不删任何东西', async () => {
168
+ for (const [scope, file] of [['../evil', 'x.md'], [SLUG, '../../../config.json'], [SLUG, '..']]) {
169
+ const r = await lib.deleteEntry(scope, file);
170
+ assert.equal(r.error, 'invalid', `${scope}/${file} 应被拒`);
171
+ }
172
+ assert.ok((await lib.listEntries(SLUG)).length >= 3, '种子文件不该被动过');
173
+ });
174
+
175
+ test('deleteEntry:删孤儿(无索引行)不算失败,indexLineRemoved=false', async () => {
176
+ const r = await lib.deleteEntry(SLUG, 'orphan.md');
177
+ assert.equal(r.removed, true);
178
+ assert.equal(r.indexLineRemoved, false, '本来就没索引行可删');
179
+ });
180
+
181
+ test('deleteEntry 不碰 archive/ 里的同名文件', async () => {
182
+ await fsp.mkdir(path.join(PROJ_DIR, 'archive'), { recursive: true });
183
+ const archived = path.join(PROJ_DIR, 'archive', 'probe.md');
184
+ await fsp.writeFile(archived, '历史', 'utf8');
185
+ await lib.deleteEntry(SLUG, 'probe.md');
186
+ assert.equal(await fsp.readFile(archived, 'utf8'), '历史', 'archive 是冻结历史,不该被动');
187
+ });