dsh-plugin-tool-management 0.8.0 → 0.9.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 +67 -1
- package/README.md +53 -48
- package/README_EN.md +47 -44
- 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 +39 -0
- package/lib/agents-md/preset-id.js +1 -1
- package/lib/agents-md/service.js +46 -9
- package/lib/approval-policy.js +1 -1
- package/lib/client.js +431 -135
- package/lib/compat/preset-reach.js +155 -93
- package/lib/compat/probe.js +6 -4
- package/lib/context-inject.js +304 -0
- package/lib/hub.js +155 -13
- package/lib/imports/upload.js +34 -14
- package/lib/index.js +870 -226
- package/lib/mcp/state-section.js +46 -15
- package/lib/rules/archive-engine.js +182 -57
- package/lib/rules/archive.js +57 -1
- package/lib/rules/service.js +386 -156
- package/lib/scene-prompt-sync.js +101 -12
- package/lib/skills/catalog.js +125 -0
- package/lib/skills/core.js +38 -4
- package/lib/skills/service.js +36 -10
- package/lib/subagents/catalog.js +15 -5
- package/lib/subagents/service.js +39 -28
- package/lib/subagents/tools.js +18 -13
- package/package.json +14 -9
- package/screenshots.json +8 -8
- package/docs/images/1/345/234/272/346/231/257.png +0 -0
- package/docs/images/1/345/234/272/346/231/257_en.png +0 -0
- package/docs/images/2MCP.png +0 -0
- package/docs/images/2MCP_en.png +0 -0
- package/docs/images/3/346/212/200/350/203/275.png +0 -0
- package/docs/images/3/346/212/200/350/203/275_en.png +0 -0
- package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223.png +0 -0
- package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223_en.png +0 -0
- package/docs/images/5/346/217/220/347/244/272/350/257/215.png +0 -0
- package/docs/images/5/346/217/220/347/244/272/350/257/215_en.png +0 -0
- package/docs/images/6/350/256/260/345/277/206.png +0 -0
- package/docs/images/6/350/256/260/345/277/206_en.png +0 -0
- package/docs/images/7/344/274/232/350/257/235.png +0 -0
- package/docs/images/7/344/274/232/350/257/235_en.png +0 -0
- package/docs/images/8/345/205/274/345/256/271.png +0 -0
- package/docs/images/8/345/205/274/345/256/271_en.png +0 -0
- package/lib/prompt-sections.js +0 -148
package/lib/rules/service.js
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
// dsh-plugin-tool-management —— 规则/记忆(Rules v0.3,见 CHANGE-REQUEST-01)服务层。
|
|
2
2
|
//
|
|
3
|
-
// 记忆 = $DSH_HOME/tool-management/memories/<场景>/<name>.md(flat)或 <场景>/<name
|
|
3
|
+
// 记忆 = $DSH_HOME/tool-management/memories/<场景>/<name>.md(flat)或 <场景>/<name>/<name>.md(bundle)。
|
|
4
|
+
// 历史遗留的 bundle 正文文件名 `SKILL.md` 仍被识别(只读兼容),但新建/更新一律写 `<name>.md`。
|
|
4
5
|
// **场景是显式记录**:$DSH_HOME/tool-management/scenes/<场景>.json(见 SceneRecord),
|
|
5
6
|
// 不再是「memories/ 下恰好有这个名字的目录」这种隐式约定——空场景因此可以存在,
|
|
6
7
|
// 且场景可以有描述。目录名即场景名(任意 Unicode,见 isValidGroupSegment)。
|
|
7
8
|
// 保留场景 `global`(界面显示「全局」):其记忆注入任何对话;它恒定存在、不可删除。
|
|
8
|
-
// 勾选启用后,该目录树内所有 .md
|
|
9
|
-
//
|
|
9
|
+
// 勾选启用后,该目录树内所有 .md 的正文自动进入模型的上下文(index.ts 注册的注入通道,
|
|
10
|
+
// 每步一条消息;见 src/context-inject.ts),模型无需做任何动作 —— 这就是"不用每次都要解释"。
|
|
10
11
|
//
|
|
11
12
|
// 单投影(原 ADR-4 的"双投影"已被本变更单修订):
|
|
12
|
-
// - 活动场景记忆 →
|
|
13
|
+
// - 活动场景记忆 → 上下文注入(自动在场,会话级恒定 → 前缀稳定、缓存可命中;
|
|
14
|
+
// 文本没变时不重发 —— 2026-09-16 从 systemPrompt 段改道,理由见 context-inject.ts 文件头)
|
|
13
15
|
// - `_shared/` 承担"恒常"语义(所有场景共用);原 per-rule `always` 标志已移除
|
|
14
16
|
// - 不再写 ~/.dsh/AGENTS.md(原始终层投影下线)
|
|
15
17
|
//
|
|
16
18
|
// 状态分层(两份文件各司其职,互不写回):
|
|
17
19
|
// - 规则文件:正文真源。frontmatter 可声明 name/description/whenToUse/globs/metadata。
|
|
18
|
-
// -
|
|
20
|
+
// - memories-index.json:启停/排序/标签/启用场景集合等**索引为准**字段(不写回记忆文件)。
|
|
19
21
|
//
|
|
20
22
|
// 发现必须自实现(不复用 readonly-discovery):可写来源只扫一层会压扁子目录场景,
|
|
21
23
|
// 而规则的目录树天然是多层的;且 readonly-discovery 的 flat 只认顶层。
|
|
@@ -31,7 +33,6 @@ import { copyFile, cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, st
|
|
|
31
33
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
32
34
|
import { parseSkillDoc, renameWithRetry, resolveDshHome, unquote } from '../skills/core.js';
|
|
33
35
|
import { expandUploads, planMemoryImport } from '../imports/upload.js';
|
|
34
|
-
import { SECTION_ORDER, createPromptSectionRegistrar } from '../prompt-sections.js';
|
|
35
36
|
import { normalizePresetId } from '../agents-md/preset-id.js';
|
|
36
37
|
import { normalizeArchive } from './archive.js';
|
|
37
38
|
import { listTrashEntries, moveOutOfTrash, moveToTrash, purgeTrashEntry, readTrashEntry } from '../hub.js';
|
|
@@ -47,12 +48,16 @@ const DEFAULT_GROUP_ORDER = 1000; // 新场景默认 order
|
|
|
47
48
|
const SNAPSHOT_TTL_MS = 1000; // 读路径短 TTL 缓存,吸收 UI 密集轮询
|
|
48
49
|
const DEFAULT_MAX_BYTES = 65536; // 场景记忆段预算上限(字节)
|
|
49
50
|
const TRUNCATION_MARKER = '<!-- truncated -->';
|
|
50
|
-
|
|
51
|
+
// 段尾清单:让模型知道自己漏了什么。去掉伞标题后这里也不再挂「场景记忆」前缀 ——
|
|
52
|
+
// 它紧跟在场景块之后,`参考信息` 与段首引导语同一说法。
|
|
53
|
+
const DROPPED_HEADING = '## 未注入的参考信息(超出预算)';
|
|
51
54
|
// bundle 附件限制(body 走 HTTP JSON + base64,故比技能上传收紧一档)。
|
|
52
55
|
const MAX_ATTACH_ENTRY_BYTES = 8 << 20; // 单个附件 8 MiB
|
|
53
56
|
const MAX_ATTACH_TOTAL_BYTES = 16 << 20; // 单次总大小 16 MiB
|
|
54
57
|
const MAX_ATTACH_ENTRIES = 32; // 单次最多 32 个
|
|
55
|
-
const
|
|
58
|
+
const LEGACY_BUNDLE_DOC = 'SKILL.md'; // 旧版 bundle 的正文文件名;仅在发现/附件排除时作只读兼容,新建一律用 bundleDocName()
|
|
59
|
+
/** bundle 的正文文件名 = `<记忆名>.md`(与目录名一致,不再是固定的 SKILL.md)。 */
|
|
60
|
+
const bundleDocName = (name) => `${name}.md`;
|
|
56
61
|
const SHARED_GROUP = '_shared'; // 保留场景名:公共基线(历史语义,仍可使用)
|
|
57
62
|
const INDEX_VERSION = 1;
|
|
58
63
|
// 场景/子目录段名约束(§4 对照表 + §5.3):任意 Unicode,但必须对文件系统安全。
|
|
@@ -91,13 +96,22 @@ const identity = (p) => (process.platform === 'win32' ? p.toLowerCase() : p);
|
|
|
91
96
|
* 场景/记忆根目录名($DSH_HOME 下),集中在 `tool-management/` 一个目录内:
|
|
92
97
|
* - `scenes/<场景>.json` 场景记录(名称/描述/顺序)
|
|
93
98
|
* - `memories/<场景>/…` 记忆正文真源
|
|
94
|
-
* - `..`(即 tool-management 根)侧车:
|
|
99
|
+
* - `..`(即 tool-management 根)侧车:memories-index.json / skills-state.json / trash/
|
|
95
100
|
*
|
|
96
101
|
* 历史位置 `$DSH_HOME/scene-memory/` 由 `relocateLegacyLayout()` 在首次读写前搬入。
|
|
97
102
|
*/
|
|
98
103
|
export const HUB_DIR = 'tool-management';
|
|
99
104
|
export const SCENES_DIR = 'scenes';
|
|
100
105
|
export const MEMORIES_DIR = 'memories';
|
|
106
|
+
/**
|
|
107
|
+
* 记忆索引文件名 / 记忆回收站目录名(hub 根下)。
|
|
108
|
+
* 域叫「记忆」(工具 `memory_manager_*`、界面「记忆」页),所以按域命名 ——
|
|
109
|
+
* 旧名 `rules-index.json` / `rules-trash/` 由 hub 的启动迁移搬过来(见 hub.ts)。
|
|
110
|
+
*/
|
|
111
|
+
export const MEMORIES_INDEX_FILE = 'memories-index.json';
|
|
112
|
+
export const MEMORIES_TRASH_DIR = 'memories-trash';
|
|
113
|
+
/** 提示词预设库目录名(hub 根下;域 = 提示词,工具 `prompt_manager_*`)。 */
|
|
114
|
+
export const PRESETS_DIR = 'prompts';
|
|
101
115
|
/** 保留场景名:界面显示「全局」,恒定存在、不可删除,其记忆注入任何对话。 */
|
|
102
116
|
export const GLOBAL_SCENE = 'global';
|
|
103
117
|
/** 保留场景在界面上的显示名(磁盘上仍用 ASCII 目录/文件名)。 */
|
|
@@ -105,7 +119,7 @@ export const GLOBAL_SCENE_LABEL = '全局';
|
|
|
105
119
|
/**
|
|
106
120
|
* 记忆导出的落盘映射(`bundle-export` 的 `kind: 'memories'` 用,见 design-plan D14)。
|
|
107
121
|
*
|
|
108
|
-
* 记忆有两种形态(见文件头):flat = `<场景>/<name>.md`,bundle = `<场景>/<name
|
|
122
|
+
* 记忆有两种形态(见文件头):flat = `<场景>/<name>.md`,bundle = `<场景>/<name>/<name>.md`;
|
|
109
123
|
* 而且**场景名本身可含 `/`**(多段场景名,见 `isValidGroupPath`)。所以「按 id 的最后一个
|
|
110
124
|
* `/` 切出场景与名字、再拼 `.md`」是错的:bundle 会被读成 `<场景>/<name>.md` → 读不到 →
|
|
111
125
|
* 该项目静默丢失(只在 `missing` 里留个名,界面按「导出成功」显示)。
|
|
@@ -143,7 +157,7 @@ export function planMemoryExport(names, rules) {
|
|
|
143
157
|
/**
|
|
144
158
|
* 同目录临时文件 + rename 原子写(临时名 `.xxx.dsh-rules-<uuid>.tmp`)。
|
|
145
159
|
* rename 走 `renameWithRetry`:Windows 上杀软/索引器会短暂占住目标文件报
|
|
146
|
-
* EPERM/EACCES/EBUSY(用户实测:进入/退出模式时
|
|
160
|
+
* EPERM/EACCES/EBUSY(用户实测:进入/退出模式时 memories-index.json 被 rename 撞上,
|
|
147
161
|
* 运行时已切换但状态落盘失败,界面开关停在旧状态、还得再点一次)。这种占用是
|
|
148
162
|
* 瞬时的,重试几轮就能过去;全失败才清理临时文件并把错误抛出。
|
|
149
163
|
*/
|
|
@@ -175,8 +189,9 @@ async function writeFileAtomicBinary(path, data) {
|
|
|
175
189
|
* BFS 发现(realpath 防环、深度/目录/条目预算),与 readonly-discovery 同构,
|
|
176
190
|
* 但规则的场景是**多层相对路径**(readonly-discovery 的 group 恒为第一层)。
|
|
177
191
|
*
|
|
178
|
-
* 目录语义:目录含
|
|
179
|
-
*
|
|
192
|
+
* 目录语义:目录含 `<目录名>.md` → 它是 bundle 规则(叶子,不再深入),其「父路径」
|
|
193
|
+
* 是场景、目录名是规则名;找不到时再退回认旧文件名 `SKILL.md`(只读兼容,不再新建);
|
|
194
|
+
* 都没有则它是场景/子分类,继续遍历其下 .md(flat,任意层级)
|
|
180
195
|
* 与子目录。同名 flat 与 bundle 冲突时 bundle 优先,flat 记入 shadowed。
|
|
181
196
|
*/
|
|
182
197
|
async function discover(rulesRoot) {
|
|
@@ -241,22 +256,35 @@ async function discover(rulesRoot) {
|
|
|
241
256
|
catch {
|
|
242
257
|
continue; // 失效链接或不可读目录不阻断其他来源
|
|
243
258
|
}
|
|
244
|
-
// bundle
|
|
259
|
+
// bundle 检查:当前目录含 `<目录名>.md`(或旧版 `SKILL.md`)→ 它是规则叶子,父路径为场景。
|
|
260
|
+
//
|
|
261
|
+
// **根下的一级目录恒为场景**(`parentGroup === ''` 时不做 bundle 判断)——与
|
|
262
|
+
// `probeSceneFilesSync`(注入段)同口径。场景 `1` 里一条叫 `1` 的记忆(`memories/1/1.md`)
|
|
263
|
+
// 与「根层的 bundle `1`」在磁盘上同形,认成后者会把整个场景目录吃成叶子:同场景其余
|
|
264
|
+
// 记忆全从列表消失、多出一条「未归属场景」的同名记忆,新建记忆还会被误报「同名 bundle
|
|
265
|
+
// 遮蔽」(2026-09-16 用户实测)。根层因此没有 bundle,只有 legacy 裸 .md(见 rulesCheck 的 noScene)。
|
|
245
266
|
if (current.group !== '') {
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
267
|
+
const sepIdx = current.group.lastIndexOf('/');
|
|
268
|
+
const parentGroup = sepIdx >= 0 ? current.group.slice(0, sepIdx) : '';
|
|
269
|
+
const name = sepIdx >= 0 ? current.group.slice(sepIdx + 1) : current.group;
|
|
270
|
+
let docPath = '';
|
|
271
|
+
if (parentGroup !== '') {
|
|
272
|
+
for (const candidate of [bundleDocName(name), LEGACY_BUNDLE_DOC]) {
|
|
273
|
+
const p = join(current.path, candidate);
|
|
274
|
+
try {
|
|
275
|
+
const st = await lstat(p);
|
|
276
|
+
if (st.isFile() && !st.isSymbolicLink()) {
|
|
277
|
+
docPath = p;
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
catch { /* 该候选名不存在 → 试下一个 */ }
|
|
256
282
|
}
|
|
257
283
|
}
|
|
258
|
-
|
|
259
|
-
|
|
284
|
+
if (docPath !== '') {
|
|
285
|
+
const id = parentGroup ? `${parentGroup}/${name}` : name;
|
|
286
|
+
addEntry({ id, group: parentGroup, name, kind: 'bundle', docPath, entryPath: current.path });
|
|
287
|
+
continue;
|
|
260
288
|
}
|
|
261
289
|
}
|
|
262
290
|
if (current.group !== '') {
|
|
@@ -420,7 +448,7 @@ function parseArchives(raw) {
|
|
|
420
448
|
}
|
|
421
449
|
return out;
|
|
422
450
|
}
|
|
423
|
-
function parseModeState(raw) {
|
|
451
|
+
export function parseModeState(raw) {
|
|
424
452
|
const obj = (raw && typeof raw === 'object' ? raw : {});
|
|
425
453
|
const scene = typeof obj.scene === 'string' && obj.scene.trim() !== '' ? obj.scene : null;
|
|
426
454
|
const snapshotRaw = (obj.snapshot && typeof obj.snapshot === 'object' ? obj.snapshot : {});
|
|
@@ -470,6 +498,15 @@ function parseModeState(raw) {
|
|
|
470
498
|
.map((x) => ({ root: String(x.root || ''), enabled: x.enabled === true }))
|
|
471
499
|
.filter((x) => x.root !== '');
|
|
472
500
|
const subagents = (Array.isArray(snapshotRaw.subagents) ? snapshotRaw.subagents : []).map((x) => String(x)).filter(Boolean);
|
|
501
|
+
// 同 subagents 一组:进入时被档案**关掉**的人设(退出要重新打开)。漏掉它 = 用户实测的
|
|
502
|
+
// 「进场景关掉了,退出却没开回来」——快照落盘后读回来就只剩「被启用」那一半名单。
|
|
503
|
+
const subagentsOn = (Array.isArray(snapshotRaw.subagentsOn) ? snapshotRaw.subagentsOn : []).map((x) => String(x)).filter(Boolean);
|
|
504
|
+
// v0.8.1 的场景备注恢复名单:**必须原样透传**——这里漏掉它,退出模式时备注永不回退
|
|
505
|
+
//(与历史上 mcpServers / skillSources 被剥掉是同一类 bug)。
|
|
506
|
+
const mcpNotes = (Array.isArray(snapshotRaw.mcpNotes) ? snapshotRaw.mcpNotes : [])
|
|
507
|
+
.filter((x) => !!x && typeof x === 'object')
|
|
508
|
+
.map((x) => ({ id: String(x.id || ''), note: typeof x.note === 'string' ? x.note : null }))
|
|
509
|
+
.filter((x) => x.id !== '');
|
|
473
510
|
return {
|
|
474
511
|
scene,
|
|
475
512
|
snapshot: scene
|
|
@@ -479,11 +516,13 @@ function parseModeState(raw) {
|
|
|
479
516
|
...(mcpServers.length ? { mcpServers } : {}),
|
|
480
517
|
...(skillSources.length ? { skillSources } : {}),
|
|
481
518
|
...(subagents.length ? { subagents } : {}),
|
|
519
|
+
...(subagentsOn.length ? { subagentsOn } : {}),
|
|
520
|
+
...(mcpNotes.length ? { mcpNotes } : {}),
|
|
482
521
|
}
|
|
483
522
|
: null,
|
|
484
523
|
};
|
|
485
524
|
}
|
|
486
|
-
/** 解析
|
|
525
|
+
/** 解析 memories-index.json 原文;任何异常/版本不符 → 默认索引(容忍缺失,§9.3)。 */
|
|
487
526
|
function parseIndex(raw) {
|
|
488
527
|
const parsed = JSON.parse(raw);
|
|
489
528
|
if (!parsed || parsed.version !== INDEX_VERSION || typeof parsed.rules !== 'object' || parsed.rules === null)
|
|
@@ -500,15 +539,15 @@ function parseIndex(raw) {
|
|
|
500
539
|
}
|
|
501
540
|
async function readIndex(stateDir) {
|
|
502
541
|
try {
|
|
503
|
-
return parseIndex(await readFile(join(stateDir,
|
|
542
|
+
return parseIndex(await readFile(join(stateDir, MEMORIES_INDEX_FILE), 'utf8'));
|
|
504
543
|
}
|
|
505
544
|
catch {
|
|
506
545
|
return defaultIndex();
|
|
507
546
|
}
|
|
508
547
|
}
|
|
509
|
-
/**
|
|
548
|
+
/** 同步读索引:注入文本的渲染路径不能 await(见文件内「两相扫描」注释)。 */
|
|
510
549
|
function readIndexSync(stateDir) {
|
|
511
|
-
const raw = readFileIfExistsSync(join(stateDir,
|
|
550
|
+
const raw = readFileIfExistsSync(join(stateDir, MEMORIES_INDEX_FILE));
|
|
512
551
|
if (raw === null)
|
|
513
552
|
return defaultIndex();
|
|
514
553
|
try {
|
|
@@ -520,9 +559,9 @@ function readIndexSync(stateDir) {
|
|
|
520
559
|
}
|
|
521
560
|
async function writeIndex(stateDir, index) {
|
|
522
561
|
await mkdir(stateDir, { recursive: true });
|
|
523
|
-
await writeFileAtomically(join(stateDir,
|
|
562
|
+
await writeFileAtomically(join(stateDir, MEMORIES_INDEX_FILE), JSON.stringify(index, null, 2));
|
|
524
563
|
}
|
|
525
|
-
// ── 场景记录(索引内,`
|
|
564
|
+
// ── 场景记录(索引内,`memories-index.json` 的 scenes 切片)+ 旧布局迁移 ────────
|
|
526
565
|
/** 索引条目 → 场景记录(供 UI 直接渲染)。 */
|
|
527
566
|
function sceneRecordOf(name, entry) {
|
|
528
567
|
const e = entry || {};
|
|
@@ -651,7 +690,7 @@ function normalizeActive(raw) {
|
|
|
651
690
|
* 因此"新建即启用"不会发生;存量数据仍按老语义读,避免升级后注入范围突变。
|
|
652
691
|
* - `_shared` 恒常启用(公共基线),不受开关影响
|
|
653
692
|
* - 保留场景 `global` 恒常启用:它的记忆对任何对话都成立
|
|
654
|
-
* 缺失
|
|
693
|
+
* 缺失 memories-index.json 一律按默认值运行,不抛错(§9.3)。
|
|
655
694
|
* 场景生效与否只由 index.active 决定(场景档案/模式也写这一份)。
|
|
656
695
|
*/
|
|
657
696
|
function resolveActiveScenes(index, knownScenes) {
|
|
@@ -759,10 +798,10 @@ async function buildSnapshot(rulesRoot, stateDir) {
|
|
|
759
798
|
}
|
|
760
799
|
// ── 场景记忆段:两相扫描(廉价指纹 → 按需读正文)──────────────────────────
|
|
761
800
|
//
|
|
762
|
-
// 硬约束 1(§5.1
|
|
763
|
-
//
|
|
801
|
+
// 硬约束 1(§5.1):注入通道每个 step 都要求**同步**取一次文本(`text: () => string`,
|
|
802
|
+
// 见 src/context-inject.ts),返回 Promise 会让这一步的注入直接失败(异常被吞)。
|
|
764
803
|
//
|
|
765
|
-
// 缓存策略(§5.4 S3
|
|
804
|
+
// 缓存策略(§5.4 S3):文本出口必须同步返回 string,但不希望每个模型步骤都
|
|
766
805
|
// 读一遍所有记忆正文。因此拆成两相:
|
|
767
806
|
// ① `probeSceneFilesSync()`:只走目录树 + `statSync`(**不读正文**),产出候选文件
|
|
768
807
|
// 与**指纹**(`id|mtime|size` + 影响渲染的索引字段)。
|
|
@@ -774,8 +813,8 @@ async function buildSnapshot(rulesRoot, stateDir) {
|
|
|
774
813
|
// 一次 stat 遍历(个人记忆树是亚毫秒级),换来"永远最新且永不静默失效",优先于"零 IO"。
|
|
775
814
|
//
|
|
776
815
|
// 与 discover() 的关系:discover 是异步全量快照(供 CRUD/列表/体检),这里是同步轻量
|
|
777
|
-
//
|
|
778
|
-
// 目录含 SKILL.md
|
|
816
|
+
// 扫描(供注入通道),两者对"什么是规则"的定义保持一致:
|
|
817
|
+
// 目录含 `<目录名>.md`(或旧版 `SKILL.md`)→ bundle 规则(叶子);否则继续下钻;只认 .md;跳过隐藏项;
|
|
779
818
|
// 同名 flat 与 bundle 冲突时 bundle 优先。
|
|
780
819
|
function readFileIfExistsSync(path) {
|
|
781
820
|
try {
|
|
@@ -849,14 +888,23 @@ function probeSceneFilesSync(rulesRoot, index) {
|
|
|
849
888
|
continue; // 同步扫描不跟随符号链接(防环)
|
|
850
889
|
if (item.isDirectory()) {
|
|
851
890
|
budget.items++;
|
|
852
|
-
|
|
853
|
-
const
|
|
854
|
-
|
|
891
|
+
let docPath = '';
|
|
892
|
+
for (const candidate of [bundleDocName(item.name), LEGACY_BUNDLE_DOC]) {
|
|
893
|
+
const p = join(child, candidate);
|
|
894
|
+
if (fileStampSync(p) !== 'missing') {
|
|
895
|
+
docPath = p;
|
|
896
|
+
break;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
// 指纹带上**目录本身**的 mtime:附件只在段里以「路径 + 文件名」出现,
|
|
900
|
+
// 增删附件不改正文文件,只靠它的话指纹不变、段不会重算,列表就会停在旧值。
|
|
901
|
+
if (docPath !== '') {
|
|
902
|
+
const stamp = `${fileStampSync(docPath)}:${fileStampSync(child)}`;
|
|
855
903
|
// bundle 规则(叶子):父路径为场景,目录名为记忆名。
|
|
856
904
|
const id = scene ? `${scene}/${relChild}` : relChild;
|
|
857
905
|
if (index.rules[id]?.enabled === false)
|
|
858
906
|
continue; // 单条停用 → 不进入段
|
|
859
|
-
add({ id, scene, name: item.name, kind: 'bundle', path:
|
|
907
|
+
add({ id, scene, name: item.name, kind: 'bundle', path: docPath, order: index.rules[id]?.order ?? DEFAULT_ORDER, stamp });
|
|
860
908
|
continue;
|
|
861
909
|
}
|
|
862
910
|
walk(scene, child, relChild, depth + 1);
|
|
@@ -939,6 +987,9 @@ function renderSceneMemory(probe, index, maxBytes) {
|
|
|
939
987
|
descriptionDerived: derived.descriptionDerived,
|
|
940
988
|
order: ref.order,
|
|
941
989
|
body: doc.body,
|
|
990
|
+
kind: ref.kind,
|
|
991
|
+
// bundle 的正文是 `<目录>/<名>.md`,附件是**同目录**的其余文件。
|
|
992
|
+
bundleDir: ref.kind === 'bundle' ? dirname(ref.path) : '',
|
|
942
993
|
});
|
|
943
994
|
}
|
|
944
995
|
const { active } = resolveActiveScenes(index, probe.scenes);
|
|
@@ -961,14 +1012,15 @@ function renderSceneMemory(probe, index, maxBytes) {
|
|
|
961
1012
|
let seq = 0;
|
|
962
1013
|
for (const scene of [...buckets.keys()].sort((a, b) => compareSceneBuckets(a, b, index))) {
|
|
963
1014
|
const sceneFiles = (buckets.get(scene) || []).slice().sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
|
|
964
|
-
const header =
|
|
1015
|
+
const header = sceneHeader(scene, index);
|
|
965
1016
|
for (const file of sceneFiles) {
|
|
966
|
-
const block
|
|
1017
|
+
const { text: block, inline } = memoryBlock(file);
|
|
967
1018
|
candidates.push({
|
|
968
1019
|
seq: seq++,
|
|
969
1020
|
scene,
|
|
970
1021
|
header,
|
|
971
1022
|
block,
|
|
1023
|
+
inline,
|
|
972
1024
|
item: { id: file.id, scene: file.scene, name: file.name, bytes: byteLen(block) },
|
|
973
1025
|
});
|
|
974
1026
|
}
|
|
@@ -976,18 +1028,36 @@ function renderSceneMemory(probe, index, maxBytes) {
|
|
|
976
1028
|
if (candidates.length === 0) {
|
|
977
1029
|
return { text: '', bytes: 0, truncated: probe.truncated, maxBytes, scenes: [], items: [], dropped: [] };
|
|
978
1030
|
}
|
|
979
|
-
/**
|
|
980
|
-
|
|
1031
|
+
/**
|
|
1032
|
+
* 选中块 → 段正文(同一场景的 `## 场景:x` 只在首次出现时发出)。
|
|
1033
|
+
* `dropped` 决定引导语用哪一版:真有条目没注入时不再声称「以下就是全部信息」。
|
|
1034
|
+
*/
|
|
1035
|
+
const renderBody = (selected, dropped) => {
|
|
1036
|
+
if (selected.length === 0)
|
|
1037
|
+
return '';
|
|
1038
|
+
const note = dropped ? SCENE_MEMORY_NOTE_PARTIAL : SCENE_MEMORY_NOTE;
|
|
981
1039
|
const chunks = [];
|
|
982
1040
|
let current = null;
|
|
983
1041
|
let buf = '';
|
|
1042
|
+
// 上一条是不是「单行条目」。连续两条单行条目紧挨着(列表的自然形态),
|
|
1043
|
+
// 其余情况都要空一行 —— 多行正文(含其缩进挂载的附件行)与下一条之间没有空行的话,
|
|
1044
|
+
// 读起来会连成一片、看不出条目边界。
|
|
1045
|
+
let prevInline = false;
|
|
984
1046
|
for (const c of selected) {
|
|
985
1047
|
if (c.scene !== current) {
|
|
986
1048
|
if (buf !== '')
|
|
987
1049
|
chunks.push(buf);
|
|
988
1050
|
current = c.scene;
|
|
989
|
-
|
|
1051
|
+
// 引导语跟着**场景块**走(场景说明之后、条目之前):它管的就是下面这些条目,
|
|
1052
|
+
// 放最顶层会飘在场景之外(用户实测反馈「怎么跑到最顶层了」)。场景数有上限
|
|
1053
|
+
// (`global`/`_shared` 恒常启用 + 至多一个启用场景),最多出现 3 次,代价可接受。
|
|
1054
|
+
buf = `${c.header}${note}\n\n`;
|
|
1055
|
+
prevInline = false;
|
|
990
1056
|
}
|
|
1057
|
+
const inline = c.inline;
|
|
1058
|
+
if (!(inline && prevInline) && !buf.endsWith('\n\n'))
|
|
1059
|
+
buf += '\n';
|
|
1060
|
+
prevInline = inline;
|
|
991
1061
|
buf += c.block;
|
|
992
1062
|
}
|
|
993
1063
|
if (buf !== '')
|
|
@@ -1025,9 +1095,12 @@ function renderSceneMemory(probe, index, maxBytes) {
|
|
|
1025
1095
|
const selected = [];
|
|
1026
1096
|
const missed = [];
|
|
1027
1097
|
const takenScenes = new Set();
|
|
1098
|
+
// 场景标题与引导语也是开销,按「每个首次出现的场景」计进预算 —— 否则它们会挤掉
|
|
1099
|
+
// 本该放得下的记忆(引导语的字节见 SCENE_NOTE_BYTES)。
|
|
1028
1100
|
let used = 0;
|
|
1101
|
+
const noteBytes = sceneNoteBytes();
|
|
1029
1102
|
for (const c of candidates) {
|
|
1030
|
-
const headerCost = takenScenes.has(c.scene) ? 0 : byteLen(c.header);
|
|
1103
|
+
const headerCost = takenScenes.has(c.scene) ? 0 : byteLen(c.header) + noteBytes;
|
|
1031
1104
|
if (used + headerCost + c.item.bytes + markerReserve > maxBytes) {
|
|
1032
1105
|
missed.push(c);
|
|
1033
1106
|
continue;
|
|
@@ -1038,12 +1111,12 @@ function renderSceneMemory(probe, index, maxBytes) {
|
|
|
1038
1111
|
}
|
|
1039
1112
|
missed.sort((a, b) => a.seq - b.seq);
|
|
1040
1113
|
// ── ② 尾注自身也占字节:放不下就把已入选的块从后往前退回,直到回到预算内 ──
|
|
1041
|
-
let body = renderBody(selected);
|
|
1114
|
+
let body = renderBody(selected, probe.truncated || missed.length > 0);
|
|
1042
1115
|
let tail = renderTail(missed, maxBytes - byteLen(body), probe.truncated || missed.length > 0);
|
|
1043
1116
|
while (byteLen(body) + byteLen(tail) > maxBytes && selected.length > 0) {
|
|
1044
1117
|
missed.push(selected.pop());
|
|
1045
1118
|
missed.sort((a, b) => a.seq - b.seq);
|
|
1046
|
-
body = renderBody(selected);
|
|
1119
|
+
body = renderBody(selected, true);
|
|
1047
1120
|
tail = renderTail(missed, maxBytes - byteLen(body), true);
|
|
1048
1121
|
}
|
|
1049
1122
|
let text = `${body}${tail}`.replace(/^\n+/, '');
|
|
@@ -1098,10 +1171,144 @@ function sceneLabel(scene, index) {
|
|
|
1098
1171
|
const label = index?.scenes?.[scene]?.label;
|
|
1099
1172
|
return label && label !== '' ? label : scene;
|
|
1100
1173
|
}
|
|
1101
|
-
/**
|
|
1102
|
-
const sceneHeading = (scene) => `##
|
|
1103
|
-
/**
|
|
1104
|
-
|
|
1174
|
+
/** 单个场景的标题:**场景在最顶层**(`##`,与「子智能体」「MCP 服务器」等段同级)。 */
|
|
1175
|
+
const sceneHeading = (scene) => `## 场景:${sceneLabel(scene)}`;
|
|
1176
|
+
/**
|
|
1177
|
+
* 没填描述时的默认「场景说明」(用户裁定 2026-09-16:全局桶一直没有描述,读起来像缺了一块,
|
|
1178
|
+
* 统一成"每个场景块都有场景说明")。
|
|
1179
|
+
*
|
|
1180
|
+
* 默认句同时承担"这个场景是什么"的答疑(此前只有光秃秃的 `## 场景:X`,模型读不懂 —— 用户实测):
|
|
1181
|
+
* 两个恒常桶说明生效范围,用户场景说明它是当前启用的那份配置。
|
|
1182
|
+
*/
|
|
1183
|
+
const defaultSceneDescription = (scene) => (scene === GLOBAL_SCENE ? '全局记忆,任何对话都生效'
|
|
1184
|
+
: scene === SHARED_GROUP ? '共享记忆,任何对话都生效'
|
|
1185
|
+
: '用户配置的上下文,当前启用');
|
|
1186
|
+
/**
|
|
1187
|
+
* 单个场景的段头:场景标题 + **恒有**的 `场景说明:<描述>`。
|
|
1188
|
+
*
|
|
1189
|
+
* 场景描述(界面「描述(可选)」,≤60 字符)**此前从未注入过** —— 它正是「这个场景是
|
|
1190
|
+
* 干什么的」的答案,属于模型做判断需要的上下文,而不是只给人看的元数据;界面上的文案
|
|
1191
|
+
* 也从没把它标成「只给使用者看」(对比 AGENTS.md 预设的描述,那里是明确标注的)。
|
|
1192
|
+
* 描述为空时给 `defaultSceneDescription` 的默认句(用户裁定:全局桶没描述时读起来像
|
|
1193
|
+
* 缺了一块,统一成每个场景块都有说明)。
|
|
1194
|
+
*/
|
|
1195
|
+
function sceneHeader(scene, index) {
|
|
1196
|
+
const head = sceneHeading(scene);
|
|
1197
|
+
const described = String(index?.scenes?.[scene]?.description ?? '').replaceAll(/\s+/g, ' ').trim();
|
|
1198
|
+
const description = described === '' ? defaultSceneDescription(scene) : described;
|
|
1199
|
+
// 加粗(用户裁定 2026-09-16):与引导语同款强调,别让"场景说明"读起来像可忽略的普通正文。
|
|
1200
|
+
return `${head}\n\n**场景说明:${description}**\n\n`;
|
|
1201
|
+
}
|
|
1202
|
+
/**
|
|
1203
|
+
* 段首的引导语:让模型知道下面是**用户提供的参考信息**,而不是对话历史或临时说明
|
|
1204
|
+
* —— 相关就用、无关可忽略。
|
|
1205
|
+
*
|
|
1206
|
+
* 写法(2026-09-16 用户裁定,基于真实注入结果的三次修正):
|
|
1207
|
+
* - **单行、加粗**,不再用括号分两行 —— 括号跨行在真实提示词里读起来像被截断,
|
|
1208
|
+
* 而加粗是 Markdown 里最省字符的强调手段(用户要求「加强模型对此的重视程度」)。
|
|
1209
|
+
* - **提到段首、整段只出现一次**:原来它挂在每个场景的段头里,多场景时会重复注入。
|
|
1210
|
+
* - **不点名任何工具**:模型从工具 schema 就知道 `memory_manager_list` 存在,点名反而
|
|
1211
|
+
* 像在提示它去调;用户裁定「没启用的信息就是不想在当前用」,所以工具指引整句删除。
|
|
1212
|
+
* 真正防探测的是**完整性声明**(「以下就是全部信息」),那半句必须留。
|
|
1213
|
+
* - **完整性声明按截断状态自适应**:真有条目因预算没注入时,段尾会有未注入清单,
|
|
1214
|
+
* 此时不能再声称「全部」,否则和清单自相矛盾 —— 也正因为那时确实有东西没给到,
|
|
1215
|
+
* 模型去查工具是**合理**的,不该再拦。
|
|
1216
|
+
*
|
|
1217
|
+
* 用词:不用「常驻」「注入」这类内部行话(模型没有先验);用户裁定用「信息」而不是
|
|
1218
|
+
* 「记忆」——「记忆」在系统提示词里指代不明,而这段的实质就是用户写的参考信息。
|
|
1219
|
+
*/
|
|
1220
|
+
const SCENE_MEMORY_NOTE = '**用户提供的参考信息:与当前任务相关时直接采用,无关时忽略。以下就是全部信息。**';
|
|
1221
|
+
/** 有未注入条目时的版本:去掉完整性声明(见上)。 */
|
|
1222
|
+
const SCENE_MEMORY_NOTE_PARTIAL = '**用户提供的参考信息:与当前任务相关时直接采用,无关时忽略。**';
|
|
1223
|
+
/**
|
|
1224
|
+
* 段首固定块。以 `\n` 结尾,与场景块 join 后自然空一行。
|
|
1225
|
+
* 预算按**较长**的那版算(见 renderSceneMemory),保守一点只会浪费几个字节。
|
|
1226
|
+
*/
|
|
1227
|
+
/**
|
|
1228
|
+
* 引导语所占的字节(含它后面的一个空行)。跟着场景块走,所以按**每个场景**计入段头开销;
|
|
1229
|
+
* 用较长的那版(带完整性声明)算,保守一点只会浪费几个字节。
|
|
1230
|
+
*
|
|
1231
|
+
* 注意:模块级不能直接算 —— `byteLen` 是后面才声明的 const,模块初始化期取它会 TDZ 报错。
|
|
1232
|
+
*/
|
|
1233
|
+
const sceneNoteBytes = () => byteLen(`${SCENE_MEMORY_NOTE}\n\n`);
|
|
1234
|
+
/** 单行正文的最大长度:超过就退回「标题 + 正文块」,避免出现一条几千字符的列表行。 */
|
|
1235
|
+
const INLINE_BODY_MAX = 120;
|
|
1236
|
+
/** 附件行里最多列几个文件名;多的只报总数(路径已经给了,缺的名字模型自己列目录即可)。 */
|
|
1237
|
+
const ATTACHMENT_LIST_MAX = 10;
|
|
1238
|
+
/**
|
|
1239
|
+
* bundle 记忆的附件名(**同步**版 —— 段渲染必须同步返回,`listAttachments` 是异步的)。
|
|
1240
|
+
* 口径与异步版一致:跳过正文本体(`<名>.md`,旧数据可能是 `SKILL.md`),只认普通文件。
|
|
1241
|
+
*/
|
|
1242
|
+
function attachmentNamesSync(bundleDir, name) {
|
|
1243
|
+
try {
|
|
1244
|
+
const docNames = new Set([bundleDocName(name), LEGACY_BUNDLE_DOC]);
|
|
1245
|
+
return readdirSync(bundleDir, { withFileTypes: true })
|
|
1246
|
+
.filter((e) => e.isFile() && !docNames.has(e.name))
|
|
1247
|
+
.map((e) => e.name)
|
|
1248
|
+
.sort((a, b) => a.localeCompare(b));
|
|
1249
|
+
}
|
|
1250
|
+
catch {
|
|
1251
|
+
return [];
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* 附件行(只有 bundle 记忆才有):**给目录与文件名,不给内容**。
|
|
1256
|
+
* 附件可能是图片、二进制、大 md —— 全文注入又贵又会把段预算吃光;给路径,模型需要时自己读。
|
|
1257
|
+
*/
|
|
1258
|
+
function attachmentLine(file) {
|
|
1259
|
+
if (file.kind !== 'bundle')
|
|
1260
|
+
return '';
|
|
1261
|
+
const names = attachmentNamesSync(file.bundleDir, file.name);
|
|
1262
|
+
if (names.length === 0)
|
|
1263
|
+
return '';
|
|
1264
|
+
const shown = names.slice(0, ATTACHMENT_LIST_MAX);
|
|
1265
|
+
const more = names.length - shown.length;
|
|
1266
|
+
return `附件目录:${file.bundleDir}(未注入正文,共 ${names.length} 个:${shown.join('、')}${more > 0 ? `,另 ${more} 个` : ''})`;
|
|
1267
|
+
}
|
|
1268
|
+
/**
|
|
1269
|
+
* 单条信息的渲染形态 —— **能一行就一行,但恒为列表项**。
|
|
1270
|
+
*
|
|
1271
|
+
* 单行且不长的正文 → `- **名称**(描述) — 正文`(与 MCP / 子智能体两个段的列表同形;无显式描述时括号不出现)
|
|
1272
|
+
* 多行或过长的正文 → `- **名称**(描述)` + 空行 + 缩进 2 格的正文(挂在条目下)
|
|
1273
|
+
*
|
|
1274
|
+
* 名称恒为标题:它就是这条记忆的身份(工具 id `<场景>/<名称>`、bundle 目录/文件名都以它为准),
|
|
1275
|
+
* 用户说「记忆里的 X」、模型再调 `memory_manager_*` 时都对得上号;显式描述是括号注解,不抢标题。
|
|
1276
|
+
*
|
|
1277
|
+
* 为什么全都做成列表项:早先多行正文走 `### 名称` 标题块,附件行只能退化成与记忆**同级**的
|
|
1278
|
+
* `- 附件目录:…`(没有父列表项可挂)—— 既可能被读成一条独立记忆,某些渲染器里还会把下一个
|
|
1279
|
+
* `###` 标题吞进列表(与上一条粘连)。统一成「一条记忆 = 一个列表项、正文与附件都缩进挂在
|
|
1280
|
+
* 条目下」后,两种记忆外观完全一致,归属也不再靠位置猜测。
|
|
1281
|
+
*
|
|
1282
|
+
* 为什么要分两种:用户常有十几条「一句话事实」(「提交格式:PDF」),每条都占标题 + 空行 +
|
|
1283
|
+
* 正文三行,整段会散成一长串标题;压成一行后十条信息就是十行。多行正文是**用户写的完整
|
|
1284
|
+
* Markdown**(可能自带标题、代码块、嵌套列表),整体缩进 2 格挂到条目下,结构原样保留。
|
|
1285
|
+
*
|
|
1286
|
+
* 返回值带 `inline`:调用方据此决定下一条记忆前要不要空行(单行条目连续排列,其余空行分隔)。
|
|
1287
|
+
*/
|
|
1288
|
+
function memoryBlock(file) {
|
|
1289
|
+
const desc = explicitDescriptionOf(file);
|
|
1290
|
+
// 加粗的只有名称:`- **名称**(描述) — 正文`,与 MCP 段 `- **server**(N 个工具) — …` 同形。
|
|
1291
|
+
const title = `**${file.name}**` + (desc === '' ? '' : `(${desc})`);
|
|
1292
|
+
const text = String(file.body ?? '').trim();
|
|
1293
|
+
const inline = text === '' || (!text.includes('\n') && text.length <= INLINE_BODY_MAX);
|
|
1294
|
+
const head = text === ''
|
|
1295
|
+
? `- ${title}`
|
|
1296
|
+
: (inline ? `- ${title} — ${text}` : `- ${title}\n\n${indentBody(text)}`);
|
|
1297
|
+
const attach = attachmentLine(file);
|
|
1298
|
+
if (attach === '')
|
|
1299
|
+
return { text: `${head}\n`, inline };
|
|
1300
|
+
// 附件行恒为缩进子项:只用 `- ` 会被解析成与记忆**同级**的列表项(`- A` / `- 附件目录:A的` /
|
|
1301
|
+
// `- B` … 四条平级,归属读不出来)。单行条目紧跟其后保持列表连续;多行条目前面空一行,
|
|
1302
|
+
// 免得被读成用户正文自己的列表项。
|
|
1303
|
+
return { text: inline ? `${head}\n - ${attach}\n` : `${head}\n\n - ${attach}\n`, inline };
|
|
1304
|
+
}
|
|
1305
|
+
/** 多行正文整体缩进 2 格(列在条目内容列上),空行保持空行、不加尾随空白。 */
|
|
1306
|
+
const indentBody = (text) => text
|
|
1307
|
+
.split('\n')
|
|
1308
|
+
.map((line) => (line === '' ? line : ' ' + line))
|
|
1309
|
+
.join('\n');
|
|
1310
|
+
/** 括号注解用的显式描述:派生描述与正文重复、不进段(只存在于界面投影);换行压成单行,避免把「一行一条」的列表项撑断。 */
|
|
1311
|
+
const explicitDescriptionOf = (f) => (f.descriptionDerived || !f.description ? '' : String(f.description).replaceAll(/\s+/g, ' ').trim());
|
|
1105
1312
|
const byteLen = (s) => Buffer.byteLength(s, 'utf8');
|
|
1106
1313
|
// ── 创建服务 ───────────────────────────────────────────────────────────────
|
|
1107
1314
|
export function createRulesService(ctx, deps) {
|
|
@@ -1163,10 +1370,10 @@ export function createRulesService(ctx, deps) {
|
|
|
1163
1370
|
sceneCache = { signature: probe.signature, value };
|
|
1164
1371
|
return value;
|
|
1165
1372
|
}
|
|
1166
|
-
// ──
|
|
1373
|
+
// ── 场景提示词(绑定预设正文;读盘、只读;写文件由 scene-prompt-sync.ts 负责)──
|
|
1167
1374
|
//
|
|
1168
1375
|
// 语义(用户裁定 2026-09-15):
|
|
1169
|
-
// - 场景可绑定**一个**提示词预设(`scenes[].prompt` → `
|
|
1376
|
+
// - 场景可绑定**一个**提示词预设(`scenes[].prompt` → `prompts/<id>/AGENTS.md`);
|
|
1170
1377
|
// - 除保留场景 `global` 外**同时只能启用一个场景**,所以同一时刻至多一份提示词在场;
|
|
1171
1378
|
// - 没有启用其它场景时才轮到 `global` 自己的绑定(它恒常生效,覆盖"永远在场的提示词");
|
|
1172
1379
|
// - 预设文件不存在 / 未绑定 → 返回 `''`(renderPrompt 会删掉空段,不产生空标题)。
|
|
@@ -1177,7 +1384,7 @@ export function createRulesService(ctx, deps) {
|
|
|
1177
1384
|
const promptFileCache = new Map();
|
|
1178
1385
|
/** 读一个预设正文(同步、带 stat 指纹缓存);不存在返回 `''`。 */
|
|
1179
1386
|
function readPresetTextSync(id) {
|
|
1180
|
-
const file = join(stateDir,
|
|
1387
|
+
const file = join(stateDir, PRESETS_DIR, id, 'AGENTS.md');
|
|
1181
1388
|
let key;
|
|
1182
1389
|
try {
|
|
1183
1390
|
const st = statSync(file);
|
|
@@ -1205,7 +1412,7 @@ export function createRulesService(ctx, deps) {
|
|
|
1205
1412
|
/** 预设文件是否存在(同步;绑定前校验用,避免悬空绑定)。 */
|
|
1206
1413
|
function presetExistsSync(id) {
|
|
1207
1414
|
try {
|
|
1208
|
-
return statSync(join(stateDir,
|
|
1415
|
+
return statSync(join(stateDir, PRESETS_DIR, id, 'AGENTS.md')).isFile();
|
|
1209
1416
|
}
|
|
1210
1417
|
catch {
|
|
1211
1418
|
return false;
|
|
@@ -1242,24 +1449,13 @@ export function createRulesService(ctx, deps) {
|
|
|
1242
1449
|
return text;
|
|
1243
1450
|
}
|
|
1244
1451
|
/**
|
|
1245
|
-
* 当前生效的场景提示词:启用场景的绑定优先,其次 `global`
|
|
1246
|
-
*
|
|
1247
|
-
* 基线里了,再注入一遍只会把同样的内容塞进上下文两次。
|
|
1452
|
+
* 当前生效的场景提示词:启用场景的绑定优先,其次 `global` 的绑定;都没有 → `null`。
|
|
1453
|
+
* 「绑了但预设为空/已删」按没绑处理(不注入空段),继续找下一个。
|
|
1248
1454
|
*/
|
|
1249
|
-
function
|
|
1455
|
+
function resolveScenePreset() {
|
|
1250
1456
|
const index = readIndexSync(stateDir);
|
|
1251
1457
|
const names = Object.keys(index.scenes || {});
|
|
1252
1458
|
const { active } = resolveActiveScenes(index, names);
|
|
1253
|
-
const globalText = readGlobalAgentsMdSync().trim();
|
|
1254
|
-
/** 绑定 → 投影;与文件一致时是 duplicate(不注入),否则原样注入。 */
|
|
1255
|
-
const project = (scene, id) => {
|
|
1256
|
-
const text = readPresetTextSync(id);
|
|
1257
|
-
if (text.trim() === '')
|
|
1258
|
-
return { scene, presetId: id, text: '', missing: true };
|
|
1259
|
-
if (globalText !== '' && text.trim() === globalText)
|
|
1260
|
-
return { scene, presetId: id, text: '', missing: false, duplicate: true };
|
|
1261
|
-
return { scene, presetId: id, text, missing: false };
|
|
1262
|
-
};
|
|
1263
1459
|
// 单选:取唯一一个非保留启用场景(resolveActiveScenes 已保证 ≤1,这里仍做确定性排序兜底)。
|
|
1264
1460
|
const enabled = names
|
|
1265
1461
|
.filter((n) => n !== SHARED_GROUP && n !== GLOBAL_SCENE && active.has(n))
|
|
@@ -1268,27 +1464,35 @@ export function createRulesService(ctx, deps) {
|
|
|
1268
1464
|
const id = index.scenes?.[scene]?.prompt;
|
|
1269
1465
|
if (!id)
|
|
1270
1466
|
continue;
|
|
1271
|
-
const
|
|
1467
|
+
const text = readPresetTextSync(id);
|
|
1272
1468
|
// 绑了但预设为空/已删 → 继续找下一个(不注入空段)。
|
|
1273
|
-
if (
|
|
1469
|
+
if (text.trim() === '')
|
|
1274
1470
|
continue;
|
|
1275
|
-
return
|
|
1471
|
+
return { scene, presetId: id, text };
|
|
1276
1472
|
}
|
|
1277
1473
|
const globalId = index.scenes?.[GLOBAL_SCENE]?.prompt;
|
|
1278
|
-
if (globalId)
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
const providerInvalidators = new Set();
|
|
1283
|
-
/** 通知已注册的 provider 提示词已变化(best-effort)。 */
|
|
1284
|
-
const invalidateProviders = () => {
|
|
1285
|
-
for (const invalidate of providerInvalidators) {
|
|
1286
|
-
try {
|
|
1287
|
-
invalidate();
|
|
1288
|
-
}
|
|
1289
|
-
catch { /* 单个失效失败不影响其余 */ }
|
|
1474
|
+
if (globalId) {
|
|
1475
|
+
const text = readPresetTextSync(globalId);
|
|
1476
|
+
if (text.trim() !== '')
|
|
1477
|
+
return { scene: GLOBAL_SCENE, presetId: globalId, text };
|
|
1290
1478
|
}
|
|
1291
|
-
|
|
1479
|
+
return null;
|
|
1480
|
+
}
|
|
1481
|
+
/**
|
|
1482
|
+
* 当前生效的场景提示词(只读投影,界面用):绑定内容与 `~/.dsh/AGENTS.md` 相同时标记
|
|
1483
|
+
* `duplicate` 且返回空段 —— 那份正文已经在基线里了,界面据此显示「生效中」。
|
|
1484
|
+
* (注入侧不看这个字段:预设挂不到 AGENTS.md 通道时照样要注入,见 `promptText`。)
|
|
1485
|
+
*/
|
|
1486
|
+
function scenePrompt() {
|
|
1487
|
+
const found = resolveScenePreset();
|
|
1488
|
+
if (!found)
|
|
1489
|
+
return { scene: null, presetId: null, text: '', missing: false };
|
|
1490
|
+
const globalText = readGlobalAgentsMdSync().trim();
|
|
1491
|
+
if (globalText !== '' && found.text.trim() === globalText) {
|
|
1492
|
+
return { scene: found.scene, presetId: found.presetId, text: '', missing: false, duplicate: true };
|
|
1493
|
+
}
|
|
1494
|
+
return { scene: found.scene, presetId: found.presetId, text: found.text, missing: false };
|
|
1495
|
+
}
|
|
1292
1496
|
// ── 写操作串行队列(避免并发覆盖同一索引/文件)──
|
|
1293
1497
|
let mutationQueue = Promise.resolve();
|
|
1294
1498
|
const enqueueMutation = (task) => {
|
|
@@ -1310,17 +1514,26 @@ export function createRulesService(ctx, deps) {
|
|
|
1310
1514
|
return null;
|
|
1311
1515
|
return { group, name };
|
|
1312
1516
|
}
|
|
1313
|
-
/**
|
|
1517
|
+
/**
|
|
1518
|
+
* 磁盘定位(bundle 优先)。返回规则文件与条目路径。
|
|
1519
|
+
* 根层(`group === ''`)**没有 bundle**:一级目录恒为场景(见 discover 的注释),
|
|
1520
|
+
* 否则 `memories/1/1.md`(场景 1 的 flat 记忆 1)会被这里读成根层 bundle「1」,
|
|
1521
|
+
* 与列表/注入两端不一致(编辑、删除都会落到错误的文件上)。
|
|
1522
|
+
*/
|
|
1314
1523
|
async function locateRule(group, name) {
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1524
|
+
if (group !== '') {
|
|
1525
|
+
const bundleDir = join(rulesRoot, group, name);
|
|
1526
|
+
for (const candidate of [bundleDocName(name), LEGACY_BUNDLE_DOC]) {
|
|
1527
|
+
const bundleDoc = join(bundleDir, candidate);
|
|
1528
|
+
try {
|
|
1529
|
+
const st = await lstat(bundleDoc);
|
|
1530
|
+
if (st.isFile() && !st.isSymbolicLink()) {
|
|
1531
|
+
return { id: group ? `${group}/${name}` : name, group, name, kind: 'bundle', docPath: bundleDoc, entryPath: bundleDir };
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
catch { /* 该候选名不存在 → 试下一个 */ }
|
|
1321
1535
|
}
|
|
1322
1536
|
}
|
|
1323
|
-
catch { /* 非 bundle */ }
|
|
1324
1537
|
const flatPath = join(rulesRoot, group, name + '.md');
|
|
1325
1538
|
try {
|
|
1326
1539
|
const st = await lstat(flatPath);
|
|
@@ -1395,7 +1608,6 @@ export function createRulesService(ctx, deps) {
|
|
|
1395
1608
|
const refresh = async () => {
|
|
1396
1609
|
invalidateSnapshot();
|
|
1397
1610
|
sceneCache = null;
|
|
1398
|
-
invalidateProviders();
|
|
1399
1611
|
};
|
|
1400
1612
|
/** 场景档案引擎专用:读-改-写 mode/archives/active 切片(写队列内,保持与其余索引写串行)。 */
|
|
1401
1613
|
const patchIndex = (patch) => enqueueMutation(async () => {
|
|
@@ -1476,8 +1688,11 @@ export function createRulesService(ctx, deps) {
|
|
|
1476
1688
|
activeScene: enabledSceneOf(index),
|
|
1477
1689
|
sceneMemory: { usedBytes: projection.bytes, maxBytes: projection.maxBytes, truncated: projection.truncated, dropped: projection.dropped },
|
|
1478
1690
|
// 当前生效的场景提示词(绑定的预设正文;与 ~/.dsh/AGENTS.md 一致时不重复注入)。
|
|
1691
|
+
// `label` 是给用户指路用的显示名(「去场景设置里改『全局』的绑定」)——`global`
|
|
1692
|
+
// 这类磁盘名直接甩给用户看没有意义。
|
|
1479
1693
|
scenePrompt: {
|
|
1480
1694
|
scene: promptProjection.scene,
|
|
1695
|
+
label: promptProjection.scene ? sceneLabel(promptProjection.scene, index) : null,
|
|
1481
1696
|
presetId: promptProjection.presetId,
|
|
1482
1697
|
missing: promptProjection.missing,
|
|
1483
1698
|
duplicate: promptProjection.duplicate === true,
|
|
@@ -1513,7 +1728,7 @@ export function createRulesService(ctx, deps) {
|
|
|
1513
1728
|
rule: {
|
|
1514
1729
|
...rule,
|
|
1515
1730
|
body: doc.body,
|
|
1516
|
-
attachments: entry.kind === 'bundle' ? await listAttachments(entry.entryPath) : [],
|
|
1731
|
+
attachments: entry.kind === 'bundle' ? await listAttachments(entry.entryPath, entry.name) : [],
|
|
1517
1732
|
frontmatter: {
|
|
1518
1733
|
name: doc.map.name != null ? String(doc.map.name) : undefined,
|
|
1519
1734
|
description: doc.map.description != null ? String(doc.map.description) : undefined,
|
|
@@ -1636,9 +1851,11 @@ export function createRulesService(ctx, deps) {
|
|
|
1636
1851
|
if (!isValidGroupSegment(name))
|
|
1637
1852
|
return fail('error.rules.invalidName', `记忆名非法:${name}(非空、≤${MAX_GROUP_SEGMENT_LENGTH} 字符、不含 / \\ < > : " | ? *、不以 . 开头)`);
|
|
1638
1853
|
// 目标已存在(bundle 或 flat 皆算)→ 拒绝,避免静默覆盖。
|
|
1854
|
+
// 用 exists 而不是 shadowed:后者说的是「同名 bundle 把 flat 遮蔽了」,用户看到
|
|
1855
|
+
// 「被同名 bundle 遮蔽」会去找一个并不存在的 bundle(2026-09-16 实测)。
|
|
1639
1856
|
const existing = await locateRule(group, name);
|
|
1640
1857
|
if (existing) {
|
|
1641
|
-
return fail('error.rules.
|
|
1858
|
+
return fail('error.rules.exists', `同名记忆已存在(${existing.kind === 'bundle' ? 'bundle' : 'flat'}):${existing.id}`);
|
|
1642
1859
|
}
|
|
1643
1860
|
const body = String((args && args.body) ?? '');
|
|
1644
1861
|
if (body.trim() === '')
|
|
@@ -1667,7 +1884,7 @@ export function createRulesService(ctx, deps) {
|
|
|
1667
1884
|
const text = serializeRuleFile(fields, body);
|
|
1668
1885
|
if (form === 'bundle') {
|
|
1669
1886
|
await mkdir(join(rulesRoot, targetGroup, name), { recursive: true });
|
|
1670
|
-
await writeFileAtomically(join(rulesRoot, targetGroup, name,
|
|
1887
|
+
await writeFileAtomically(join(rulesRoot, targetGroup, name, bundleDocName(name)), text);
|
|
1671
1888
|
}
|
|
1672
1889
|
else {
|
|
1673
1890
|
await mkdir(join(rulesRoot, targetGroup), { recursive: true });
|
|
@@ -1684,7 +1901,6 @@ export function createRulesService(ctx, deps) {
|
|
|
1684
1901
|
index.groups[targetGroup] = { order: DEFAULT_GROUP_ORDER, label: targetGroup };
|
|
1685
1902
|
await writeIndex(stateDir, index);
|
|
1686
1903
|
invalidateSnapshot();
|
|
1687
|
-
invalidateProviders();
|
|
1688
1904
|
return { ok: true, rule: await buildProjected(createdId) };
|
|
1689
1905
|
}
|
|
1690
1906
|
/**
|
|
@@ -1769,10 +1985,10 @@ export function createRulesService(ctx, deps) {
|
|
|
1769
1985
|
const id = `${item.group}/${item.name}`;
|
|
1770
1986
|
try {
|
|
1771
1987
|
if (item.kind === 'bundle') {
|
|
1772
|
-
// 与 rules-create 同落点:bundle = <场景>/<名>/ 目录,正文
|
|
1988
|
+
// 与 rules-create 同落点:bundle = <场景>/<名>/ 目录,正文 <名>.md,附件平铺同层。
|
|
1773
1989
|
const bundleDir = join(rulesRoot, item.group, item.name);
|
|
1774
1990
|
await mkdir(bundleDir, { recursive: true });
|
|
1775
|
-
await writeFileAtomically(join(bundleDir,
|
|
1991
|
+
await writeFileAtomically(join(bundleDir, bundleDocName(item.name)), item.text);
|
|
1776
1992
|
for (const att of item.attachments)
|
|
1777
1993
|
await writeFileAtomicBinary(join(bundleDir, att.name), att.data);
|
|
1778
1994
|
}
|
|
@@ -1875,7 +2091,7 @@ export function createRulesService(ctx, deps) {
|
|
|
1875
2091
|
}
|
|
1876
2092
|
else if (located.kind === 'flat' && newForm === 'bundle') {
|
|
1877
2093
|
await mkdir(join(rulesRoot, parts.group, newName), { recursive: true });
|
|
1878
|
-
await writeFileAtomically(join(rulesRoot, parts.group, newName,
|
|
2094
|
+
await writeFileAtomically(join(rulesRoot, parts.group, newName, bundleDocName(newName)), serialize());
|
|
1879
2095
|
await rm(join(rulesRoot, parts.group, parts.name + '.md'), { force: true });
|
|
1880
2096
|
}
|
|
1881
2097
|
else if (located.kind === 'flat' && newName !== parts.name) {
|
|
@@ -1899,7 +2115,6 @@ export function createRulesService(ctx, deps) {
|
|
|
1899
2115
|
}
|
|
1900
2116
|
await writeIndex(stateDir, index);
|
|
1901
2117
|
invalidateSnapshot();
|
|
1902
|
-
invalidateProviders();
|
|
1903
2118
|
return { ok: true, rule: await buildProjected(newId) };
|
|
1904
2119
|
}
|
|
1905
2120
|
async function rulesRemove(args) {
|
|
@@ -1911,7 +2126,7 @@ export function createRulesService(ctx, deps) {
|
|
|
1911
2126
|
if (!located)
|
|
1912
2127
|
return fail('error.rules.notFound', `规则不存在:${id}`);
|
|
1913
2128
|
const trashId = Date.now().toString(36) + '-' + randomUUID().slice(0, 8);
|
|
1914
|
-
const trashDir = join(stateDir,
|
|
2129
|
+
const trashDir = join(stateDir, MEMORIES_TRASH_DIR, trashId);
|
|
1915
2130
|
const manifest = { group: parts.group, name: parts.name, form: located.kind, deletedAt: new Date().toISOString() };
|
|
1916
2131
|
await mkdir(trashDir, { recursive: true });
|
|
1917
2132
|
if (located.kind === 'bundle') {
|
|
@@ -1928,14 +2143,13 @@ export function createRulesService(ctx, deps) {
|
|
|
1928
2143
|
delete index.rules[id];
|
|
1929
2144
|
await writeIndex(stateDir, index);
|
|
1930
2145
|
invalidateSnapshot();
|
|
1931
|
-
invalidateProviders();
|
|
1932
2146
|
return { ok: true, trashId };
|
|
1933
2147
|
}
|
|
1934
2148
|
async function rulesRestore(args) {
|
|
1935
2149
|
const trashId = String((args && args.trashId) || '');
|
|
1936
2150
|
if (!isValidTrashId(trashId))
|
|
1937
2151
|
return fail('error.rules.notFound', `回收站条目不存在:${trashId}`);
|
|
1938
|
-
const trashDir = join(stateDir,
|
|
2152
|
+
const trashDir = join(stateDir, MEMORIES_TRASH_DIR, trashId);
|
|
1939
2153
|
let manifest;
|
|
1940
2154
|
try {
|
|
1941
2155
|
manifest = JSON.parse(await readFile(join(trashDir, 'manifest.json'), 'utf8'));
|
|
@@ -1949,7 +2163,7 @@ export function createRulesService(ctx, deps) {
|
|
|
1949
2163
|
// 恢复目标已存在(期间用户重建了同名规则)→ 拒绝,避免覆盖。
|
|
1950
2164
|
const conflict = await locateRule(manifest.group, manifest.name);
|
|
1951
2165
|
if (conflict) {
|
|
1952
|
-
return fail('error.rules.
|
|
2166
|
+
return fail('error.rules.exists', `同名记忆已存在:${conflict.id},请先移除后再恢复`);
|
|
1953
2167
|
}
|
|
1954
2168
|
const form = manifest.form === 'bundle' ? 'bundle' : 'flat';
|
|
1955
2169
|
if (form === 'bundle') {
|
|
@@ -1963,7 +2177,6 @@ export function createRulesService(ctx, deps) {
|
|
|
1963
2177
|
}
|
|
1964
2178
|
await rm(trashDir, { recursive: true, force: true });
|
|
1965
2179
|
invalidateSnapshot();
|
|
1966
|
-
invalidateProviders();
|
|
1967
2180
|
return { ok: true, rule: await buildProjected(manifest.group ? `${manifest.group}/${manifest.name}` : manifest.name) };
|
|
1968
2181
|
}
|
|
1969
2182
|
/** trashId 只由本插件生成(时间戳 base36 + uuid 前 8 位):严格白名单,杜绝路径穿越。 */
|
|
@@ -1971,11 +2184,11 @@ export function createRulesService(ctx, deps) {
|
|
|
1971
2184
|
return /^[a-z0-9]+-[a-z0-9]{1,32}$/i.test(trashId);
|
|
1972
2185
|
}
|
|
1973
2186
|
/**
|
|
1974
|
-
* 记忆回收站列表(只读):`<stateDir>/
|
|
2187
|
+
* 记忆回收站列表(只读):`<stateDir>/memories-trash/<trashId>/`(`rulesRemove` 移入,
|
|
1975
2188
|
* `rulesRestore` 恢复)。损坏或内容缺失的条目跳过,不让一个坏条目挡住整份列表。
|
|
1976
2189
|
*/
|
|
1977
2190
|
async function rulesTrashList() {
|
|
1978
|
-
const root = join(stateDir,
|
|
2191
|
+
const root = join(stateDir, MEMORIES_TRASH_DIR);
|
|
1979
2192
|
let ids = [];
|
|
1980
2193
|
try {
|
|
1981
2194
|
ids = await readdir(root);
|
|
@@ -2001,9 +2214,20 @@ export function createRulesService(ctx, deps) {
|
|
|
2001
2214
|
const form = manifest.form === 'bundle' ? 'bundle' : 'flat';
|
|
2002
2215
|
let bytes = 0;
|
|
2003
2216
|
try {
|
|
2004
|
-
|
|
2217
|
+
const bundleDoc = form === 'bundle'
|
|
2218
|
+
? join(dir, 'bundle', bundleDocName(manifest.name))
|
|
2219
|
+
: join(dir, 'rule.md');
|
|
2220
|
+
bytes = (await stat(bundleDoc)).size;
|
|
2221
|
+
}
|
|
2222
|
+
catch {
|
|
2223
|
+
// 兼容旧回收站条目(正文仍叫 SKILL.md);仍读不到就按 0 计,恢复时由 rulesRestore 兜底报错。
|
|
2224
|
+
if (form === 'bundle') {
|
|
2225
|
+
try {
|
|
2226
|
+
bytes = (await stat(join(dir, 'bundle', LEGACY_BUNDLE_DOC))).size;
|
|
2227
|
+
}
|
|
2228
|
+
catch { /* 内容缺失 */ }
|
|
2229
|
+
}
|
|
2005
2230
|
}
|
|
2006
|
-
catch { /* 内容缺失:仍列出,恢复时由 rulesRestore 兜底报错 */ }
|
|
2007
2231
|
entries.push({ trashId, group, name: manifest.name, form, deletedAt: String(manifest.deletedAt || ''), bytes });
|
|
2008
2232
|
}
|
|
2009
2233
|
catch { /* 损坏条目跳过 */ }
|
|
@@ -2016,7 +2240,7 @@ export function createRulesService(ctx, deps) {
|
|
|
2016
2240
|
const trashId = String((args && args.trashId) || '');
|
|
2017
2241
|
if (!isValidTrashId(trashId))
|
|
2018
2242
|
return fail('error.rules.notFound', `回收站条目不存在:${trashId}`);
|
|
2019
|
-
const dir = join(stateDir,
|
|
2243
|
+
const dir = join(stateDir, MEMORIES_TRASH_DIR, trashId);
|
|
2020
2244
|
try {
|
|
2021
2245
|
const st = await lstat(dir);
|
|
2022
2246
|
if (!st.isDirectory())
|
|
@@ -2028,8 +2252,8 @@ export function createRulesService(ctx, deps) {
|
|
|
2028
2252
|
await rm(dir, { recursive: true, force: true });
|
|
2029
2253
|
return { ok: true };
|
|
2030
2254
|
}
|
|
2031
|
-
/** bundle 目录下的附件(顶层普通文件,排除正文 SKILL.md
|
|
2032
|
-
async function listAttachments(bundleDir) {
|
|
2255
|
+
/** bundle 目录下的附件(顶层普通文件,排除正文 `<名>.md`,兼容旧数据 `SKILL.md`);不存在/不可读返回空数组。 */
|
|
2256
|
+
async function listAttachments(bundleDir, name) {
|
|
2033
2257
|
let entries;
|
|
2034
2258
|
try {
|
|
2035
2259
|
entries = await readdir(bundleDir, { withFileTypes: true });
|
|
@@ -2037,9 +2261,10 @@ export function createRulesService(ctx, deps) {
|
|
|
2037
2261
|
catch {
|
|
2038
2262
|
return [];
|
|
2039
2263
|
}
|
|
2264
|
+
const docNames = new Set([bundleDocName(name), LEGACY_BUNDLE_DOC]);
|
|
2040
2265
|
const out = [];
|
|
2041
2266
|
for (const entry of entries) {
|
|
2042
|
-
if (entry.name
|
|
2267
|
+
if (docNames.has(entry.name))
|
|
2043
2268
|
continue;
|
|
2044
2269
|
if (!entry.isFile())
|
|
2045
2270
|
continue; // 子目录 / 符号链接不列出(detach 也只动普通文件)
|
|
@@ -2073,10 +2298,13 @@ export function createRulesService(ctx, deps) {
|
|
|
2073
2298
|
return fail('error.rules.tooManyFiles', `一次最多 ${MAX_ATTACH_ENTRIES} 个附件`, { limit: MAX_ATTACH_ENTRIES });
|
|
2074
2299
|
const pending = [];
|
|
2075
2300
|
let total = 0;
|
|
2301
|
+
const docNames = new Set([bundleDocName(located.name), LEGACY_BUNDLE_DOC]);
|
|
2076
2302
|
for (const file of files) {
|
|
2077
2303
|
const name = String((file && (file.path || file.name)) || '');
|
|
2078
2304
|
if (!isValidGroupSegment(name))
|
|
2079
2305
|
return fail('error.rules.invalidName', `附件名非法:${name || '(空)'}`);
|
|
2306
|
+
if (docNames.has(name))
|
|
2307
|
+
return fail('error.rules.invalidName', `附件名不能与正文文件同名:${name}`);
|
|
2080
2308
|
const data = Buffer.from(String((file && file.data) || ''), 'base64');
|
|
2081
2309
|
if (data.length === 0)
|
|
2082
2310
|
return fail('error.rules.emptyFile', `附件内容为空:${name}`);
|
|
@@ -2089,9 +2317,9 @@ export function createRulesService(ctx, deps) {
|
|
|
2089
2317
|
}
|
|
2090
2318
|
for (const file of pending)
|
|
2091
2319
|
await writeFileAtomicBinary(join(located.entryPath, file.name), file.data);
|
|
2092
|
-
return { ok: true, attachments: await listAttachments(located.entryPath) };
|
|
2320
|
+
return { ok: true, attachments: await listAttachments(located.entryPath, located.name) };
|
|
2093
2321
|
}
|
|
2094
|
-
/** 删除 bundle 记忆的一个附件;正文 SKILL.md
|
|
2322
|
+
/** 删除 bundle 记忆的一个附件;正文 `<名>.md`(或旧数据 `SKILL.md`)不可删。 */
|
|
2095
2323
|
async function rulesDetach(args) {
|
|
2096
2324
|
const id = String((args && args.id) || '');
|
|
2097
2325
|
const parts = parseId(id);
|
|
@@ -2103,8 +2331,9 @@ export function createRulesService(ctx, deps) {
|
|
|
2103
2331
|
if (located.kind !== 'bundle')
|
|
2104
2332
|
return fail('error.rules.notBundle', `「${parts.name}」是 flat(单文件),没有附件`);
|
|
2105
2333
|
const name = String((args && args.name) || '');
|
|
2106
|
-
if (!isValidGroupSegment(name) || name ===
|
|
2334
|
+
if (!isValidGroupSegment(name) || name === bundleDocName(located.name) || name === LEGACY_BUNDLE_DOC) {
|
|
2107
2335
|
return fail('error.rules.invalidName', `附件名非法:${name || '(空)'}`);
|
|
2336
|
+
}
|
|
2108
2337
|
const target = join(located.entryPath, name);
|
|
2109
2338
|
try {
|
|
2110
2339
|
const st = await lstat(target);
|
|
@@ -2115,7 +2344,7 @@ export function createRulesService(ctx, deps) {
|
|
|
2115
2344
|
return fail('error.rules.notFound', `附件不存在:${name}`);
|
|
2116
2345
|
}
|
|
2117
2346
|
await rm(target, { force: true });
|
|
2118
|
-
return { ok: true, attachments: await listAttachments(located.entryPath) };
|
|
2347
|
+
return { ok: true, attachments: await listAttachments(located.entryPath, located.name) };
|
|
2119
2348
|
}
|
|
2120
2349
|
async function rulesToggle(args) {
|
|
2121
2350
|
const id = String((args && args.id) || '');
|
|
@@ -2135,7 +2364,6 @@ export function createRulesService(ctx, deps) {
|
|
|
2135
2364
|
index.rules[id] = { ...idxEntry, enabled, updatedAt: new Date().toISOString() };
|
|
2136
2365
|
await writeIndex(stateDir, index);
|
|
2137
2366
|
invalidateSnapshot();
|
|
2138
|
-
invalidateProviders();
|
|
2139
2367
|
return { ok: true, rule: await buildProjected(id) };
|
|
2140
2368
|
}
|
|
2141
2369
|
/**
|
|
@@ -2180,7 +2408,6 @@ export function createRulesService(ctx, deps) {
|
|
|
2180
2408
|
index.active = names;
|
|
2181
2409
|
await writeIndex(stateDir, index);
|
|
2182
2410
|
invalidateSnapshot();
|
|
2183
|
-
invalidateProviders();
|
|
2184
2411
|
const snap = await snapshot();
|
|
2185
2412
|
return {
|
|
2186
2413
|
ok: true,
|
|
@@ -2238,7 +2465,6 @@ export function createRulesService(ctx, deps) {
|
|
|
2238
2465
|
index.scenes[name] = next;
|
|
2239
2466
|
await writeIndex(stateDir, index);
|
|
2240
2467
|
invalidateSnapshot();
|
|
2241
|
-
invalidateProviders();
|
|
2242
2468
|
return { ok: true, scene: sceneRecordOf(name, next), ...(collapsed ? { collapsedActive: true } : {}) };
|
|
2243
2469
|
}
|
|
2244
2470
|
/**
|
|
@@ -2366,7 +2592,6 @@ export function createRulesService(ctx, deps) {
|
|
|
2366
2592
|
index.scenes[finalName] = next;
|
|
2367
2593
|
await writeIndex(stateDir, index);
|
|
2368
2594
|
invalidateSnapshot();
|
|
2369
|
-
invalidateProviders();
|
|
2370
2595
|
return {
|
|
2371
2596
|
ok: true,
|
|
2372
2597
|
...(finalName === name ? {} : { renamedFrom: name }),
|
|
@@ -2491,7 +2716,6 @@ export function createRulesService(ctx, deps) {
|
|
|
2491
2716
|
if (dirty)
|
|
2492
2717
|
await writeIndex(stateDir, index);
|
|
2493
2718
|
invalidateSnapshot();
|
|
2494
|
-
invalidateProviders();
|
|
2495
2719
|
return { ok: true, name, trashId: trashed.id, movedFiles: moves.length };
|
|
2496
2720
|
}
|
|
2497
2721
|
/** 场景回收站列表(只读):`<hub>/trash/scenes-trash/<id>/manifest.json`。 */
|
|
@@ -2550,7 +2774,6 @@ export function createRulesService(ctx, deps) {
|
|
|
2550
2774
|
await writeIndex(stateDir, index);
|
|
2551
2775
|
await purgeTrashEntry('scenes', id);
|
|
2552
2776
|
invalidateSnapshot();
|
|
2553
|
-
invalidateProviders();
|
|
2554
2777
|
return { ok: true, name, restoredFiles: restored.length };
|
|
2555
2778
|
}
|
|
2556
2779
|
/** 永久删除一条场景回收站条目。 */
|
|
@@ -2563,7 +2786,7 @@ export function createRulesService(ctx, deps) {
|
|
|
2563
2786
|
}
|
|
2564
2787
|
/**
|
|
2565
2788
|
* 提示词预设改名后**同步场景绑定**:把所有 `scenes[].prompt === from` 改成 `to`。
|
|
2566
|
-
* 由 AGENTS.md 预设库的改名路径调用——绑定存在 `
|
|
2789
|
+
* 由 AGENTS.md 预设库的改名路径调用——绑定存在 `memories-index.json` 里,预设库
|
|
2567
2790
|
* 自己看不到它,不叫这一声改名就会留下悬空绑定(场景卡片显示「预设不存在」)。
|
|
2568
2791
|
* @returns 改了几个场景。
|
|
2569
2792
|
*/
|
|
@@ -2585,7 +2808,6 @@ export function createRulesService(ctx, deps) {
|
|
|
2585
2808
|
index.scenes = scenes;
|
|
2586
2809
|
await writeIndex(stateDir, index);
|
|
2587
2810
|
invalidateSnapshot();
|
|
2588
|
-
invalidateProviders();
|
|
2589
2811
|
}
|
|
2590
2812
|
return { ok: true, from, to, changed };
|
|
2591
2813
|
}
|
|
@@ -2613,33 +2835,40 @@ export function createRulesService(ctx, deps) {
|
|
|
2613
2835
|
index.rules[id] = next;
|
|
2614
2836
|
await writeIndex(stateDir, index);
|
|
2615
2837
|
invalidateSnapshot();
|
|
2616
|
-
invalidateProviders();
|
|
2617
2838
|
return { ok: true, rule: await buildProjected(id) };
|
|
2618
2839
|
}
|
|
2619
2840
|
// ── 组装 ─────────────────────────────────────────────────────────────────
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2841
|
+
// 注入文本出口(2026-09-16 第四版)。
|
|
2842
|
+
//
|
|
2843
|
+
// 历史:本服务先后把场景记忆做成"系统提示词段"(会被 persona complete 压掉)与
|
|
2844
|
+
// "写 AGENTS.md 文件"(预设不挂 dsh-agent-instructions 时到不了模型)。现在两条都交给
|
|
2845
|
+
// 注入通道(src/context-inject.ts):每个 step 前把文本并进一条注入消息,任何预设都到得了。
|
|
2846
|
+
// 这里只留**同步取文本**的两个出口,注册/生命周期由 index.ts 的注入器统一管。
|
|
2847
|
+
//
|
|
2848
|
+
// - `memoryText`:场景记忆段(记忆正文;`''` = 没有可注入的内容)。
|
|
2849
|
+
// - `promptText`:全局提示词正文 = 场景期间用场景提示词、平时用 AGENTS.md 正文。
|
|
2850
|
+
// **不判"与文件重复"** —— 预设挂了官方 agent-instructions 时那份正文已经由文件送达
|
|
2851
|
+
// (注入侧会跳过整个域),没挂时(极简)才需要注入兜底;判定在注入器的 `selectInjections` 里。
|
|
2852
|
+
const memoryText = () => sceneMemory().text;
|
|
2853
|
+
/**
|
|
2854
|
+
* `~/.dsh/AGENTS.md` 正文。上限与官方那一行的 `maxBytes` 同量级(64 KiB):超大文件按字节
|
|
2855
|
+
* 截断并留一行标记,免得一份手写的巨型基线把上下文撑爆。
|
|
2856
|
+
*/
|
|
2857
|
+
const AGENTS_MD_MAX_BYTES = 65536;
|
|
2858
|
+
const agentsMdText = () => {
|
|
2859
|
+
const text = readGlobalAgentsMdSync();
|
|
2860
|
+
if (text.trim() === '')
|
|
2861
|
+
return '';
|
|
2862
|
+
if (Buffer.byteLength(text, 'utf8') <= AGENTS_MD_MAX_BYTES)
|
|
2863
|
+
return text;
|
|
2864
|
+
const cut = Buffer.from(text, 'utf8').subarray(0, AGENTS_MD_MAX_BYTES).toString('utf8');
|
|
2865
|
+
return cut + `\n\n(…文件超过 ${Math.floor(AGENTS_MD_MAX_BYTES / 1024)} KiB,已截断。)`;
|
|
2866
|
+
};
|
|
2867
|
+
// 场景提示词与 AGENTS.md 正文在用户眼里就是同一件事("全局提示词"):场景期间前者取代后者,
|
|
2868
|
+
// 与官方语义一致(进场景改写文件、退出恢复)。所以一个域、一份文本,取到非空的场景提示词就用它。
|
|
2869
|
+
const promptText = () => {
|
|
2870
|
+
const scene = resolveScenePreset()?.text ?? '';
|
|
2871
|
+
return scene.trim() !== '' ? scene : agentsMdText();
|
|
2643
2872
|
};
|
|
2644
2873
|
/** 写操作:串行队列内执行,成功后触发 refresh(失效缓存,下一请求即生效)。 */
|
|
2645
2874
|
const runWrite = (task) => enqueueMutation(async () => {
|
|
@@ -2688,7 +2917,8 @@ export function createRulesService(ctx, deps) {
|
|
|
2688
2917
|
const service = {
|
|
2689
2918
|
ops,
|
|
2690
2919
|
writeOps,
|
|
2691
|
-
|
|
2920
|
+
memoryText,
|
|
2921
|
+
promptText,
|
|
2692
2922
|
refresh,
|
|
2693
2923
|
patchIndex,
|
|
2694
2924
|
readArchiveSlice,
|