dsh-plugin-tool-management 0.9.1 → 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 +119 -1
- package/README.md +227 -201
- package/README_EN.md +227 -199
- 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 +144 -12
- package/lib/client.js +8437 -5929
- package/lib/compat/preset-reach.js +1 -10
- package/lib/compat/probe.js +158 -25
- package/lib/context-inject.js +396 -53
- package/lib/host-names.js +12 -0
- package/lib/http-fence.js +35 -15
- package/lib/hub.js +31 -3
- package/lib/imports/parsers.js +15 -9
- package/lib/imports/upload.js +43 -4
- package/lib/index.js +940 -3765
- package/lib/mcp/loader-token.js +238 -0
- package/lib/mcp/manager.js +1681 -0
- package/lib/mcp/override-blocks.js +20 -9
- package/lib/mcp/patch-yaml.js +351 -0
- package/lib/mcp/secret-guard.js +145 -0
- package/lib/mcp/state-section.js +64 -21
- package/lib/{rules → memories}/archive-engine.js +65 -10
- package/lib/{rules → memories}/archive.js +6 -7
- 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 +22 -5
- package/lib/sessions/history.js +323 -0
- package/lib/{history → sessions}/tombstone.js +1 -2
- package/lib/{history → sessions}/workspace.js +92 -24
- package/lib/skills/catalog.js +6 -9
- package/lib/skills/core.js +79 -43
- package/lib/skills/readonly-discovery.js +4 -1
- package/lib/skills/service.js +98 -13
- package/lib/subagents/catalog.js +31 -15
- package/lib/subagents/service.js +506 -89
- package/lib/subagents/tools.js +29 -4
- 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 +105 -102
- package/lib/agents-md/preset-id.js +0 -49
- package/lib/history/projcache.js +0 -335
- package/lib/rules/service.js +0 -2971
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
// 规则/记忆域的**记忆正文 ops**(2026-09-19 从 memories/service.ts 的 createMemoriesService 闭包抽出)。
|
|
2
|
+
//
|
|
3
|
+
// 列出 / 读取 / 预算 / 体检 / 新建 / 导入 / 更新 / 开关 / 索引调整 / 附件增删 —— 记忆正文这条主线。
|
|
4
|
+
// 与回收站、场景两个域互不调用;共享的读模型与写管道经 MemoriesOpsCtx 显式传入。
|
|
5
|
+
//
|
|
6
|
+
// ops 表里的包装(enqueueMutation / runWrite)**留在 service.ts**:本文件只导出裸 op,
|
|
7
|
+
// 包装归属集中在一处,改门禁时一眼看全。
|
|
8
|
+
import { lstat, mkdir, readFile, rm } from 'node:fs/promises';
|
|
9
|
+
import { basename, dirname, join } from 'node:path';
|
|
10
|
+
import { parseSkillDoc, unquote } from '../skills/core.js';
|
|
11
|
+
import { expandUploads, planMemoryImport } from '../imports/upload.js';
|
|
12
|
+
import { MAX_DESCRIPTION_LENGTH, MAX_RULE_BYTES, DEFAULT_ORDER, DEFAULT_GROUP_ORDER, GLOBAL_SCENE, GLOBAL_SCENE_LABEL, TRUNCATION_MARKER, MAX_ATTACH_ENTRY_BYTES, MAX_ATTACH_TOTAL_BYTES, MAX_ATTACH_ENTRIES, LEGACY_BUNDLE_DOC, bundleDocName, SEGMENT_RULE_HINT, isValidGroupSegment, isValidGroupPath, message, fail } from '../memories/constants.js';
|
|
13
|
+
import { normalizeActive, enabledSceneOf, sceneOf, sceneLabel, attachmentSummarySync } from '../memories/projection.js';
|
|
14
|
+
import { writeFileAtomically, writeFileAtomicBinary, readIndex, writeIndex, pathExists } from '../memories/index-io.js';
|
|
15
|
+
import { deriveDescription } from '../memories/snapshot.js';
|
|
16
|
+
/** 组装本域 op。rc 由 createMemoriesService 组装,契约见 ./ctx.ts。 */
|
|
17
|
+
export function buildMemoryOps(rc) {
|
|
18
|
+
const { buildProjected, copyIntoMemoriesTrash, invalidateSnapshot, listAttachments, locateRule, maxBytes, parseId, refuseOutsideRoot, memoriesRoot, sceneMemory, scenePrompt, sceneRows, scenesRoot, serializeRuleFile, serializeUpdatedFile, snapshot, stateDir } = rc;
|
|
19
|
+
async function rulesList(args) {
|
|
20
|
+
const groupFilter = args && typeof args.group === 'string' && args.group !== '' ? args.group : undefined;
|
|
21
|
+
const snap = await snapshot();
|
|
22
|
+
// 索引直接用快照里那份:以前这里再 `readIndex(stateDir)` 一次 —— 同一次调用把
|
|
23
|
+
// memories-index.json 读两遍,且可能出现「规则来自 t0、锁定/场景来自 t1」的混合态。
|
|
24
|
+
// 新鲜度不变:插件自身的写路径走 `runWrite → refresh()` 全量失效,TTL 只对外部改文件生效。
|
|
25
|
+
const index = snap.index;
|
|
26
|
+
const rules = snap.rules
|
|
27
|
+
.filter((r) => !groupFilter || r.group === groupFilter)
|
|
28
|
+
.sort((a, b) => a.group.localeCompare(b.group) || a.order - b.order || a.name.localeCompare(b.name))
|
|
29
|
+
// bundle 记忆附带附件摘要(用户裁定 2026-09-17):界面要显示「几个附件」。
|
|
30
|
+
// 代价是**同步**系统调用(其余读路径都是 await):每条 bundle 一次 `readdirSync`
|
|
31
|
+
// + 每个附件一次 `statSync`,总量 = bundle 条数 × 平均附件数,随内容线性增长。
|
|
32
|
+
// 「可以忽略」这个判断只成立于 bundle 少、附件也少的时候 —— 列表是界面轮询的热路径
|
|
33
|
+
// (默认 5 秒一次,见 SETTINGS_DEFAULTS.pollIntervalMs),到几百条就该换成缓存或异步。
|
|
34
|
+
// flat 没有目录,原样返回(不加字段 = 界面不显示附件标签)。
|
|
35
|
+
.map((r) => {
|
|
36
|
+
if (r.form !== 'bundle')
|
|
37
|
+
return r;
|
|
38
|
+
const attach = attachmentSummarySync(dirname(r.path), r.name);
|
|
39
|
+
return attach ? { ...r, attach } : r;
|
|
40
|
+
});
|
|
41
|
+
const groups = groupFilter ? snap.groups.filter((g) => g.name === groupFilter) : snap.groups;
|
|
42
|
+
const scenes = sceneRows(snap, index);
|
|
43
|
+
// 场景锁定状态(v0.8):任意一个场景锁定 = 五个管理域整体冻结。界面据此禁用各页的写控件。
|
|
44
|
+
const anyLocked = Object.values(index.scenes || {}).some((s) => s.locked === true);
|
|
45
|
+
const projection = sceneMemory();
|
|
46
|
+
const promptProjection = scenePrompt();
|
|
47
|
+
return {
|
|
48
|
+
ok: true,
|
|
49
|
+
rules,
|
|
50
|
+
// 对外契约(§7.1)用 key 标识分组;name 保留兼容内部引用。
|
|
51
|
+
groups: groups.map((g) => ({ ...g, key: g.name })),
|
|
52
|
+
// 场景 = 显式记录(含保留场景 global 与尚无记忆的空场景);active = 是否参与注入。
|
|
53
|
+
scenes,
|
|
54
|
+
// 任一场景被锁定 = 五个管理域整体冻结(客户端据此禁用写控件;服务端 handlers 另有守卫)。
|
|
55
|
+
anyLocked,
|
|
56
|
+
activeMode: normalizeActive(index.active) === null ? 'all' : 'custom',
|
|
57
|
+
// 单选模型:当前启用的那个场景(null = 只留 `_shared` 与 `global`)。
|
|
58
|
+
activeScene: enabledSceneOf(index),
|
|
59
|
+
sceneMemory: { usedBytes: projection.bytes, maxBytes: projection.maxBytes, truncated: projection.truncated, dropped: projection.dropped },
|
|
60
|
+
// 当前生效的场景提示词(绑定的预设正文;与 ~/.dsh/AGENTS.md 一致时不重复注入)。
|
|
61
|
+
// `label` 是给用户指路用的显示名(「去场景设置里改『全局』的绑定」)——`global`
|
|
62
|
+
// 这类磁盘名直接甩给用户看没有意义。
|
|
63
|
+
scenePrompt: {
|
|
64
|
+
scene: promptProjection.scene,
|
|
65
|
+
label: promptProjection.scene ? sceneLabel(promptProjection.scene, index) : null,
|
|
66
|
+
presetId: promptProjection.presetId,
|
|
67
|
+
missing: promptProjection.missing,
|
|
68
|
+
duplicate: promptProjection.duplicate === true,
|
|
69
|
+
bytes: Buffer.byteLength(promptProjection.text, 'utf8'),
|
|
70
|
+
},
|
|
71
|
+
// 路径供 UI 显示「文件在哪」;不再让界面硬编码 ~/.dsh/scene-memory。
|
|
72
|
+
paths: { memories: memoriesRoot, scenes: scenesRoot, hub: stateDir },
|
|
73
|
+
stats: {
|
|
74
|
+
total: rules.length,
|
|
75
|
+
enabled: rules.filter((r) => r.enabled).length,
|
|
76
|
+
scenes: scenes.filter((s) => s.active).length,
|
|
77
|
+
},
|
|
78
|
+
...(snap.warnings.length ? { warnings: snap.warnings } : {}),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
async function rulesRead(args) {
|
|
82
|
+
const id = String((args && args.id) || '');
|
|
83
|
+
const snap = await snapshot();
|
|
84
|
+
const rule = snap.rules.find((r) => r.id === id && !r.shadowed);
|
|
85
|
+
const entry = snap.entries.get(id);
|
|
86
|
+
if (!rule || !entry)
|
|
87
|
+
return fail('error.rules.notFound', `规则不存在:${id}`);
|
|
88
|
+
let text = '';
|
|
89
|
+
try {
|
|
90
|
+
text = await readFile(entry.docPath, 'utf8');
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return fail('error.rules.notFound', `规则不存在:${id}`);
|
|
94
|
+
}
|
|
95
|
+
const doc = parseSkillDoc(text);
|
|
96
|
+
return {
|
|
97
|
+
ok: true,
|
|
98
|
+
rule: {
|
|
99
|
+
...rule,
|
|
100
|
+
body: doc.body,
|
|
101
|
+
attachments: entry.kind === 'bundle' ? await listAttachments(entry.entryPath, entry.name) : [],
|
|
102
|
+
frontmatter: {
|
|
103
|
+
name: doc.map.name != null ? String(doc.map.name) : undefined,
|
|
104
|
+
description: doc.map.description != null ? String(doc.map.description) : undefined,
|
|
105
|
+
whenToUse: doc.map.whenToUse != null ? String(doc.map.whenToUse) : undefined,
|
|
106
|
+
globs: doc.map.globs != null ? String(doc.map.globs) : undefined,
|
|
107
|
+
metadata: doc.map.metadata !== undefined ? doc.map.metadata : undefined,
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* 场景记忆段预算视图。段落由「活动场景 + 文件内容」决定,超限时**不拒绝写入**,
|
|
114
|
+
* 而是按确定性顺序截断(§4 对照表)——这里只报告事实,供 UI 显著告警。
|
|
115
|
+
*/
|
|
116
|
+
async function rulesBudget() {
|
|
117
|
+
const projection = sceneMemory();
|
|
118
|
+
return {
|
|
119
|
+
ok: true,
|
|
120
|
+
usedBytes: projection.bytes,
|
|
121
|
+
maxBytes: projection.maxBytes,
|
|
122
|
+
truncated: projection.truncated,
|
|
123
|
+
scenes: projection.scenes,
|
|
124
|
+
items: projection.items,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* 规则体检 + 诊断汇总(Phase 3):逐条规则检查 6 类异常,附场景记忆段预算与场景统计。
|
|
129
|
+
* 自身不做写操作,但 snapshot() 会顺带清理索引里的悬空记录(整份覆盖写),
|
|
130
|
+
* 所以 op 表把它也放进写队列 —— 见 op 表上方的说明。
|
|
131
|
+
*/
|
|
132
|
+
async function rulesDiagnose() {
|
|
133
|
+
const snap = await snapshot();
|
|
134
|
+
// 这里**故意**直读磁盘(而不是用 `snap.index`):体检工具的语义是「盘上现在是什么样」,
|
|
135
|
+
// 用户刚手改过索引就跑一次诊断时,值得多花一次读盘换取所见即所得。
|
|
136
|
+
const index = await readIndex(stateDir);
|
|
137
|
+
const scenes = sceneRows(snap, index);
|
|
138
|
+
const issues = [];
|
|
139
|
+
for (const rule of snap.rules) {
|
|
140
|
+
if (rule.shadowed) {
|
|
141
|
+
issues.push({ severity: 'warning', code: 'shadowed', ruleId: rule.id, message: `规则「${rule.id}」被同名 bundle 遮蔽,不会被加载。` });
|
|
142
|
+
}
|
|
143
|
+
if (String(rule.description || '').length > MAX_DESCRIPTION_LENGTH) {
|
|
144
|
+
issues.push({ severity: 'error', code: 'descriptionTooLong', ruleId: rule.id, message: `规则「${rule.id}」描述超过 ${MAX_DESCRIPTION_LENGTH} 字符,会被列表与检索截断。` });
|
|
145
|
+
}
|
|
146
|
+
if (rule.form === 'flat') {
|
|
147
|
+
const fileBase = basename(rule.path);
|
|
148
|
+
const base = fileBase.toLowerCase().endsWith('.md') ? fileBase.slice(0, -3) : fileBase;
|
|
149
|
+
if (base !== rule.name) {
|
|
150
|
+
issues.push({ severity: 'error', code: 'nameMismatch', ruleId: rule.id, message: `规则「${rule.id}」文件名(${fileBase})与 name(${rule.name})不一致,无法按 name 定位。` });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const body = snap.bodies.get(rule.id) || '';
|
|
154
|
+
if (body.trim() === '') {
|
|
155
|
+
issues.push({ severity: 'error', code: 'emptyBody', ruleId: rule.id, message: `规则「${rule.id}」正文为空。` });
|
|
156
|
+
}
|
|
157
|
+
if (rule.descriptionDerived && String(rule.description || '').length < 10) {
|
|
158
|
+
issues.push({ severity: 'info', code: 'vagueDescription', ruleId: rule.id, message: `规则「${rule.id}」描述过于笼统(自动派生,不足 10 字符),建议补充。` });
|
|
159
|
+
}
|
|
160
|
+
// 记忆所在场景未启用 → 不会自动生效(不是错误,但值得提示,避免"改了没效果")。
|
|
161
|
+
const scene = sceneOf(rule.group);
|
|
162
|
+
if (scene === '') {
|
|
163
|
+
// v0.4:记忆必须归属某个场景(留空 = 保留场景 `global`)。归不到场景的记忆
|
|
164
|
+
// 不会被投影,也不会出现在场景卡片里 —— 必须显式报出来,不能静默。
|
|
165
|
+
if (!rule.shadowed) {
|
|
166
|
+
issues.push({ severity: 'warning', code: 'noScene', ruleId: rule.id, message: `规则「${rule.id}」没有归属场景(文件直接放在 memories/ 根层),不会被注入。请移入某个场景目录,或放到 memories/${GLOBAL_SCENE}/ 作为「全局」记忆。` });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
else if (!rule.shadowed) {
|
|
170
|
+
const row = scenes.find((s) => s.name === scene);
|
|
171
|
+
if (row && !row.active) {
|
|
172
|
+
issues.push({ severity: 'info', code: 'sceneDisabled', ruleId: rule.id, message: `规则「${rule.id}」所属场景「${scene}」未启用,当前不会进入系统提示词。` });
|
|
173
|
+
}
|
|
174
|
+
else if (!row) {
|
|
175
|
+
issues.push({ severity: 'warning', code: 'sceneUnknown', ruleId: rule.id, message: `规则「${rule.id}」的场景「${scene}」没有对应记录(可能被手工创建)——请到「场景」页补一条场景描述。` });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// frontmatter 非法:以 --- 开头但解析不出任何字段(残缺 frontmatter)。
|
|
180
|
+
for (const entry of snap.entries.values()) {
|
|
181
|
+
try {
|
|
182
|
+
const text = await readFile(entry.docPath, 'utf8');
|
|
183
|
+
const stripped = String(text || '').replace(/^\uFEFF/, '').trimStart();
|
|
184
|
+
if (stripped.startsWith('---')) {
|
|
185
|
+
const doc = parseSkillDoc(text);
|
|
186
|
+
if (Object.keys(doc.map || {}).length === 0) {
|
|
187
|
+
issues.push({ severity: 'warning', code: 'badFrontmatter', ruleId: entry.id, message: `规则「${entry.id}」frontmatter 无法解析(以 --- 开头但无有效字段)。` });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
catch { /* 读不到正文的条目根本不进 snap.entries —— 它在 buildSnapshot 的 warnings 里 */ }
|
|
192
|
+
}
|
|
193
|
+
const projection = sceneMemory();
|
|
194
|
+
if (projection.truncated) {
|
|
195
|
+
issues.push({
|
|
196
|
+
severity: 'warning',
|
|
197
|
+
code: 'sceneMemoryTruncated',
|
|
198
|
+
message: `场景记忆段超出预算(${projection.bytes} 字节 > ${projection.maxBytes} 字节),已按确定性顺序截断并追加 ${TRUNCATION_MARKER};请精简记忆或只保留 _shared。`,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
ok: true,
|
|
203
|
+
issues,
|
|
204
|
+
budget: { usedBytes: projection.bytes, maxBytes: projection.maxBytes, over: projection.truncated },
|
|
205
|
+
counts: { rules: snap.rules.length, groups: snap.groups.length, scenes: scenes.length },
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
// ── ops:写(串行队列内)───────────────────────────────────────────────
|
|
209
|
+
async function rulesCreate(args) {
|
|
210
|
+
const group = String((args && args.group) || '').trim();
|
|
211
|
+
const name = String((args && args.name) || '').trim();
|
|
212
|
+
const form = args && args.form === 'bundle' ? 'bundle' : 'flat';
|
|
213
|
+
// 场景必填:留空落到保留场景 `global`(界面「全局」,任何对话都注入)。
|
|
214
|
+
// 场景必须是**已存在**的记录——写成不存在的名字会静默造出一个没有描述的场景,
|
|
215
|
+
// 所以这里显式引导用户先去「场景」页创建(错误码可被 UI 翻译)。
|
|
216
|
+
if (group !== '' && !isValidGroupPath(group))
|
|
217
|
+
return fail('error.rules.invalidGroup', `场景名非法:${group}(${SEGMENT_RULE_HINT})`);
|
|
218
|
+
const targetGroup = group === '' ? GLOBAL_SCENE : group;
|
|
219
|
+
const indexForScene = await readIndex(stateDir);
|
|
220
|
+
// 保留场景 global 恒存在(不用先建);其余场景必须已存在——见上方注释。
|
|
221
|
+
if (targetGroup !== GLOBAL_SCENE && !indexForScene.scenes?.[targetGroup] && !(await pathExists(join(memoriesRoot, targetGroup)))) {
|
|
222
|
+
return fail('error.rules.sceneNotFound', `场景不存在:${targetGroup}(请先在「场景」页创建该场景)`, { group: targetGroup });
|
|
223
|
+
}
|
|
224
|
+
if (!isValidGroupSegment(name))
|
|
225
|
+
return fail('error.rules.invalidName', `记忆名非法:${name}(${SEGMENT_RULE_HINT})`);
|
|
226
|
+
// 目标已存在(bundle 或 flat 皆算)→ 拒绝,避免静默覆盖。
|
|
227
|
+
// 用 exists 而不是 shadowed:后者说的是「同名 bundle 把 flat 遮蔽了」,用户看到
|
|
228
|
+
// 「被同名 bundle 遮蔽」会去找一个并不存在的 bundle(2026-09-16 实测)。
|
|
229
|
+
const existing = await locateRule(group, name);
|
|
230
|
+
if (existing) {
|
|
231
|
+
return fail('error.rules.exists', `同名记忆已存在(${existing.kind === 'bundle' ? 'bundle' : 'flat'}):${existing.id}`);
|
|
232
|
+
}
|
|
233
|
+
// 落点必须在根内(realpath 口径):`targetGroup` 的某一级可能是指向根外的目录链接。
|
|
234
|
+
const denied = await refuseOutsideRoot(join(memoriesRoot, targetGroup, name));
|
|
235
|
+
if (denied)
|
|
236
|
+
return denied;
|
|
237
|
+
const body = String((args && args.body) ?? '');
|
|
238
|
+
if (body.trim() === '')
|
|
239
|
+
return fail('error.rules.bodyRequired', '规则正文不能为空');
|
|
240
|
+
if (Buffer.byteLength(body, 'utf8') > MAX_RULE_BYTES)
|
|
241
|
+
return fail('error.rules.tooLarge', `规则正文过大(超过 ${MAX_RULE_BYTES >> 10} KiB)`);
|
|
242
|
+
let description;
|
|
243
|
+
const rawDescription = args && args.description != null ? String(args.description).trim() : '';
|
|
244
|
+
if (rawDescription !== '') {
|
|
245
|
+
if (rawDescription.length > MAX_DESCRIPTION_LENGTH) {
|
|
246
|
+
return fail('error.rules.descriptionTooLong', `描述超过 ${MAX_DESCRIPTION_LENGTH} 字符(当前 ${rawDescription.length})`);
|
|
247
|
+
}
|
|
248
|
+
description = rawDescription;
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
const derived = deriveDescription(body);
|
|
252
|
+
if (!derived)
|
|
253
|
+
return fail('error.rules.descriptionRequired', '请提供规则描述(正文中也无可派生的标题或首行)');
|
|
254
|
+
description = derived; // 派生只进内存投影,不写回文件
|
|
255
|
+
}
|
|
256
|
+
// 正文序列化(frontmatter 仅写用户显式提供的字段)。是否生效由"场景是否启用"决定,
|
|
257
|
+
// 超预算只在渲染时确定性截断 + 告警,不再拒绝写入(§4 对照表)。
|
|
258
|
+
const fields = { name };
|
|
259
|
+
if (rawDescription !== '')
|
|
260
|
+
fields.description = description;
|
|
261
|
+
const text = serializeRuleFile(fields, body);
|
|
262
|
+
// 更新索引(force 重读后合并,避免覆盖用户手工编辑)。
|
|
263
|
+
// id 恒为 `<场景>/<名>`(与 parseId 同构);场景记录此时必然已存在。
|
|
264
|
+
const createdId = `${targetGroup}/${name}`;
|
|
265
|
+
const index = indexForScene;
|
|
266
|
+
// P5:新建记忆默认**不开启** —— 记忆的开关是单一真相源(`enabled`),
|
|
267
|
+
// 用户裁定「创建的记忆默认不开启,在记忆页开启后对应的场景档案也要显示开启」。
|
|
268
|
+
index.rules[createdId] = { order: DEFAULT_ORDER, enabled: false, updatedAt: new Date().toISOString() };
|
|
269
|
+
if (!index.groups[targetGroup])
|
|
270
|
+
index.groups[targetGroup] = { order: DEFAULT_GROUP_ORDER, label: targetGroup };
|
|
271
|
+
// **先落索引、再落文件**,顺序不能反。索引条目指向一个尚不存在的文件是无害的:列表与投影
|
|
272
|
+
// 都以「发现到的文件」为准,找不到就跳过,buildSnapshot 的清理还会顺手把这条记录删掉。
|
|
273
|
+
// 反过来(先文件后索引)一旦进程死在中间,留下的文件没有索引条目,就会落到 projectRule
|
|
274
|
+
// 的 `enabled ?? true` 默认值上 —— 用户没开过的记忆被当成启用并注入模型上下文,且无提示。
|
|
275
|
+
await writeIndex(stateDir, index);
|
|
276
|
+
try {
|
|
277
|
+
if (form === 'bundle') {
|
|
278
|
+
await mkdir(join(memoriesRoot, targetGroup, name), { recursive: true });
|
|
279
|
+
await writeFileAtomically(join(memoriesRoot, targetGroup, name, bundleDocName(name)), text);
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
await mkdir(join(memoriesRoot, targetGroup), { recursive: true });
|
|
283
|
+
await writeFileAtomically(join(memoriesRoot, targetGroup, name + '.md'), text);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
catch (e) {
|
|
287
|
+
// 文件没落成 → 撤回刚写的索引条目,别留下一条指向不存在文件的幽灵记录。
|
|
288
|
+
delete index.rules[createdId];
|
|
289
|
+
try {
|
|
290
|
+
await writeIndex(stateDir, index);
|
|
291
|
+
}
|
|
292
|
+
catch { /* 撤回失败不掩盖原始错误 */ }
|
|
293
|
+
throw e;
|
|
294
|
+
}
|
|
295
|
+
invalidateSnapshot();
|
|
296
|
+
return { ok: true, rule: await buildProjected(createdId) };
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* 导入记忆(.md / .zip):文件即真源——把 .md 原文落进 `<场景>/<名>.md`,场景为空 = 全局根层
|
|
300
|
+
* zip 内带目录 → 目录路径当场景;裸 .md → 落到 `args.scene`(留空 = 保留场景 `global`)。
|
|
301
|
+
* 引用到的场景不存在时**自动补一条场景记录**(导入是批量动作,要求用户先逐个建场景不现实);
|
|
302
|
+
* 这不算静默造场景——被补的场景会在 `scenes` 结果里回传,UI 会提示。
|
|
303
|
+
* 重名一律**跳过并报告**(与技能导入同策略),绝不覆盖用户既有文件。
|
|
304
|
+
* 部分成功:单条失败只记 skipped,不影响同批其余条目。
|
|
305
|
+
*/
|
|
306
|
+
async function rulesImport(args) {
|
|
307
|
+
const scene = String((args && args.scene) || '').replace(/\\/g, '/').replace(/^\/+|\/+$/g, '').trim();
|
|
308
|
+
if (scene !== '' && !isValidGroupPath(scene)) {
|
|
309
|
+
return fail('error.rules.invalidGroup', `场景名非法:${scene}(留空 = 全局;否则${SEGMENT_RULE_HINT})`);
|
|
310
|
+
}
|
|
311
|
+
const defaultScene = scene === '' ? GLOBAL_SCENE : scene;
|
|
312
|
+
const files = args && args.files;
|
|
313
|
+
if (!Array.isArray(files) || !files.length)
|
|
314
|
+
return fail('error.import.noFiles', '没有选择要导入的文件');
|
|
315
|
+
const { entries, problems } = expandUploads(files);
|
|
316
|
+
const planned = planMemoryImport(entries, defaultScene);
|
|
317
|
+
const skipped = [...problems, ...planned.problems];
|
|
318
|
+
const imported = [];
|
|
319
|
+
const accepted = [];
|
|
320
|
+
for (const target of planned.targets) {
|
|
321
|
+
if (target.group !== '' && !isValidGroupPath(target.group)) {
|
|
322
|
+
skipped.push({ name: target.name, reason: '场景名非法,已跳过' });
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (!isValidGroupSegment(target.name)) {
|
|
326
|
+
skipped.push({ name: target.name, reason: '记忆名不合法,已跳过' });
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
const text = Buffer.from(target.bytes).toString('utf8').replace(/^\uFEFF/, '');
|
|
330
|
+
if (text.trim() === '') {
|
|
331
|
+
skipped.push({ name: target.name, reason: '内容为空,已跳过' });
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (Buffer.byteLength(text, 'utf8') > MAX_RULE_BYTES) {
|
|
335
|
+
skipped.push({ name: target.name, reason: `正文超过 ${MAX_RULE_BYTES >> 10} KiB,已跳过` });
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
// bundle 附件复检(规划层已过滤,这里按 rules-attach 同口径再拦一次,防绕过规划层的调用方)。
|
|
339
|
+
const rawAttachments = target.kind === 'bundle' ? target.attachments || [] : [];
|
|
340
|
+
const checkedAttachments = [];
|
|
341
|
+
// 同名检查必须与 rules-attach 同口径:写入顺序是「正文先、附件后」,附件名撞上正文本体
|
|
342
|
+
// 会把刚写好的正文覆盖掉(用户数据静默丢失)。此前这里漏了这道,注释却写着「同口径」。
|
|
343
|
+
const docNames = new Set([bundleDocName(target.name), LEGACY_BUNDLE_DOC]);
|
|
344
|
+
let attachTotal = 0;
|
|
345
|
+
for (const att of rawAttachments) {
|
|
346
|
+
if (!isValidGroupSegment(att.name)) {
|
|
347
|
+
skipped.push({ name: `${target.name}/${att.name}`, reason: '附件名不合法,已跳过' });
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (docNames.has(att.name)) {
|
|
351
|
+
skipped.push({ name: `${target.name}/${att.name}`, reason: '附件名不能与正文文件同名,已跳过' });
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
const data = Buffer.from(att.bytes);
|
|
355
|
+
if (!data.length) {
|
|
356
|
+
skipped.push({ name: `${target.name}/${att.name}`, reason: '附件内容为空,已跳过' });
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (data.length > MAX_ATTACH_ENTRY_BYTES) {
|
|
360
|
+
skipped.push({ name: `${target.name}/${att.name}`, reason: `附件过大(单个上限 ${MAX_ATTACH_ENTRY_BYTES >> 20} MiB),已跳过` });
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
attachTotal += data.length;
|
|
364
|
+
if (attachTotal > MAX_ATTACH_TOTAL_BYTES) {
|
|
365
|
+
skipped.push({ name: `${target.name}/${att.name}`, reason: `附件合计超过 ${MAX_ATTACH_TOTAL_BYTES >> 20} MiB,已跳过` });
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
checkedAttachments.push({ name: att.name, data });
|
|
369
|
+
}
|
|
370
|
+
const existing = await locateRule(target.group, target.name);
|
|
371
|
+
if (existing) {
|
|
372
|
+
skipped.push({ name: target.group ? `${target.group}/${target.name}` : target.name, reason: '同名已存在(已跳过)' });
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
accepted.push({ group: target.group, name: target.name, text, kind: target.kind, attachments: checkedAttachments });
|
|
376
|
+
}
|
|
377
|
+
// 落点必须在根内(realpath 口径):场景目录可能是指向根外的链接。逐条判定、失败的进 skipped
|
|
378
|
+
// —— 导入是批量操作,不该因为一条越界就把其余条目一起丢掉。
|
|
379
|
+
for (let i = accepted.length - 1; i >= 0; i--) {
|
|
380
|
+
const denied = await refuseOutsideRoot(join(memoriesRoot, accepted[i].group, accepted[i].name));
|
|
381
|
+
if (!denied)
|
|
382
|
+
continue;
|
|
383
|
+
skipped.push({ name: `${accepted[i].group}/${accepted[i].name}`, reason: denied.error });
|
|
384
|
+
accepted.splice(i, 1);
|
|
385
|
+
}
|
|
386
|
+
if (!accepted.length)
|
|
387
|
+
return { ok: true, imported, skipped, scenes: [] };
|
|
388
|
+
const index = await readIndex(stateDir);
|
|
389
|
+
if (!index.scenes)
|
|
390
|
+
index.scenes = {};
|
|
391
|
+
const createdScenes = [];
|
|
392
|
+
// 场景记录在本趟新增的那些:只有对应的记忆真的落成,才算「新建了场景」报给界面。
|
|
393
|
+
const scenesAddedHere = new Set();
|
|
394
|
+
// 第一趟:索引条目全部写好(含场景分组与场景记录)。
|
|
395
|
+
// **先索引后文件**,顺序不能反 —— 先文件后索引时,进程死在中间会留下没有索引条目的文件,
|
|
396
|
+
// 它们会落到 projectRule 的 `enabled ?? true` 默认值上,被当成启用并注入模型上下文
|
|
397
|
+
// (与 rules-create 同一处说明)。反过来留下的索引条目指向不存在的文件是无害的:
|
|
398
|
+
// 列表与投影都以发现到的文件为准,buildSnapshot 的清理还会把这类记录删掉。
|
|
399
|
+
for (const item of accepted) {
|
|
400
|
+
const id = `${item.group}/${item.name}`;
|
|
401
|
+
// 索引记录:与 rules-create 同口径(order/enabled/updatedAt + 场景分组),用户既有设置不覆盖。
|
|
402
|
+
const entry = index.rules[id];
|
|
403
|
+
// P5:导入的记忆同样默认**不开启**(与 rules-create 同一口径);已存在的条目保留原开关。
|
|
404
|
+
index.rules[id] = { ...(entry || {}), order: entry?.order ?? DEFAULT_ORDER, enabled: entry?.enabled ?? false, updatedAt: new Date().toISOString() };
|
|
405
|
+
if (!index.groups[item.group])
|
|
406
|
+
index.groups[item.group] = { order: DEFAULT_GROUP_ORDER, label: item.group };
|
|
407
|
+
// 场景记录补齐(导入进来的目录名此前可能没有记录)。
|
|
408
|
+
if (!index.scenes[item.group]) {
|
|
409
|
+
index.scenes[item.group] = item.group === GLOBAL_SCENE
|
|
410
|
+
? { label: GLOBAL_SCENE_LABEL, order: 0 }
|
|
411
|
+
: { order: DEFAULT_GROUP_ORDER, createdAt: new Date().toISOString() };
|
|
412
|
+
scenesAddedHere.add(item.group);
|
|
413
|
+
}
|
|
414
|
+
imported.push(id);
|
|
415
|
+
}
|
|
416
|
+
await writeIndex(stateDir, index);
|
|
417
|
+
// 第二趟:落文件。失败项从索引里撤回,否则会留下指向不存在文件的幽灵条目。
|
|
418
|
+
let rolledBack = false;
|
|
419
|
+
for (const item of accepted) {
|
|
420
|
+
const id = `${item.group}/${item.name}`;
|
|
421
|
+
try {
|
|
422
|
+
if (item.kind === 'bundle') {
|
|
423
|
+
// 与 rules-create 同落点:bundle = <场景>/<名>/ 目录,正文 <名>.md,附件平铺同层。
|
|
424
|
+
const bundleDir = join(memoriesRoot, item.group, item.name);
|
|
425
|
+
await mkdir(bundleDir, { recursive: true });
|
|
426
|
+
await writeFileAtomically(join(bundleDir, bundleDocName(item.name)), item.text);
|
|
427
|
+
for (const att of item.attachments)
|
|
428
|
+
await writeFileAtomicBinary(join(bundleDir, att.name), att.data);
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
await mkdir(join(memoriesRoot, item.group), { recursive: true });
|
|
432
|
+
await writeFileAtomically(join(memoriesRoot, item.group, item.name + '.md'), item.text);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
catch (e) {
|
|
436
|
+
skipped.push({ name: id, reason: '写入失败:' + message(e) });
|
|
437
|
+
delete index.rules[id];
|
|
438
|
+
const at = imported.indexOf(id);
|
|
439
|
+
if (at >= 0)
|
|
440
|
+
imported.splice(at, 1);
|
|
441
|
+
rolledBack = true;
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
if (scenesAddedHere.has(item.group) && createdScenes.indexOf(item.group) < 0)
|
|
445
|
+
createdScenes.push(item.group);
|
|
446
|
+
}
|
|
447
|
+
if (rolledBack)
|
|
448
|
+
await writeIndex(stateDir, index);
|
|
449
|
+
return { ok: true, imported, skipped, scenes: createdScenes };
|
|
450
|
+
}
|
|
451
|
+
async function rulesUpdate(args) {
|
|
452
|
+
const id = String((args && args.id) || '');
|
|
453
|
+
const parts = parseId(id);
|
|
454
|
+
if (!parts)
|
|
455
|
+
return fail('error.rules.notFound', `规则不存在:${id}`);
|
|
456
|
+
const located = await locateRule(parts.group, parts.name);
|
|
457
|
+
if (!located)
|
|
458
|
+
return fail('error.rules.notFound', `规则不存在:${id}`);
|
|
459
|
+
let text;
|
|
460
|
+
try {
|
|
461
|
+
text = await readFile(located.docPath, 'utf8');
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
return fail('error.rules.notFound', `规则不存在:${id}`);
|
|
465
|
+
}
|
|
466
|
+
const doc = parseSkillDoc(text);
|
|
467
|
+
// flat 形态:frontmatter 声明名必须等于文件名(发现时可列出,更新时校验)。
|
|
468
|
+
const currentName = doc.map.name != null && String(doc.map.name).trim() !== '' ? unquote(String(doc.map.name)).trim() : parts.name;
|
|
469
|
+
if (located.kind === 'flat' && currentName !== parts.name) {
|
|
470
|
+
return fail('error.rules.invalidName', `flat 规则名与文件名不一致(frontmatter name: ${currentName},文件名: ${parts.name}),请先修正规则文件`);
|
|
471
|
+
}
|
|
472
|
+
const newName = args && args.name != null ? String(args.name).trim() : currentName;
|
|
473
|
+
if (!isValidGroupSegment(newName))
|
|
474
|
+
return fail('error.rules.invalidName', `记忆名非法:${newName}(${SEGMENT_RULE_HINT})`);
|
|
475
|
+
const newForm = args && args.form === 'bundle' ? 'bundle' : args && args.form === 'flat' ? 'flat' : located.kind;
|
|
476
|
+
const newBody = args && args.body != null ? String(args.body) : doc.body;
|
|
477
|
+
if (newBody.trim() === '')
|
|
478
|
+
return fail('error.rules.bodyRequired', '规则正文不能为空');
|
|
479
|
+
if (Buffer.byteLength(newBody, 'utf8') > MAX_RULE_BYTES)
|
|
480
|
+
return fail('error.rules.tooLarge', `规则正文过大(超过 ${MAX_RULE_BYTES >> 10} KiB)`);
|
|
481
|
+
// description:显式传 → 校验并写文件;传空串 → 删除字段并从正文派生;未传 → 保留现有(缺失则派生)。
|
|
482
|
+
let description;
|
|
483
|
+
if (args && args.description != null) {
|
|
484
|
+
const ds = String(args.description).trim();
|
|
485
|
+
if (ds !== '') {
|
|
486
|
+
if (ds.length > MAX_DESCRIPTION_LENGTH) {
|
|
487
|
+
return fail('error.rules.descriptionTooLong', `描述超过 ${MAX_DESCRIPTION_LENGTH} 字符(当前 ${ds.length})`);
|
|
488
|
+
}
|
|
489
|
+
description = ds;
|
|
490
|
+
}
|
|
491
|
+
else {
|
|
492
|
+
const derived = deriveDescription(newBody);
|
|
493
|
+
if (!derived)
|
|
494
|
+
return fail('error.rules.descriptionRequired', '请提供规则描述(正文中也无可派生的标题或首行)');
|
|
495
|
+
description = derived; // 派生不写回
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
else {
|
|
499
|
+
const existing = doc.map.description != null ? unquote(String(doc.map.description)).trim() : '';
|
|
500
|
+
if (existing !== '')
|
|
501
|
+
description = existing;
|
|
502
|
+
else
|
|
503
|
+
description = deriveDescription(newBody) || undefined;
|
|
504
|
+
}
|
|
505
|
+
// 形态转换 / 改名:先建新形态文件,再把旧形态复制进回收站、最后删原件
|
|
506
|
+
// (确保任意失败点不产生半份规则;旧形态随时可恢复,README「删除都进回收站」无例外)。
|
|
507
|
+
// description 写回约定:显式传 → string/null(删除);未传 → undefined(保留原字段)。
|
|
508
|
+
const serialize = () => {
|
|
509
|
+
let descArg;
|
|
510
|
+
if (args && args.description != null)
|
|
511
|
+
descArg = String(args.description).trim() === '' ? null : description;
|
|
512
|
+
else
|
|
513
|
+
descArg = undefined;
|
|
514
|
+
return serializeUpdatedFile(doc, newName, descArg, newBody);
|
|
515
|
+
};
|
|
516
|
+
// 所有写入 / 删除分支的落点都在 `<root>/<group>` 之下:先确认这个目录(经 realpath)仍在根内。
|
|
517
|
+
const denied = await refuseOutsideRoot(join(memoriesRoot, parts.group));
|
|
518
|
+
if (denied)
|
|
519
|
+
return denied;
|
|
520
|
+
if (located.kind === 'bundle' && newForm === 'flat') {
|
|
521
|
+
await mkdir(join(memoriesRoot, parts.group), { recursive: true });
|
|
522
|
+
await writeFileAtomically(join(memoriesRoot, parts.group, newName + '.md'), serialize());
|
|
523
|
+
await copyIntoMemoriesTrash(located, parts.group, parts.name);
|
|
524
|
+
await rm(located.entryPath, { recursive: true, force: true });
|
|
525
|
+
}
|
|
526
|
+
else if (located.kind === 'flat' && newForm === 'bundle') {
|
|
527
|
+
await mkdir(join(memoriesRoot, parts.group, newName), { recursive: true });
|
|
528
|
+
await writeFileAtomically(join(memoriesRoot, parts.group, newName, bundleDocName(newName)), serialize());
|
|
529
|
+
await copyIntoMemoriesTrash(located, parts.group, parts.name);
|
|
530
|
+
await rm(join(memoriesRoot, parts.group, parts.name + '.md'), { force: true });
|
|
531
|
+
}
|
|
532
|
+
else if (located.kind === 'flat' && newName !== parts.name) {
|
|
533
|
+
// flat 改名 = 文件改名
|
|
534
|
+
await writeFileAtomically(join(memoriesRoot, parts.group, newName + '.md'), serialize());
|
|
535
|
+
await copyIntoMemoriesTrash(located, parts.group, parts.name);
|
|
536
|
+
await rm(join(memoriesRoot, parts.group, parts.name + '.md'), { force: true });
|
|
537
|
+
}
|
|
538
|
+
else {
|
|
539
|
+
// bundle 不 rename 目录:只更新 frontmatter/正文
|
|
540
|
+
await writeFileAtomically(located.docPath, serialize());
|
|
541
|
+
}
|
|
542
|
+
// 索引:改名则迁移记录(保留 order/tags/enabled 等),否则原地更新。
|
|
543
|
+
const index = await readIndex(stateDir);
|
|
544
|
+
const newId = `${parts.group}/${newName}`;
|
|
545
|
+
if (newId !== id && index.rules[id]) {
|
|
546
|
+
index.rules[newId] = { ...index.rules[id], updatedAt: new Date().toISOString() };
|
|
547
|
+
delete index.rules[id];
|
|
548
|
+
}
|
|
549
|
+
else {
|
|
550
|
+
index.rules[id] = { ...(index.rules[id] || {}), updatedAt: new Date().toISOString() };
|
|
551
|
+
}
|
|
552
|
+
await writeIndex(stateDir, index);
|
|
553
|
+
invalidateSnapshot();
|
|
554
|
+
return { ok: true, rule: await buildProjected(newId) };
|
|
555
|
+
}
|
|
556
|
+
async function rulesToggle(args) {
|
|
557
|
+
const id = String((args && args.id) || '');
|
|
558
|
+
const parts = parseId(id);
|
|
559
|
+
if (!parts)
|
|
560
|
+
return fail('error.rules.notFound', `规则不存在:${id}`);
|
|
561
|
+
// `enabled` 必须显式给。缺省时旧实现回落到"沿用当前值",却照样刷新
|
|
562
|
+
// updatedAt 并返回 ok:true —— 调用方按 toggle(翻转)理解时会以为自己改了
|
|
563
|
+
// 状态,实际什么都没改(界面不受影响:它一直显式传值)。与 rulesSetActive
|
|
564
|
+
// 同一条口径:参数缺失就明确拒绝,不猜。
|
|
565
|
+
if (typeof (args && args.enabled) !== 'boolean') {
|
|
566
|
+
return fail('error.rules.invalidArgs', '缺少参数:enabled 必须是布尔值(toggle 只按传入值写入,不做"翻转"推断)');
|
|
567
|
+
}
|
|
568
|
+
const index = await readIndex(stateDir);
|
|
569
|
+
const idxEntry = index.rules[id] || {};
|
|
570
|
+
const enabled = args.enabled === true;
|
|
571
|
+
index.rules[id] = { ...idxEntry, enabled, updatedAt: new Date().toISOString() };
|
|
572
|
+
await writeIndex(stateDir, index);
|
|
573
|
+
invalidateSnapshot();
|
|
574
|
+
return { ok: true, rule: await buildProjected(id) };
|
|
575
|
+
}
|
|
576
|
+
async function rulesSetIndex(args) {
|
|
577
|
+
const id = String((args && args.id) || '');
|
|
578
|
+
const parts = parseId(id);
|
|
579
|
+
if (!parts)
|
|
580
|
+
return fail('error.rules.notFound', `规则不存在:${id}`);
|
|
581
|
+
const index = await readIndex(stateDir);
|
|
582
|
+
const idxEntry = index.rules[id] || {};
|
|
583
|
+
const next = { ...idxEntry, updatedAt: new Date().toISOString() };
|
|
584
|
+
if (args && args.order !== undefined) {
|
|
585
|
+
const order = Number(args.order);
|
|
586
|
+
if (!Number.isFinite(order) || order < 0)
|
|
587
|
+
return fail('error.rules.invalidGroup', `非法排序值:${args.order}`);
|
|
588
|
+
next.order = Math.floor(order);
|
|
589
|
+
}
|
|
590
|
+
if (args && args.tags !== undefined) {
|
|
591
|
+
next.tags = Array.isArray(args.tags) ? args.tags.map((t) => String(t)) : [];
|
|
592
|
+
}
|
|
593
|
+
if (args && args.pinned !== undefined)
|
|
594
|
+
next.pinned = args.pinned === true;
|
|
595
|
+
if (args && args.note !== undefined)
|
|
596
|
+
next.note = String(args.note);
|
|
597
|
+
index.rules[id] = next;
|
|
598
|
+
await writeIndex(stateDir, index);
|
|
599
|
+
invalidateSnapshot();
|
|
600
|
+
return { ok: true, rule: await buildProjected(id) };
|
|
601
|
+
}
|
|
602
|
+
// ── 组装 ─────────────────────────────────────────────────────────────────
|
|
603
|
+
/**
|
|
604
|
+
* 往 bundle 记忆里写附件。flat 形态没有目录,直接拒绝。
|
|
605
|
+
* 先整体校验、再逐个落盘:避免写到一半才因为第 N 个文件非法而留下半份附件。
|
|
606
|
+
* 同名附件覆盖(这是用户对自己文件的显式上传动作)。
|
|
607
|
+
*/
|
|
608
|
+
async function rulesAttach(args) {
|
|
609
|
+
const id = String((args && args.id) || '');
|
|
610
|
+
const parts = parseId(id);
|
|
611
|
+
if (!parts)
|
|
612
|
+
return fail('error.rules.notFound', `规则不存在:${id}`);
|
|
613
|
+
const located = await locateRule(parts.group, parts.name);
|
|
614
|
+
if (!located)
|
|
615
|
+
return fail('error.rules.notFound', `规则不存在:${id}`);
|
|
616
|
+
if (located.kind !== 'bundle')
|
|
617
|
+
return fail('error.rules.notBundle', `「${parts.name}」是 flat(单文件),不能带附件`);
|
|
618
|
+
const files = Array.isArray(args && args.files) ? args.files : [];
|
|
619
|
+
if (files.length === 0)
|
|
620
|
+
return fail('error.rules.noFiles', '没有选择附件');
|
|
621
|
+
if (files.length > MAX_ATTACH_ENTRIES)
|
|
622
|
+
return fail('error.rules.tooManyFiles', `一次最多 ${MAX_ATTACH_ENTRIES} 个附件`, { limit: MAX_ATTACH_ENTRIES });
|
|
623
|
+
const pending = [];
|
|
624
|
+
let total = 0;
|
|
625
|
+
const docNames = new Set([bundleDocName(located.name), LEGACY_BUNDLE_DOC]);
|
|
626
|
+
for (const file of files) {
|
|
627
|
+
const name = String((file && (file.path || file.name)) || '');
|
|
628
|
+
if (!isValidGroupSegment(name))
|
|
629
|
+
return fail('error.rules.invalidName', `附件名非法:${name || '(空)'}`);
|
|
630
|
+
if (docNames.has(name))
|
|
631
|
+
return fail('error.rules.invalidName', `附件名不能与正文文件同名:${name}`);
|
|
632
|
+
const data = Buffer.from(String((file && file.data) || ''), 'base64');
|
|
633
|
+
if (data.length === 0)
|
|
634
|
+
return fail('error.rules.emptyFile', `附件内容为空:${name}`);
|
|
635
|
+
if (data.length > MAX_ATTACH_ENTRY_BYTES)
|
|
636
|
+
return fail('error.rules.fileTooLarge', `附件过大:${name}(单个上限 ${MAX_ATTACH_ENTRY_BYTES >> 20} MiB)`, { limit: MAX_ATTACH_ENTRY_BYTES >> 20 });
|
|
637
|
+
total += data.length;
|
|
638
|
+
if (total > MAX_ATTACH_TOTAL_BYTES)
|
|
639
|
+
return fail('error.rules.tooLarge', `附件总大小超过 ${MAX_ATTACH_TOTAL_BYTES >> 20} MiB`, { limit: MAX_ATTACH_TOTAL_BYTES >> 20 });
|
|
640
|
+
pending.push({ name, data });
|
|
641
|
+
}
|
|
642
|
+
for (const file of pending)
|
|
643
|
+
await writeFileAtomicBinary(join(located.entryPath, file.name), file.data);
|
|
644
|
+
return { ok: true, attachments: await listAttachments(located.entryPath, located.name) };
|
|
645
|
+
}
|
|
646
|
+
/** 删除 bundle 记忆的一个附件;正文 `<名>.md`(或旧数据 `SKILL.md`)不可删。 */
|
|
647
|
+
async function rulesDetach(args) {
|
|
648
|
+
const id = String((args && args.id) || '');
|
|
649
|
+
const parts = parseId(id);
|
|
650
|
+
if (!parts)
|
|
651
|
+
return fail('error.rules.notFound', `规则不存在:${id}`);
|
|
652
|
+
const located = await locateRule(parts.group, parts.name);
|
|
653
|
+
if (!located)
|
|
654
|
+
return fail('error.rules.notFound', `规则不存在:${id}`);
|
|
655
|
+
if (located.kind !== 'bundle')
|
|
656
|
+
return fail('error.rules.notBundle', `「${parts.name}」是 flat(单文件),没有附件`);
|
|
657
|
+
const name = String((args && args.name) || '');
|
|
658
|
+
if (!isValidGroupSegment(name) || name === bundleDocName(located.name) || name === LEGACY_BUNDLE_DOC) {
|
|
659
|
+
return fail('error.rules.invalidName', `附件名非法:${name || '(空)'}`);
|
|
660
|
+
}
|
|
661
|
+
const target = join(located.entryPath, name);
|
|
662
|
+
// 附件落点同样要在根内(realpath 口径)。
|
|
663
|
+
const denied = await refuseOutsideRoot(target);
|
|
664
|
+
if (denied)
|
|
665
|
+
return denied;
|
|
666
|
+
try {
|
|
667
|
+
const st = await lstat(target);
|
|
668
|
+
if (!st.isFile() || st.isSymbolicLink())
|
|
669
|
+
return fail('error.rules.notFound', `附件不存在:${name}`);
|
|
670
|
+
}
|
|
671
|
+
catch {
|
|
672
|
+
return fail('error.rules.notFound', `附件不存在:${name}`);
|
|
673
|
+
}
|
|
674
|
+
await rm(target, { force: true });
|
|
675
|
+
return { ok: true, attachments: await listAttachments(located.entryPath, located.name) };
|
|
676
|
+
}
|
|
677
|
+
return { rulesList, rulesRead, rulesBudget, rulesDiagnose, rulesCreate, rulesImport, rulesUpdate, rulesToggle, rulesSetIndex, rulesAttach, rulesDetach };
|
|
678
|
+
}
|