dsh-plugin-tool-management 0.10.0 → 0.11.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.
- package/CHANGELOG.md +71 -1
- package/README.md +64 -49
- package/README_EN.md +58 -37
- package/docs/images/1-EN.png +0 -0
- package/docs/images/1.png +0 -0
- package/docs/images/2-EN.png +0 -0
- package/docs/images/2.png +0 -0
- package/docs/images/3-EN.png +0 -0
- package/docs/images/3.png +0 -0
- package/docs/images/4-EN.png +0 -0
- package/docs/images/4.png +0 -0
- package/docs/images/5-EN.png +0 -0
- package/docs/images/5.png +0 -0
- package/docs/images/6-EN.png +0 -0
- package/docs/images/6.png +0 -0
- package/docs/images/7-EN.png +0 -0
- package/docs/images/7.png +0 -0
- package/docs/images/8-EN.png +0 -0
- package/docs/images/8.png +0 -0
- package/docs/update.md +105 -12
- package/lib/client.js +2763 -546
- package/lib/compat/preset-reach.js +1 -10
- package/lib/compat/probe.js +158 -20
- package/lib/context-inject.js +11 -0
- package/lib/host-names.js +12 -0
- package/lib/http-fence.js +35 -15
- package/lib/hub.js +28 -2
- package/lib/imports/parsers.js +15 -9
- package/lib/imports/upload.js +43 -4
- package/lib/index.js +804 -3887
- package/lib/mcp/loader-token.js +238 -0
- package/lib/mcp/manager.js +1681 -0
- package/lib/mcp/override-blocks.js +10 -3
- package/lib/mcp/patch-yaml.js +351 -0
- package/lib/mcp/secret-guard.js +145 -0
- package/lib/{rules → memories}/archive-engine.js +1 -1
- package/lib/{rules → memories}/archive.js +1 -1
- package/lib/memories/constants.js +128 -0
- package/lib/memories/index-io.js +330 -0
- package/lib/memories/projection.js +280 -0
- package/lib/memories/service.js +686 -0
- package/lib/memories/snapshot.js +672 -0
- package/lib/ops/candidates.js +64 -0
- package/lib/ops/compat.js +136 -0
- package/lib/ops/ctx.js +9 -0
- package/lib/ops/memory.js +678 -0
- package/lib/ops/prompts.js +107 -0
- package/lib/ops/scene-records.js +460 -0
- package/lib/ops/scene-sync.js +17 -0
- package/lib/ops/sessions.js +603 -0
- package/lib/ops/trash.js +140 -0
- package/lib/paths.js +103 -0
- package/lib/prompts/preset-id.js +49 -0
- package/lib/{agents-md → prompts}/service.js +1 -1
- package/lib/request-gate.js +320 -0
- package/lib/scene-prompt-sync.js +4 -4
- package/lib/scenes/candidates.js +344 -0
- package/lib/{history → sessions}/bridge.js +15 -5
- package/lib/sessions/history.js +323 -0
- package/lib/{history → sessions}/tombstone.js +1 -1
- package/lib/{history → sessions}/workspace.js +92 -24
- package/lib/skills/core.js +74 -39
- package/lib/skills/readonly-discovery.js +4 -1
- package/lib/skills/service.js +98 -13
- package/lib/subagents/service.js +199 -55
- package/lib/tools/deps.js +8 -0
- package/lib/tools/mcp.js +110 -0
- package/lib/tools/memory.js +87 -0
- package/lib/tools/prompt.js +70 -0
- package/lib/tools/skills.js +139 -0
- package/lib/tools/subagent.js +40 -0
- package/package.json +7 -7
- package/lib/agents-md/preset-id.js +0 -49
- package/lib/rules/service.js +0 -3078
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
// 规则/记忆域的**发现与快照**(2026-09-19 从 memories/service.ts 抽出)。
|
|
2
|
+
//
|
|
3
|
+
// 两件事:① `discover` 扫 memories/ 目录树得出条目清单(必须自实现,理由见 service.ts 文件头);
|
|
4
|
+
// ② `buildSnapshot` / `renderSceneMemory` 把索引 + 文件内容投影成界面与注入看到的那份快照。
|
|
5
|
+
//
|
|
6
|
+
// 缓存红线在这里落地:段内容只由「启用场景 + 文件内容」决定,不掺时间戳/计数,
|
|
7
|
+
// 场景组合或记忆文件不变 ⇒ 逐字节稳定 ⇒ 前缀缓存命中。
|
|
8
|
+
//
|
|
9
|
+
// 对 service.ts 只做 type-only 引用(`import type`),避免与它形成运行时循环依赖。
|
|
10
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
11
|
+
import { cp, lstat, mkdir, readFile, readdir, realpath, rename, rm } from 'node:fs/promises';
|
|
12
|
+
import { dirname, join, resolve } from 'node:path';
|
|
13
|
+
import { parseSkillDoc, resolveDshHome, unquote } from '../skills/core.js';
|
|
14
|
+
import { MAX_SOURCE_DEPTH, MAX_DIRECTORIES, MAX_ENTRIES, MAX_DESCRIPTION_LENGTH, DEFAULT_ORDER, DEFAULT_GROUP_ORDER, GLOBAL_SCENE, TRUNCATION_MARKER, DROPPED_HEADING, SCENE_MEMORY_NOTE, SCENE_MEMORY_NOTE_PARTIAL, LEGACY_BUNDLE_DOC, bundleDocName, SEGMENT_RULE_HINT, byteLen, message, isValidGroupSegment } from './constants.js';
|
|
15
|
+
import { ensureSceneRecords, resolveActiveScenes, signatureOfIndex, sceneLabel, sceneHeader, compareSceneBuckets, memoryBlock } from './projection.js';
|
|
16
|
+
import { isIndexQuarantined, readIndex, writeIndex, pathExists, readFileIfExistsSync } from './index-io.js';
|
|
17
|
+
export const identity = (p) => (process.platform === 'win32' ? p.toLowerCase() : p);
|
|
18
|
+
// ── 文件工具 ───────────────────────────────────────────────────────────────
|
|
19
|
+
/**
|
|
20
|
+
* BFS 发现(realpath 防环、深度/目录/条目预算),与 readonly-discovery 同构,
|
|
21
|
+
* 但规则的场景是**多层相对路径**(readonly-discovery 的 group 恒为第一层),
|
|
22
|
+
* 且**不跟随符号链接**(readonly-discovery 允许目录链接,因为那是用户显式接入的只读技能源;
|
|
23
|
+
* 记忆根则必须与注入路径同口径,见下方 `st.isSymbolicLink()` 处)。
|
|
24
|
+
*
|
|
25
|
+
* 目录语义:目录含 `<目录名>.md` → 它是 bundle 规则(叶子,不再深入),其「父路径」
|
|
26
|
+
* 是场景、目录名是规则名;找不到时再退回认旧文件名 `SKILL.md`(只读兼容,不再新建);
|
|
27
|
+
* 都没有则它是场景/子分类,继续遍历其下 .md(flat,任意层级)
|
|
28
|
+
* 与子目录。同名 flat 与 bundle 冲突时 bundle 优先,flat 记入 shadowed。
|
|
29
|
+
*/
|
|
30
|
+
export async function discover(memoriesRoot) {
|
|
31
|
+
const entries = new Map();
|
|
32
|
+
const shadowed = [];
|
|
33
|
+
const groups = new Set();
|
|
34
|
+
const scenes = [];
|
|
35
|
+
const warnings = [];
|
|
36
|
+
const rootPath = resolve(memoriesRoot);
|
|
37
|
+
try {
|
|
38
|
+
const st = await lstat(rootPath);
|
|
39
|
+
if (!st.isDirectory() || st.isSymbolicLink())
|
|
40
|
+
return { entries, shadowed, groups, scenes, warnings, truncated: false };
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return { entries, shadowed, groups, scenes, warnings, truncated: false };
|
|
44
|
+
}
|
|
45
|
+
const queue = [
|
|
46
|
+
{ path: rootPath, group: '', depth: 0, ancestors: new Set() },
|
|
47
|
+
];
|
|
48
|
+
let directories = 0;
|
|
49
|
+
let itemCount = 0;
|
|
50
|
+
let truncated = false;
|
|
51
|
+
const addEntry = (entry) => {
|
|
52
|
+
const existing = entries.get(entry.id);
|
|
53
|
+
if (!existing) {
|
|
54
|
+
entries.set(entry.id, entry);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (entry.kind === 'bundle' && existing.kind === 'flat') {
|
|
58
|
+
shadowed.push({ ...existing, shadowed: true });
|
|
59
|
+
entries.set(entry.id, entry);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (entry.kind === 'flat' && existing.kind === 'bundle') {
|
|
63
|
+
shadowed.push({ ...entry, shadowed: true });
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
for (let cursor = 0; cursor < queue.length; cursor++) {
|
|
67
|
+
if (directories >= MAX_DIRECTORIES || itemCount >= MAX_ENTRIES) {
|
|
68
|
+
truncated = true;
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
const current = queue[cursor];
|
|
72
|
+
let realDirectory;
|
|
73
|
+
let dirents = [];
|
|
74
|
+
try {
|
|
75
|
+
realDirectory = await realpath(current.path);
|
|
76
|
+
if (current.ancestors.has(identity(realDirectory)))
|
|
77
|
+
continue;
|
|
78
|
+
const dir = await readdir(current.path, { withFileTypes: true });
|
|
79
|
+
directories++;
|
|
80
|
+
for (const item of dir) {
|
|
81
|
+
if (itemCount >= MAX_ENTRIES) {
|
|
82
|
+
truncated = true;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
itemCount++;
|
|
86
|
+
dirents.push(item);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
continue; // 失效链接或不可读目录不阻断其他来源
|
|
91
|
+
}
|
|
92
|
+
// bundle 检查:当前目录含 `<目录名>.md`(或旧版 `SKILL.md`)→ 它是规则叶子,父路径为场景。
|
|
93
|
+
//
|
|
94
|
+
// **根下的一级目录恒为场景**(`parentGroup === ''` 时不做 bundle 判断)——与
|
|
95
|
+
// `probeSceneFilesSync`(注入段)同口径。场景 `1` 里一条叫 `1` 的记忆(`memories/1/1.md`)
|
|
96
|
+
// 与「根层的 bundle `1`」在磁盘上同形,认成后者会把整个场景目录吃成叶子:同场景其余
|
|
97
|
+
// 记忆全从列表消失、多出一条「未归属场景」的同名记忆,新建记忆还会被误报「同名 bundle
|
|
98
|
+
// 遮蔽」(2026-09-16 用户实测)。根层因此没有 bundle,只有 legacy 裸 .md(见 rulesCheck 的 noScene)。
|
|
99
|
+
if (current.group !== '') {
|
|
100
|
+
const sepIdx = current.group.lastIndexOf('/');
|
|
101
|
+
const parentGroup = sepIdx >= 0 ? current.group.slice(0, sepIdx) : '';
|
|
102
|
+
const name = sepIdx >= 0 ? current.group.slice(sepIdx + 1) : current.group;
|
|
103
|
+
let docPath = '';
|
|
104
|
+
if (parentGroup !== '') {
|
|
105
|
+
for (const candidate of [bundleDocName(name), LEGACY_BUNDLE_DOC]) {
|
|
106
|
+
const p = join(current.path, candidate);
|
|
107
|
+
try {
|
|
108
|
+
const st = await lstat(p);
|
|
109
|
+
if (st.isFile() && !st.isSymbolicLink()) {
|
|
110
|
+
docPath = p;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch { /* 该候选名不存在 → 试下一个 */ }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (docPath !== '') {
|
|
118
|
+
const id = parentGroup ? `${parentGroup}/${name}` : name;
|
|
119
|
+
addEntry({ id, group: parentGroup, name, kind: 'bundle', docPath, entryPath: current.path });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (current.group !== '') {
|
|
124
|
+
groups.add(current.group);
|
|
125
|
+
// 一级目录 = 场景名(多段场景名用 '/' 连接,见 isValidGroupPath)。
|
|
126
|
+
if (current.depth === 1)
|
|
127
|
+
scenes.push(current.group);
|
|
128
|
+
}
|
|
129
|
+
dirents.sort((a, b) => a.name.localeCompare(b.name));
|
|
130
|
+
const ancestors = new Set(current.ancestors).add(identity(realDirectory));
|
|
131
|
+
for (const item of dirents) {
|
|
132
|
+
if (item.name.startsWith('.'))
|
|
133
|
+
continue; // 隐藏目录/文件跳过
|
|
134
|
+
const path = join(current.path, item.name);
|
|
135
|
+
try {
|
|
136
|
+
const st = await lstat(path);
|
|
137
|
+
// 符号链接一律不跟随:注入路径(`probeSceneFilesSync`)本来就不跟随,两端必须同口径 ——
|
|
138
|
+
// 否则列表里会出现**永远注入不进去**的记忆(用户打开开关却什么也没发生)。顺带也关掉了
|
|
139
|
+
// 「链接指向根外、写操作顺着链接穿透出去」这条路(写入侧另有 realpath 断言兜底)。
|
|
140
|
+
if (st.isSymbolicLink())
|
|
141
|
+
continue;
|
|
142
|
+
if (st.isDirectory()) {
|
|
143
|
+
if (!isValidGroupSegment(item.name)) {
|
|
144
|
+
warnings.push(`跳过非法目录名(${SEGMENT_RULE_HINT}):${current.group ? current.group + '/' : ''}${item.name}`);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (current.depth >= MAX_SOURCE_DEPTH || queue.length >= MAX_DIRECTORIES) {
|
|
148
|
+
truncated = true;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
queue.push({
|
|
152
|
+
path,
|
|
153
|
+
group: current.group ? `${current.group}/${item.name}` : item.name,
|
|
154
|
+
depth: current.depth + 1,
|
|
155
|
+
ancestors,
|
|
156
|
+
});
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (!st.isFile())
|
|
160
|
+
continue;
|
|
161
|
+
if (!item.name.toLowerCase().endsWith('.md'))
|
|
162
|
+
continue; // 只认 .md
|
|
163
|
+
const name = item.name.slice(0, -3);
|
|
164
|
+
const id = current.group ? `${current.group}/${name}` : name;
|
|
165
|
+
addEntry({ id, group: current.group, name, kind: 'flat', docPath: path, entryPath: path });
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
/* 来源在扫描期间失效时跳过 */
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return { entries, shadowed, groups, scenes, warnings, truncated };
|
|
173
|
+
}
|
|
174
|
+
// ── 规则解析 + 派生 ────────────────────────────────────────────────────────
|
|
175
|
+
/** 派生 description:首个 `#` 标题 → 首个非空行;截断 500 字符。 */
|
|
176
|
+
export function deriveDescription(body) {
|
|
177
|
+
const text = String(body ?? '').trim();
|
|
178
|
+
if (!text)
|
|
179
|
+
return '';
|
|
180
|
+
const heading = /^#\s+(.+)$/m.exec(text);
|
|
181
|
+
const raw = heading && heading[1].trim() ? heading[1].trim() : text.split(/\r?\n/, 1)[0].trim();
|
|
182
|
+
return raw.slice(0, MAX_DESCRIPTION_LENGTH);
|
|
183
|
+
}
|
|
184
|
+
/** 从 frontmatter/正文派生字段(派生只存在于内存投影,不写回文件)。 */
|
|
185
|
+
export function deriveFromDoc(entry, doc) {
|
|
186
|
+
const declaredName = doc.map.name != null && String(doc.map.name).trim() !== '' ? unquote(String(doc.map.name)).trim() : '';
|
|
187
|
+
const name = declaredName || entry.name;
|
|
188
|
+
let description = doc.map.description != null ? unquote(String(doc.map.description)).trim() : '';
|
|
189
|
+
let descriptionDerived = false;
|
|
190
|
+
if (description === '') {
|
|
191
|
+
description = deriveDescription(doc.body);
|
|
192
|
+
descriptionDerived = true;
|
|
193
|
+
}
|
|
194
|
+
const globs = doc.map.globs != null && String(doc.map.globs).trim() !== '' ? String(doc.map.globs).trim() : undefined;
|
|
195
|
+
let whenToUse;
|
|
196
|
+
if (doc.map.whenToUse != null && String(doc.map.whenToUse).trim() !== '') {
|
|
197
|
+
whenToUse = String(doc.map.whenToUse).trim();
|
|
198
|
+
}
|
|
199
|
+
else if (globs) {
|
|
200
|
+
whenToUse = `适用路径:${globs}`;
|
|
201
|
+
}
|
|
202
|
+
const metadata = doc.map.metadata !== undefined ? doc.map.metadata : undefined;
|
|
203
|
+
return { name, description, descriptionDerived, whenToUse, globs, metadata };
|
|
204
|
+
}
|
|
205
|
+
/** 合并索引字段(enabled/order 等以索引为准;无记录走默认投影)。 */
|
|
206
|
+
export function projectRule(entry, derived, idxEntry) {
|
|
207
|
+
const rule = {
|
|
208
|
+
id: entry.id,
|
|
209
|
+
group: entry.group,
|
|
210
|
+
name: derived.name,
|
|
211
|
+
form: entry.kind,
|
|
212
|
+
path: entry.docPath,
|
|
213
|
+
description: derived.description,
|
|
214
|
+
...(derived.descriptionDerived ? { descriptionDerived: true } : {}),
|
|
215
|
+
...(derived.whenToUse !== undefined ? { whenToUse: derived.whenToUse } : {}),
|
|
216
|
+
...(derived.globs !== undefined ? { globs: derived.globs } : {}),
|
|
217
|
+
...(derived.metadata !== undefined ? { metadata: derived.metadata } : {}),
|
|
218
|
+
enabled: idxEntry?.enabled ?? true,
|
|
219
|
+
order: idxEntry?.order ?? DEFAULT_ORDER,
|
|
220
|
+
tags: Array.isArray(idxEntry?.tags) ? idxEntry.tags : [],
|
|
221
|
+
pinned: idxEntry?.pinned === true,
|
|
222
|
+
note: typeof idxEntry?.note === 'string' ? idxEntry.note : '',
|
|
223
|
+
...(typeof idxEntry?.updatedAt === 'string' ? { updatedAt: idxEntry.updatedAt } : {}),
|
|
224
|
+
};
|
|
225
|
+
return rule;
|
|
226
|
+
}
|
|
227
|
+
// ── 侧车文件(索引 / 场景)─────────────────────────────────────────────────
|
|
228
|
+
/** 把旧布局搬进 `tool-management/`(每个进程只尝试一次,幂等)。
|
|
229
|
+
*
|
|
230
|
+
* 旧:$DSH_HOME/scene-memory/<root>.md → memories/global/<root>.md
|
|
231
|
+
* $DSH_HOME/scene-memory/<scene>/… → memories/<scene>/…
|
|
232
|
+
* (场景记录由索引镜像补齐,见 ensureSceneRecords)
|
|
233
|
+
* 更旧:$DSH_HOME/rules/…(v0.3 之前)同样按上面两条处理。
|
|
234
|
+
*
|
|
235
|
+
* 搬移用 `rename`(同卷零拷贝);跨卷(EXDEV)退化为 `cp` + `rm`。
|
|
236
|
+
* 源目录**保留**(内容已搬走,留空壳不影响正确性,也方便用户核对)。
|
|
237
|
+
* 目标已存在同名项 → 保留目标、跳过该项(绝不覆盖新数据)。
|
|
238
|
+
*/
|
|
239
|
+
export async function relocateLegacyLayout(memoriesRoot) {
|
|
240
|
+
const home = resolveDshHome();
|
|
241
|
+
const legacyRoots = [join(home, 'scene-memory'), join(home, 'rules')];
|
|
242
|
+
for (const legacyRoot of legacyRoots) {
|
|
243
|
+
if (!(await pathExists(legacyRoot)))
|
|
244
|
+
continue;
|
|
245
|
+
if (identity(resolve(legacyRoot)) === identity(resolve(memoriesRoot)))
|
|
246
|
+
continue; // 自定义 roots 落在旧路径 → 不自我搬移
|
|
247
|
+
let items;
|
|
248
|
+
try {
|
|
249
|
+
items = await readdir(legacyRoot, { withFileTypes: true });
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
for (const item of items) {
|
|
255
|
+
if (item.name.startsWith('.'))
|
|
256
|
+
continue;
|
|
257
|
+
const src = join(legacyRoot, item.name);
|
|
258
|
+
// 根层 .md = 旧的「全局」桶 → 搬进 `global/`,文件名保留(否则多套一层目录)。
|
|
259
|
+
// 其余条目(场景目录)整体搬进 memories/,目录名保留。
|
|
260
|
+
const isRootMd = item.isFile() && item.name.toLowerCase().endsWith('.md');
|
|
261
|
+
const destDir = isRootMd ? join(memoriesRoot, GLOBAL_SCENE) : memoriesRoot;
|
|
262
|
+
const dest = join(destDir, item.name);
|
|
263
|
+
try {
|
|
264
|
+
if (await pathExists(dest))
|
|
265
|
+
continue;
|
|
266
|
+
await mkdir(destDir, { recursive: true });
|
|
267
|
+
try {
|
|
268
|
+
await rename(src, dest);
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
// 跨设备/被占用 → 复制后删源;复制失败则保留源文件(宁可重复,不可丢失)。
|
|
272
|
+
await cp(src, dest, { recursive: true });
|
|
273
|
+
await rm(src, { recursive: true, force: true });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
/* 单项失败不阻断其余项(如文件被外部程序锁住,下次启动再试) */
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// ── 快照(发现 + 索引合并 + 索引清理)──────────────────────────────────────
|
|
283
|
+
export async function buildSnapshot(memoriesRoot, stateDir) {
|
|
284
|
+
const discovery = await discover(memoriesRoot);
|
|
285
|
+
let index = await readIndex(stateDir);
|
|
286
|
+
// 索引损坏时一律按「未启用」投影(理由同 probeSceneFilesSync 的 forceDisabled)。
|
|
287
|
+
// 必须在 readIndex 之后取:隔离判定正是在那次读失败时建立的。
|
|
288
|
+
const quarantined = isIndexQuarantined(stateDir);
|
|
289
|
+
// 索引有记录但文件已删 → 清理记录(磁盘与索引保持一致)。
|
|
290
|
+
let dirty = false;
|
|
291
|
+
for (const id of Object.keys(index.rules)) {
|
|
292
|
+
if (!discovery.entries.has(id)) {
|
|
293
|
+
delete index.rules[id];
|
|
294
|
+
dirty = true;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
for (const group of Object.keys(index.groups)) {
|
|
298
|
+
if (!discovery.groups.has(group)) {
|
|
299
|
+
delete index.groups[group];
|
|
300
|
+
dirty = true;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// 场景记录:保留场景 global 恒存在;有目录没记录的补一条。
|
|
304
|
+
// 注意**不反向清理**——删掉 memories/<场景>/ 目录不应删掉场景记录,
|
|
305
|
+
// 否则“先建场景、后加记忆”的用法会在加记忆前把场景弄丢。
|
|
306
|
+
if (ensureSceneRecords(index, discovery.scenes))
|
|
307
|
+
dirty = true;
|
|
308
|
+
// 索引损坏时跳过这次清理落盘:写侧本就拒绝(会抛),而这里的清理只是收尾性的,
|
|
309
|
+
// 它抛出去会让整个记忆页打不开 —— 用户更需要先看见「记忆都在、只是全未启用」。
|
|
310
|
+
if (dirty && !quarantined)
|
|
311
|
+
await writeIndex(stateDir, index);
|
|
312
|
+
const rules = [];
|
|
313
|
+
const bodies = new Map();
|
|
314
|
+
const entries = new Map();
|
|
315
|
+
// 读取失败必须说出来:那一条会从三处(`rules` / `bodies` / `entries`)同时消失,
|
|
316
|
+
// 界面表现为"这条记忆不存在",而 `rules-diagnose` 的 `emptyBody` 依赖 `snap.bodies`、
|
|
317
|
+
// 也兜不到它。走与 `discover()` 同一条 `warnings` 通道 → `rules-list` 透传 → 记忆页横幅。
|
|
318
|
+
const warnings = discovery.warnings.slice();
|
|
319
|
+
for (const entry of discovery.entries.values()) {
|
|
320
|
+
try {
|
|
321
|
+
const text = await readFile(entry.docPath, 'utf8');
|
|
322
|
+
const doc = parseSkillDoc(text);
|
|
323
|
+
const derived = deriveFromDoc(entry, doc);
|
|
324
|
+
rules.push(projectRule(entry, derived, quarantined ? { enabled: false } : index.rules[entry.id]));
|
|
325
|
+
bodies.set(entry.id, doc.body);
|
|
326
|
+
entries.set(entry.id, entry);
|
|
327
|
+
}
|
|
328
|
+
catch (error) {
|
|
329
|
+
/* 文件在扫描与读取间被删/损坏:跳过该规则,但如实说出是哪一条、为什么 */
|
|
330
|
+
warnings.push(`读取失败,已跳过:${entry.id}(${message(error)})`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
for (const entry of discovery.shadowed) {
|
|
334
|
+
try {
|
|
335
|
+
const text = await readFile(entry.docPath, 'utf8');
|
|
336
|
+
const doc = parseSkillDoc(text);
|
|
337
|
+
const derived = deriveFromDoc(entry, doc);
|
|
338
|
+
rules.push({ ...projectRule(entry, derived, undefined), shadowed: true });
|
|
339
|
+
entries.set(entry.id, entry);
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
warnings.push(`读取失败,已跳过(被同名 bundle 遮蔽的条目):${entry.id}(${message(error)})`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
const groups = [...discovery.groups]
|
|
346
|
+
.map((name) => {
|
|
347
|
+
const gi = index.groups[name] || {};
|
|
348
|
+
return {
|
|
349
|
+
name,
|
|
350
|
+
label: typeof gi.label === 'string' && gi.label !== '' ? gi.label : name,
|
|
351
|
+
order: gi.order ?? DEFAULT_GROUP_ORDER,
|
|
352
|
+
count: rules.filter((r) => r.group === name && !r.shadowed).length,
|
|
353
|
+
};
|
|
354
|
+
})
|
|
355
|
+
.sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
|
|
356
|
+
return { rules, groups, scenes: discovery.scenes, warnings, truncated: discovery.truncated, entries, bodies, index };
|
|
357
|
+
}
|
|
358
|
+
export function fileStampSync(path) {
|
|
359
|
+
try {
|
|
360
|
+
const st = statSync(path);
|
|
361
|
+
return `${st.mtimeMs}:${st.size}`;
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
return 'missing';
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* 第一相:走目录树 + stat,不读正文。
|
|
369
|
+
* `<场景>/...` → 场景记忆(一级目录名即场景名,含保留场景 `global`);
|
|
370
|
+
* 是否生效由 index.active 决定,`global` 恒定生效(见 renderSceneMemory)。
|
|
371
|
+
* 根层的裸 .md **不再是记忆**(旧的「全局」桶已迁入 `global/`,见 relocateLegacyLayout),
|
|
372
|
+
* 因此这里不再扫描根层文件——把文件丢在 memories/ 根下不会静默生效,也不会被投影。
|
|
373
|
+
* `signature` 不变 ⇒ 上次渲染结果可原样复用(零正文 IO、零重排)。
|
|
374
|
+
*/
|
|
375
|
+
export function probeSceneFilesSync(memoriesRoot, index,
|
|
376
|
+
// 索引已损坏时置真:索引是开关的唯一真相源,它没了就无法知道用户开过哪些记忆,
|
|
377
|
+
// 而默认值(无记录 = 启用)会把**全部**记忆一次性注入模型上下文。表现为「记忆都没生效」
|
|
378
|
+
// 是可解释、可恢复的;替用户决定全开不是。
|
|
379
|
+
forceDisabled = false) {
|
|
380
|
+
const byId = new Map();
|
|
381
|
+
const scenes = [];
|
|
382
|
+
let truncated = false;
|
|
383
|
+
const budget = { dirs: 0, items: 0 };
|
|
384
|
+
let rootEntries;
|
|
385
|
+
try {
|
|
386
|
+
rootEntries = readdirSync(memoriesRoot, { withFileTypes: true });
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
// 目录不存在/不可读 → 空段(不报错)。签名含固定前缀,便于与"空树"区分。
|
|
390
|
+
return { refs: [], scenes: [], truncated: false, signature: `∅|${signatureOfIndex(index)}` };
|
|
391
|
+
}
|
|
392
|
+
const add = (ref) => {
|
|
393
|
+
const existing = byId.get(ref.id);
|
|
394
|
+
// bundle 优先于 flat(与 discover 的同名遮蔽规则一致)。
|
|
395
|
+
if (!existing || (ref.kind === 'bundle' && existing.kind === 'flat'))
|
|
396
|
+
byId.set(ref.id, ref);
|
|
397
|
+
};
|
|
398
|
+
/** 收集单个目录树内的记忆(scene = 场景名)。 */
|
|
399
|
+
const walk = (scene, dir, rel, depth) => {
|
|
400
|
+
if (depth > MAX_SOURCE_DEPTH || budget.dirs >= MAX_DIRECTORIES || budget.items >= MAX_ENTRIES) {
|
|
401
|
+
truncated = true;
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
let entries;
|
|
405
|
+
try {
|
|
406
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
budget.dirs++;
|
|
412
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
413
|
+
for (const item of entries) {
|
|
414
|
+
if (budget.items >= MAX_ENTRIES) {
|
|
415
|
+
truncated = true;
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (item.name.startsWith('.'))
|
|
419
|
+
continue; // 隐藏项跳过
|
|
420
|
+
const child = join(dir, item.name);
|
|
421
|
+
const relChild = rel ? `${rel}/${item.name}` : item.name;
|
|
422
|
+
if (item.isSymbolicLink())
|
|
423
|
+
continue; // 同步扫描不跟随符号链接(防环)
|
|
424
|
+
if (item.isDirectory()) {
|
|
425
|
+
budget.items++;
|
|
426
|
+
let docPath = '';
|
|
427
|
+
for (const candidate of [bundleDocName(item.name), LEGACY_BUNDLE_DOC]) {
|
|
428
|
+
const p = join(child, candidate);
|
|
429
|
+
if (fileStampSync(p) !== 'missing') {
|
|
430
|
+
docPath = p;
|
|
431
|
+
break;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
// 指纹带上**目录本身**的 mtime:附件只在段里以「路径 + 文件名」出现,
|
|
435
|
+
// 增删附件不改正文文件,只靠它的话指纹不变、段不会重算,列表就会停在旧值。
|
|
436
|
+
if (docPath !== '') {
|
|
437
|
+
const stamp = `${fileStampSync(docPath)}:${fileStampSync(child)}`;
|
|
438
|
+
// bundle 规则(叶子):父路径为场景,目录名为记忆名。
|
|
439
|
+
const id = scene ? `${scene}/${relChild}` : relChild;
|
|
440
|
+
if (forceDisabled || index.rules[id]?.enabled === false)
|
|
441
|
+
continue; // 单条停用 → 不进入段
|
|
442
|
+
add({ id, scene, name: item.name, kind: 'bundle', path: docPath, order: index.rules[id]?.order ?? DEFAULT_ORDER, stamp });
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
walk(scene, child, relChild, depth + 1);
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
if (!item.isFile())
|
|
449
|
+
continue;
|
|
450
|
+
if (!item.name.toLowerCase().endsWith('.md'))
|
|
451
|
+
continue; // 只认 .md
|
|
452
|
+
budget.items++;
|
|
453
|
+
const id = scene ? `${scene}/${relChild.slice(0, -3)}` : relChild.slice(0, -3);
|
|
454
|
+
if (forceDisabled || index.rules[id]?.enabled === false)
|
|
455
|
+
continue;
|
|
456
|
+
add({
|
|
457
|
+
id,
|
|
458
|
+
scene,
|
|
459
|
+
name: item.name.slice(0, -3),
|
|
460
|
+
kind: 'flat',
|
|
461
|
+
path: child,
|
|
462
|
+
order: index.rules[id]?.order ?? DEFAULT_ORDER,
|
|
463
|
+
stamp: fileStampSync(child),
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
// 一级目录 = 场景(根层的裸 .md 不再是记忆,故此处不处理文件)。
|
|
468
|
+
for (const item of [...rootEntries].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
469
|
+
if (item.name.startsWith('.'))
|
|
470
|
+
continue;
|
|
471
|
+
if (item.isSymbolicLink())
|
|
472
|
+
continue;
|
|
473
|
+
if (!item.isDirectory())
|
|
474
|
+
continue;
|
|
475
|
+
if (!isValidGroupSegment(item.name))
|
|
476
|
+
continue; // 非法目录名 → 不作为场景
|
|
477
|
+
scenes.push(item.name);
|
|
478
|
+
}
|
|
479
|
+
// ② 场景目录树
|
|
480
|
+
for (const scene of scenes)
|
|
481
|
+
walk(scene, join(memoriesRoot, scene), '', 1);
|
|
482
|
+
const refs = [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
483
|
+
const signature = [
|
|
484
|
+
signatureOfIndex(index),
|
|
485
|
+
truncated ? 'T' : '-',
|
|
486
|
+
scenes.join('\u0001'),
|
|
487
|
+
...refs.map((r) => `${r.id}\u0000${r.kind}\u0000${r.order}\u0000${r.stamp}`),
|
|
488
|
+
].join('\u0002');
|
|
489
|
+
return { refs, scenes, truncated, signature };
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* 第二相:读正文 + 派生 + 确定性拼接。仅在指纹变化时调用。
|
|
493
|
+
*/
|
|
494
|
+
export function renderSceneMemory(probe, index, maxBytes) {
|
|
495
|
+
const files = [];
|
|
496
|
+
for (const ref of probe.refs) {
|
|
497
|
+
const text = readFileIfExistsSync(ref.path);
|
|
498
|
+
if (text === null)
|
|
499
|
+
continue; // 探测与读取之间被删:跳过(下次指纹变化会再校正)
|
|
500
|
+
const doc = parseSkillDoc(text);
|
|
501
|
+
const derived = deriveFromDoc({ id: ref.id, group: ref.scene, name: ref.name, kind: ref.kind, docPath: ref.path, entryPath: ref.path }, doc);
|
|
502
|
+
files.push({
|
|
503
|
+
id: ref.id,
|
|
504
|
+
scene: ref.scene,
|
|
505
|
+
name: derived.name,
|
|
506
|
+
description: derived.description,
|
|
507
|
+
descriptionDerived: derived.descriptionDerived,
|
|
508
|
+
order: ref.order,
|
|
509
|
+
body: doc.body,
|
|
510
|
+
kind: ref.kind,
|
|
511
|
+
// bundle 的正文是 `<目录>/<名>.md`,附件是**同目录**的其余文件。
|
|
512
|
+
bundleDir: ref.kind === 'bundle' ? dirname(ref.path) : '',
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
const { active } = resolveActiveScenes(index, probe.scenes);
|
|
516
|
+
const buckets = new Map();
|
|
517
|
+
for (const file of files) {
|
|
518
|
+
// 保留场景 global 恒定生效(「全局」= 任何对话都注入);其余由 index.active 决定。
|
|
519
|
+
if (file.scene !== GLOBAL_SCENE && !active.has(file.scene))
|
|
520
|
+
continue;
|
|
521
|
+
// ⚠️ 这里**不再**看场景档案的 memories 段:记忆的开关是**单一真相源** `rules[*].enabled`
|
|
522
|
+
// (见下方 enabled 判定)。档案弹窗里的记忆勾选就是同一个值,所以两边天然一致,
|
|
523
|
+
// 不存在「记忆页开了、档案页还显示未开」的两套状态。
|
|
524
|
+
const list = buckets.get(file.scene);
|
|
525
|
+
if (list)
|
|
526
|
+
list.push(file);
|
|
527
|
+
else
|
|
528
|
+
buckets.set(file.scene, [file]);
|
|
529
|
+
}
|
|
530
|
+
// ── 候选块(确定性顺序:场景 → 场景内 order/名称)────────────────────────
|
|
531
|
+
const candidates = [];
|
|
532
|
+
let seq = 0;
|
|
533
|
+
for (const scene of [...buckets.keys()].sort((a, b) => compareSceneBuckets(a, b, index))) {
|
|
534
|
+
const sceneFiles = (buckets.get(scene) || []).slice().sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
|
|
535
|
+
const header = sceneHeader(scene, index);
|
|
536
|
+
for (const file of sceneFiles) {
|
|
537
|
+
const { text: block, inline } = memoryBlock(file);
|
|
538
|
+
candidates.push({
|
|
539
|
+
seq: seq++,
|
|
540
|
+
scene,
|
|
541
|
+
header,
|
|
542
|
+
block,
|
|
543
|
+
inline,
|
|
544
|
+
item: { id: file.id, scene: file.scene, name: file.name, bytes: byteLen(block) },
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
if (candidates.length === 0) {
|
|
549
|
+
return { text: '', bytes: 0, truncated: probe.truncated, maxBytes, scenes: [], items: [], dropped: [] };
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* 选中块 → 段正文(同一场景的 `## 场景:x` 只在首次出现时发出)。
|
|
553
|
+
* `dropped` 决定引导语用哪一版:真有条目没注入时不再声称「以下就是全部信息」。
|
|
554
|
+
*/
|
|
555
|
+
const renderBody = (selected, dropped) => {
|
|
556
|
+
if (selected.length === 0)
|
|
557
|
+
return '';
|
|
558
|
+
const note = dropped ? SCENE_MEMORY_NOTE_PARTIAL : SCENE_MEMORY_NOTE;
|
|
559
|
+
const chunks = [];
|
|
560
|
+
let current = null;
|
|
561
|
+
let buf = '';
|
|
562
|
+
// 上一条是不是「单行条目」。连续两条单行条目紧挨着(列表的自然形态),
|
|
563
|
+
// 其余情况都要空一行 —— 多行正文(含其缩进挂载的附件行)与下一条之间没有空行的话,
|
|
564
|
+
// 读起来会连成一片、看不出条目边界。
|
|
565
|
+
let prevInline = false;
|
|
566
|
+
for (const c of selected) {
|
|
567
|
+
if (c.scene !== current) {
|
|
568
|
+
if (buf !== '')
|
|
569
|
+
chunks.push(buf);
|
|
570
|
+
current = c.scene;
|
|
571
|
+
// 引导语跟着**场景块**走(场景说明之后、条目之前):它管的就是下面这些条目,
|
|
572
|
+
// 放最顶层会飘在场景之外(用户实测反馈「怎么跑到最顶层了」)。场景数有上限
|
|
573
|
+
// (`global`/`_shared` 恒常启用 + 至多一个启用场景),最多出现 3 次,代价可接受。
|
|
574
|
+
buf = `${c.header}${note}\n\n`;
|
|
575
|
+
prevInline = false;
|
|
576
|
+
}
|
|
577
|
+
const inline = c.inline;
|
|
578
|
+
if (!(inline && prevInline) && !buf.endsWith('\n\n'))
|
|
579
|
+
buf += '\n';
|
|
580
|
+
prevInline = inline;
|
|
581
|
+
buf += c.block;
|
|
582
|
+
}
|
|
583
|
+
if (buf !== '')
|
|
584
|
+
chunks.push(buf);
|
|
585
|
+
return chunks.join('\n');
|
|
586
|
+
};
|
|
587
|
+
const sceneLabelOf = (scene) => sceneLabel(scene);
|
|
588
|
+
/** 未注入清单 + 截断标记,在 `space` 字节内尽量列全(放不下的折叠为一行计数)。 */
|
|
589
|
+
const renderTail = (missed, space, wasTruncated) => {
|
|
590
|
+
if (!wasTruncated)
|
|
591
|
+
return ''; // 没截断就不该出现标记
|
|
592
|
+
const marker = `\n${TRUNCATION_MARKER}\n`;
|
|
593
|
+
if (missed.length === 0)
|
|
594
|
+
return byteLen(marker) <= space ? marker : '';
|
|
595
|
+
const head = `\n${DROPPED_HEADING}\n\n`;
|
|
596
|
+
const lines = [];
|
|
597
|
+
let used = byteLen(head) + byteLen(marker);
|
|
598
|
+
for (const c of missed) {
|
|
599
|
+
const line = `- ${sceneLabelOf(c.scene)}/${c.item.name}(${c.item.bytes} B)\n`;
|
|
600
|
+
if (used + byteLen(line) > space)
|
|
601
|
+
break;
|
|
602
|
+
lines.push(line);
|
|
603
|
+
used += byteLen(line);
|
|
604
|
+
}
|
|
605
|
+
if (lines.length === 0)
|
|
606
|
+
return byteLen(marker) <= space ? marker : '';
|
|
607
|
+
const rest = missed.length - lines.length;
|
|
608
|
+
const fold = `- …(其余 ${rest} 条未列出)\n`;
|
|
609
|
+
const tail = `${head}${lines.join('')}${rest > 0 && used + byteLen(fold) <= space ? fold : ''}${marker}`;
|
|
610
|
+
return tail;
|
|
611
|
+
};
|
|
612
|
+
// ── ① 贪心:放得下就放,越界**跳过**(而不是整体停止)──────────────────
|
|
613
|
+
// 原来一旦某块越界就 stopped,于是一条超长记忆会把它后面的所有小记忆一起饿死。
|
|
614
|
+
const markerReserve = byteLen(`\n${TRUNCATION_MARKER}\n`);
|
|
615
|
+
const selected = [];
|
|
616
|
+
const missed = [];
|
|
617
|
+
const takenScenes = new Set();
|
|
618
|
+
// 场景标题与引导语也是开销,按「每个首次出现的场景」计进预算 —— 否则它们会挤掉
|
|
619
|
+
// 本该放得下的记忆(引导语的字节见 SCENE_NOTE_BYTES)。
|
|
620
|
+
let used = 0;
|
|
621
|
+
const noteBytes = sceneNoteBytes();
|
|
622
|
+
for (const c of candidates) {
|
|
623
|
+
const headerCost = takenScenes.has(c.scene) ? 0 : byteLen(c.header) + noteBytes;
|
|
624
|
+
if (used + headerCost + c.item.bytes + markerReserve > maxBytes) {
|
|
625
|
+
missed.push(c);
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
selected.push(c);
|
|
629
|
+
takenScenes.add(c.scene);
|
|
630
|
+
used += headerCost + c.item.bytes;
|
|
631
|
+
}
|
|
632
|
+
missed.sort((a, b) => a.seq - b.seq);
|
|
633
|
+
// ── ② 尾注自身也占字节:放不下就把已入选的块从后往前退回,直到回到预算内 ──
|
|
634
|
+
let body = renderBody(selected, probe.truncated || missed.length > 0);
|
|
635
|
+
let tail = renderTail(missed, maxBytes - byteLen(body), probe.truncated || missed.length > 0);
|
|
636
|
+
while (byteLen(body) + byteLen(tail) > maxBytes && selected.length > 0) {
|
|
637
|
+
missed.push(selected.pop());
|
|
638
|
+
missed.sort((a, b) => a.seq - b.seq);
|
|
639
|
+
body = renderBody(selected, true);
|
|
640
|
+
tail = renderTail(missed, maxBytes - byteLen(body), true);
|
|
641
|
+
}
|
|
642
|
+
let text = `${body}${tail}`.replace(/^\n+/, '');
|
|
643
|
+
// ③ 兜底:预算小到连标记都放不下时也宁可超出几个字节——"有记忆没注入"这件事
|
|
644
|
+
// 绝不能静默消失(原来单条超预算会让整段变成空串,模型端完全无痕)。
|
|
645
|
+
if (text === '' && (missed.length > 0 || probe.truncated))
|
|
646
|
+
text = `${TRUNCATION_MARKER}\n`;
|
|
647
|
+
const scenes = [];
|
|
648
|
+
for (const c of selected)
|
|
649
|
+
if (!scenes.includes(c.scene))
|
|
650
|
+
scenes.push(c.scene);
|
|
651
|
+
return {
|
|
652
|
+
text,
|
|
653
|
+
bytes: byteLen(text),
|
|
654
|
+
truncated: probe.truncated || missed.length > 0,
|
|
655
|
+
maxBytes,
|
|
656
|
+
scenes,
|
|
657
|
+
items: selected.map((c) => c.item),
|
|
658
|
+
dropped: missed.map((c) => c.item),
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* 段首固定块。以 `\n` 结尾,与场景块 join 后自然空一行。
|
|
663
|
+
* 预算按**较长**的那版算(见 renderSceneMemory),保守一点只会浪费几个字节。
|
|
664
|
+
*/
|
|
665
|
+
/**
|
|
666
|
+
* 引导语所占的字节(含它后面的一个空行)。跟着场景块走,所以按**每个场景**计入段头开销;
|
|
667
|
+
* 用较长的那版(带完整性声明)算,保守一点只会浪费几个字节。
|
|
668
|
+
*
|
|
669
|
+
* 注意:模块级不能直接算 —— `byteLen` 是后面才声明的 const,模块初始化期取它会 TDZ 报错。
|
|
670
|
+
*/
|
|
671
|
+
export const sceneNoteBytes = () => byteLen(`${SCENE_MEMORY_NOTE}\n\n`);
|
|
672
|
+
// ── 创建服务 ───────────────────────────────────────────────────────────────
|