zen-gitsync 2.17.24 → 2.17.26
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/README.md +6 -0
- package/package.json +6 -3
- package/scripts/release.js +8 -10
- package/scripts/run-tests.cjs +5 -0
- package/src/cli/ai/agent.js +20 -0
- package/src/cli/ai/termui.js +73 -1
- package/src/cli/ai/termui.test.js +51 -0
- package/src/cli/ai/tools.js +116 -1
- package/src/cli/ai/tools.test.js +65 -1
- package/src/cli/ai/turn.js +3 -0
- package/src/memory/library.js +273 -0
- package/src/memory/library.test.js +187 -0
- package/src/memory/store.js +199 -0
- package/src/memory/store.test.js +137 -0
- package/src/memory/templates.js +113 -0
- package/src/paths.js +18 -0
- package/src/ui/public/assets/{AgentEngineSelector-CvIoGCLD.js → AgentEngineSelector-CEayBStS.js} +1 -1
- package/src/ui/public/assets/{AgentView-DnN-vCMi.css → AgentView-B3LhhRsy.css} +1 -1
- package/src/ui/public/assets/{AgentView-BrqS78Rt.js → AgentView-BhLlAvng.js} +1 -1
- package/src/ui/public/assets/{AppVersionBadge-BHxZm7-h.js → AppVersionBadge-btxeyJpH.js} +2 -2
- package/src/ui/public/assets/{BranchSelector-w5ybmWVv.js → BranchSelector-ERybqfLW.js} +1 -1
- package/src/ui/public/assets/{CommitForm-pRCnAfz6.js → CommitForm-DcXdYwKn.js} +1 -1
- package/src/ui/public/assets/{CommonDialog-qXAsFhNS.js → CommonDialog-5bwugMhs.js} +1 -1
- package/src/ui/public/assets/EditorView-BzxNDG8A.js +1 -0
- package/src/ui/public/assets/{EditorView-C6HFYeEM.css → EditorView-DqxBkn6c.css} +1 -1
- package/src/ui/public/assets/{FileDiffViewer-Bc8j9cQn.js → FileDiffViewer-BBOtL3bR.js} +1 -1
- package/src/ui/public/assets/{FlowExecutionViewer-CF4HkGu6.js → FlowExecutionViewer-BPMDn3Md.js} +1 -1
- package/src/ui/public/assets/{FlowOrchestrationWorkspace-a3Zk4BMK.js → FlowOrchestrationWorkspace-PtCM4LB1.js} +1 -1
- package/src/ui/public/assets/{IconButton-BjIHmE4p.js → IconButton-BELiW6cx.js} +1 -1
- package/src/ui/public/assets/{LogList-CqpSAW5T.js → LogList-BAD5dV_N.js} +1 -1
- package/src/ui/public/assets/{MindmapView-CgptM54l.js → MindmapView-BAwb7Gs1.js} +1 -1
- package/src/ui/public/assets/{MonitorView-qb0EPvQW.js → MonitorView-DIqKQfiM.js} +1 -1
- package/src/ui/public/assets/{ProjectStartupButton-CmankQtk.js → ProjectStartupButton-DnQyvZOG.js} +1 -1
- package/src/ui/public/assets/{RecentDirectoriesChat-CsHHckPP.js → RecentDirectoriesChat-BMZA0DgP.js} +1 -1
- package/src/ui/public/assets/RecentDirectoriesChat-o4xK1Koc.css +1 -0
- package/src/ui/public/assets/{RemoteManagerDialog-Cpg4g5KW.js → RemoteManagerDialog-BmdcIfwA.js} +1 -1
- package/src/ui/public/assets/{RemoteRepoCard-DCc-adLb.js → RemoteRepoCard-DqKIIcGh.js} +1 -1
- package/src/ui/public/assets/{SourceMapView-I41XvvZr.js → SourceMapView-Sd_871DD.js} +1 -1
- package/src/ui/public/assets/{SvgIcon-BB80ERhO.js → SvgIcon-DyQoHzNw.js} +1 -1
- package/src/ui/public/assets/{TemplateManager-BB_bEiBB.js → TemplateManager-BI1EXn7d.js} +1 -1
- package/src/ui/public/assets/{UserInputNode-y9gz0EKH.js → UserInputNode-3qtC_swg.js} +1 -1
- package/src/ui/public/assets/{WorkbenchView-Bkzbu5Sp.css → WorkbenchView-BNxybEqF.css} +1 -1
- package/src/ui/public/assets/WorkbenchView-wAdnj8Kl.js +6 -0
- package/src/ui/public/assets/_plugin-vue_export-helper-CGArUtKL.js +19 -0
- package/src/ui/public/assets/agentConversations-cmS8iux5.js +5 -0
- package/src/ui/public/assets/{configStore-BR8vEJCp.js → configStore-Vh8LZWlL.js} +1 -1
- package/src/ui/public/assets/{gitStore-Bz77W-S0.js → gitStore-B9gZQf2q.js} +1 -1
- package/src/ui/public/assets/index-BZFKDSGv.css +1 -0
- package/src/ui/public/assets/index-DE-31lcL.js +29 -0
- package/src/ui/public/assets/{previewSandbox-luPexzF4.js → previewSandbox-DmecmT9g.js} +1 -1
- package/src/ui/public/assets/{toolsStore-CeI7jM5o.js → toolsStore-DgCBDdhm.js} +1 -1
- package/src/ui/public/assets/vendor-BY5ZDlW1.css +1 -0
- package/src/ui/public/assets/{vendor-CyWsJd6S.js → vendor-BjomZKSS.js} +28 -120
- package/src/ui/public/index.html +14 -14
- package/src/ui/server/index.js +4 -0
- package/src/ui/server/routes/fileOpen.js +43 -7
- package/src/ui/server/routes/memory.js +125 -0
- package/src/ui/server/routes/workbench/agentChat.js +35 -3
- package/src/ui/server/routes/workbench/agentChatShared.test.js +49 -0
- package/src/ui/server/routes/workbench/dispatchInstruction.js +8 -6
- package/src/ui/server/routes/workbench/dispatchInstruction.test.js +29 -2
- package/src/ui/server/routes/workbench/index.js +41 -17
- package/src/ui/server/routes/workbench/memoryContext.js +125 -0
- package/src/ui/server/routes/workbench/memoryContext.test.js +79 -0
- package/src/ui/server/routes/workbench/orchestratorPrompt.test.js +47 -2
- package/src/ui/server/routes/workbench/orchestratorStore.js +29 -1
- package/src/ui/server/routes/workbench/runExternalTurn.js +6 -1
- package/src/ui/server/routes/workbench/shared.js +28 -2
- package/src/ui/server/routes/workbench/taskRunner.js +48 -5
- package/src/utils/selfUpdatePolicy.js +26 -4
- package/src/utils/selfUpdatePolicy.test.js +21 -2
- package/src/ui/public/assets/EditorView-DnuGnX3k.js +0 -1
- package/src/ui/public/assets/RecentDirectoriesChat-vHT8jMxW.css +0 -1
- package/src/ui/public/assets/WorkbenchView-DKqriLBP.js +0 -6
- package/src/ui/public/assets/_plugin-vue_export-helper-Dd_YmKEO.js +0 -19
- package/src/ui/public/assets/agentConversations-CT9E8eQr.js +0 -5
- package/src/ui/public/assets/index-CsLyZsXy.js +0 -29
- package/src/ui/public/assets/index-D2rf9QKB.css +0 -1
- package/src/ui/public/assets/vendor-BzMymUBi.css +0 -1
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,199 @@
|
|
|
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
|
+
// 跨会话「记忆库」的读写层。
|
|
16
|
+
//
|
|
17
|
+
// ── 为什么是"索引 + 正文"两层 ─────────────────────────────────────────
|
|
18
|
+
// 用户原话点破的那件事:「总结要尽可能简洁,或者用索引的方式,不然上下文会太长」。
|
|
19
|
+
// 早期形态是单个 MEMORY.md 全文进上下文,越记越贵、越贵越没人看。所以:
|
|
20
|
+
//
|
|
21
|
+
// INDEX.md 每条 ≤100 字,只有它进 prompt(见 memoryContext.js)
|
|
22
|
+
// lessons/ 正文单条 ≤25 行,Agent 命中关键词才自己 read
|
|
23
|
+
// archive/ 冻结历史,永不读
|
|
24
|
+
//
|
|
25
|
+
// ── 谁在写 ───────────────────────────────────────────────────────────
|
|
26
|
+
// **Agent 自己**,不是服务端。服务端只在 prompt 里给一段指针,Agent 用它自己的
|
|
27
|
+
// 文件工具去读索引、按需读正文、收尾时追加 lesson。理由:
|
|
28
|
+
// · Agent 才知道"这次踩的坑"是什么,服务端只有一堆 IO 事件;
|
|
29
|
+
// · 少一次 LLM 调用(服务端代写就得再起一轮模型,还猜不准该记什么);
|
|
30
|
+
// · Agent 写完立刻能自己验证路径对不对。
|
|
31
|
+
// 服务端只保留两件事:目录不存在时铺种子、算 slug 告诉 Agent 写哪儿。
|
|
32
|
+
//
|
|
33
|
+
// ⚠️ 这个模块**只碰 MEMORY_DIR 下的路径**,一个字节都不写到用户工作区或
|
|
34
|
+
// 第三方工具的配置目录(~/.claude、~/.codex 等)。Agent 按 prompt 里的
|
|
35
|
+
// 指令去写那些文件时是它自己的选择,不是本产品替他改配置。
|
|
36
|
+
|
|
37
|
+
import fs from 'node:fs';
|
|
38
|
+
import fsp from 'node:fs/promises';
|
|
39
|
+
import path from 'node:path';
|
|
40
|
+
import { createHash } from 'node:crypto';
|
|
41
|
+
import { MEMORY_DIR, MEMORY_GLOBAL_FILE, MEMORY_INDEX_FILE, MEMORY_PROJECTS_DIR } from '../paths.js';
|
|
42
|
+
|
|
43
|
+
/** 种子模板:随包发布,首次运行时铺进 MEMORY_DIR(见 templates.js) */
|
|
44
|
+
import { MEMORY_SEED_FILES } from './templates.js';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 仓库绝对路径 → 记忆库里的项目 slug。
|
|
48
|
+
*
|
|
49
|
+
* 形状:`<可读部分>-<路径哈希 6 位>`
|
|
50
|
+
* `C:\workspace\github_workspace\xz333221\zen-gitsync`
|
|
51
|
+
* → `c-workspace-github_workspace-xz333221-zen-gitsync-3f2a1b`
|
|
52
|
+
*
|
|
53
|
+
* ⚠️ **哈希是必须的,不是装饰**(2026-09-29 实测踩到):
|
|
54
|
+
* 只做"小写 + 非 [a-z0-9_] 压成 -"的话,两个不同的中文路径会塌成同一个 slug ——
|
|
55
|
+
* `C:\中文\项目` 与 `C:\中文\别的` → 都是 `c-`
|
|
56
|
+
* 于是两个仓库共用一份 lessons,A 项目的教训会被当成 B 项目的写进 prompt。
|
|
57
|
+
* 这类污染**不报错、不可见**,是记忆系统最坏的一类失效。
|
|
58
|
+
*
|
|
59
|
+
* 哈希前先把 `\` 归一成 `/` 再算:`C:\a\b` 与 `C:/a/b` 是同一个仓库的两个写法,
|
|
60
|
+
* 归一后才能算出同一个 slug(否则同仓库会裂成两份记忆)。
|
|
61
|
+
*
|
|
62
|
+
* ⚠️ 两条纪律(改这个函数前先读):
|
|
63
|
+
* 1. **纯函数,不碰文件系统** —— 单测要能直接钉住映射规则,不许夹带 stat。
|
|
64
|
+
* 2. **规则一改 = 全量历史记忆失联**(目录名变了)。所以要改必须同时带一份
|
|
65
|
+
* 重命名迁移;宁可永远不改规则也不要"顺手优化一下"。
|
|
66
|
+
*
|
|
67
|
+
* 保留 `_`(不下划线)是为了让 slug 读起来还认得出仓库名。
|
|
68
|
+
*/
|
|
69
|
+
export function projectSlug(repoPath) {
|
|
70
|
+
if (typeof repoPath !== 'string') return '';
|
|
71
|
+
const s = repoPath.trim();
|
|
72
|
+
if (!s) return '';
|
|
73
|
+
// 分隔符先归一,让同一个仓库的两种写法落到同一个 slug
|
|
74
|
+
const normalized = s.replace(/\\/g, '/').toLowerCase();
|
|
75
|
+
const readable = normalized
|
|
76
|
+
.replace(/[^a-z0-9_]+/g, '-')
|
|
77
|
+
.replace(/^-+|-+$/g, '');
|
|
78
|
+
// 哈希走归一后的路径:大小写与分隔符差异不该让同一个仓库裂成两份
|
|
79
|
+
const hash = createHash('sha1').update(normalized).digest('hex').slice(0, 6);
|
|
80
|
+
return `${readable || 'root'}-${hash}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 某仓库的项目目录绝对路径(不保证存在 —— 存在性由 ensureProjectDir 负责) */
|
|
84
|
+
export function projectMemoryDir(repoPath) {
|
|
85
|
+
const slug = projectSlug(repoPath);
|
|
86
|
+
return slug ? path.join(MEMORY_PROJECTS_DIR, slug) : '';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 某仓库的项目索引绝对路径 */
|
|
90
|
+
export function projectIndexFile(repoPath) {
|
|
91
|
+
const dir = projectMemoryDir(repoPath);
|
|
92
|
+
return dir ? path.join(dir, 'INDEX.md') : '';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 读一个文本文件;不存在返回 '',读失败也返回 ''(记忆读不到不该影响任何事) */
|
|
96
|
+
async function readTextOrEmpty(file) {
|
|
97
|
+
if (!file) return '';
|
|
98
|
+
try {
|
|
99
|
+
return await fsp.readFile(file, 'utf8');
|
|
100
|
+
} catch {
|
|
101
|
+
return '';
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 幂等铺种子:目录不存在时把 MEMORY_SEED_FILES 写进去。
|
|
107
|
+
*
|
|
108
|
+
* **已存在的文件一律不覆盖** —— 用户(和 Agent)可能在里面写过东西,
|
|
109
|
+
* 每次启动都重铺等于把记忆洗回出厂设置。
|
|
110
|
+
*
|
|
111
|
+
* @returns {Promise<{created: string[], existed: number}>} created = 实际新建的文件名
|
|
112
|
+
*/
|
|
113
|
+
export async function ensureMemoryStore() {
|
|
114
|
+
await fsp.mkdir(MEMORY_DIR, { recursive: true });
|
|
115
|
+
await fsp.mkdir(path.join(MEMORY_DIR, 'archive'), { recursive: true });
|
|
116
|
+
|
|
117
|
+
let existed = 0;
|
|
118
|
+
const created = [];
|
|
119
|
+
for (const [rel, content] of Object.entries(MEMORY_SEED_FILES)) {
|
|
120
|
+
const target = path.join(MEMORY_DIR, ...rel.split('/'));
|
|
121
|
+
try {
|
|
122
|
+
await fsp.access(target);
|
|
123
|
+
existed += 1;
|
|
124
|
+
} catch {
|
|
125
|
+
await fsp.mkdir(path.dirname(target), { recursive: true });
|
|
126
|
+
await fsp.writeFile(target, content, 'utf8');
|
|
127
|
+
created.push(rel);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { created, existed };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 确保某仓库的项目目录存在,并返回它的索引路径。
|
|
135
|
+
* 索引不存在就铺一份**带 path 头**的骨架 —— 头里记着仓库绝对路径,
|
|
136
|
+
* 这样即便将来 slug 规则变了,人/Agent 还能从 INDEX.md 认回来。
|
|
137
|
+
*
|
|
138
|
+
* @returns {Promise<string>} 项目索引绝对路径;repoPath 非法时返回 ''
|
|
139
|
+
*/
|
|
140
|
+
export async function ensureProjectMemory(repoPath) {
|
|
141
|
+
const dir = projectMemoryDir(repoPath);
|
|
142
|
+
if (!dir) return '';
|
|
143
|
+
await fsp.mkdir(path.join(dir, 'lessons'), { recursive: true });
|
|
144
|
+
await fsp.mkdir(path.join(dir, 'archive'), { recursive: true });
|
|
145
|
+
|
|
146
|
+
const idx = path.join(dir, 'INDEX.md');
|
|
147
|
+
try {
|
|
148
|
+
await fsp.access(idx);
|
|
149
|
+
} catch {
|
|
150
|
+
await fsp.writeFile(idx, renderProjectIndexHeader(repoPath), 'utf8');
|
|
151
|
+
}
|
|
152
|
+
return idx;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 新项目索引的头部。抽成函数是为了单测能直接断言首行格式。 */
|
|
156
|
+
export function renderProjectIndexHeader(repoPath) {
|
|
157
|
+
return [
|
|
158
|
+
'# 记忆索引 · 本项目',
|
|
159
|
+
'',
|
|
160
|
+
`> 仓库:\`${repoPath}\``,
|
|
161
|
+
'> 预算:≤40 条 / ≤6KB,**每条 ≤100 字**。**正文在 `lessons/`,只有命中本行才去 Read 对应文件。**',
|
|
162
|
+
'> 条目只写"先查什么 / 以后怎么做",不写"我做了什么"。单条 >25 行 → 那是文档,踢去项目 `docs/`。',
|
|
163
|
+
'',
|
|
164
|
+
'## 主题',
|
|
165
|
+
'',
|
|
166
|
+
].join('\n');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** 全局索引内容(可能为空串) */
|
|
170
|
+
export function readGlobalIndex() {
|
|
171
|
+
return readTextOrEmpty(MEMORY_INDEX_FILE);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 跨项目经验正文(可能为空串) */
|
|
175
|
+
export function readGlobalMemory() {
|
|
176
|
+
return readTextOrEmpty(MEMORY_GLOBAL_FILE);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 某仓库的项目索引内容(可能为空串) */
|
|
180
|
+
export function readProjectIndex(repoPath) {
|
|
181
|
+
return readTextOrEmpty(projectIndexFile(repoPath));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 记忆库是否"可用"。
|
|
186
|
+
*
|
|
187
|
+
* 判据刻意很弱:只要 MEMORY_DIR 存在就算可用。因为产品默认**不预填任何经验**,
|
|
188
|
+
* 一个只有种子文件、没有一条 lesson 的空库也该被指针照常指向 ——
|
|
189
|
+
* 否则第一次派发时 Agent 看不到"有个记忆库存在",永远写不出第一条。
|
|
190
|
+
* 首次写入的活由 ensureMemoryStore() 兜。
|
|
191
|
+
*/
|
|
192
|
+
export function memoryAvailable() {
|
|
193
|
+
try {
|
|
194
|
+
fs.accessSync(MEMORY_DIR);
|
|
195
|
+
return true;
|
|
196
|
+
} catch {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
}
|