dsh-plugin-tool-management 0.8.5 → 0.9.1
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 +275 -206
- package/README.md +71 -69
- package/README_EN.md +65 -65
- 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 +132 -86
- 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 +867 -146
- package/lib/compat/preset-reach.js +155 -93
- package/lib/context-inject.js +401 -0
- package/lib/hub.js +155 -13
- package/lib/imports/upload.js +34 -14
- package/lib/index.js +1072 -243
- package/lib/mcp/state-section.js +46 -15
- package/lib/rules/archive-engine.js +158 -64
- package/lib/rules/archive.js +41 -1
- package/lib/rules/service.js +424 -162
- package/lib/scene-prompt-sync.js +93 -12
- package/lib/skills/catalog.js +125 -0
- package/lib/skills/core.js +6 -4
- package/lib/skills/service.js +56 -2
- package/lib/subagents/catalog.js +15 -6
- package/lib/subagents/service.js +25 -12
- package/lib/subagents/tools.js +18 -13
- package/package.json +11 -6
- 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 !== '') {
|
|
@@ -458,9 +486,9 @@ export function parseModeState(raw) {
|
|
|
458
486
|
list.push(tool);
|
|
459
487
|
}
|
|
460
488
|
}
|
|
461
|
-
// v2 快照的**服务器级 /
|
|
462
|
-
// 这里曾把它们剥掉,结果退出模式时两张恢复名单全是空的:服务器级 MCP
|
|
463
|
-
//
|
|
489
|
+
// v2 快照的**服务器级 / 来源级**名单、v0.8 的子智能体名单、v0.9.1 的人设全量映射
|
|
490
|
+
// 必须原样透传 —— 这里曾把它们剥掉,结果退出模式时两张恢复名单全是空的:服务器级 MCP 与
|
|
491
|
+
// 技能来源永远不回滚(用户报的「MCP 不复原」「技能目录不回退」),只有工具级 / 技能级能还原。
|
|
464
492
|
const mcpServers = (Array.isArray(snapshotRaw.mcpServers) ? snapshotRaw.mcpServers : [])
|
|
465
493
|
.filter((x) => !!x && typeof x === 'object')
|
|
466
494
|
.map((x) => ({ id: String(x.id || ''), level: String(x.level || ''), disabled: x.disabled === true }))
|
|
@@ -470,6 +498,12 @@ export 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.9.1 的人设**全量**开关映射(名字 → 进场景时是否开着):同样必须原样透传 ——
|
|
505
|
+
// 剥掉它,退出模式就只能退回上面两个部分名单,「场景里手动开过的人设」不再被还原。
|
|
506
|
+
const subagentsAll = toFlagMap(snapshotRaw.subagentsAll);
|
|
473
507
|
// v0.8.1 的场景备注恢复名单:**必须原样透传**——这里漏掉它,退出模式时备注永不回退
|
|
474
508
|
//(与历史上 mcpServers / skillSources 被剥掉是同一类 bug)。
|
|
475
509
|
const mcpNotes = (Array.isArray(snapshotRaw.mcpNotes) ? snapshotRaw.mcpNotes : [])
|
|
@@ -485,12 +519,14 @@ export function parseModeState(raw) {
|
|
|
485
519
|
...(mcpServers.length ? { mcpServers } : {}),
|
|
486
520
|
...(skillSources.length ? { skillSources } : {}),
|
|
487
521
|
...(subagents.length ? { subagents } : {}),
|
|
522
|
+
...(subagentsOn.length ? { subagentsOn } : {}),
|
|
523
|
+
...(Object.keys(subagentsAll).length ? { subagentsAll } : {}),
|
|
488
524
|
...(mcpNotes.length ? { mcpNotes } : {}),
|
|
489
525
|
}
|
|
490
526
|
: null,
|
|
491
527
|
};
|
|
492
528
|
}
|
|
493
|
-
/** 解析
|
|
529
|
+
/** 解析 memories-index.json 原文;任何异常/版本不符 → 默认索引(容忍缺失,§9.3)。 */
|
|
494
530
|
function parseIndex(raw) {
|
|
495
531
|
const parsed = JSON.parse(raw);
|
|
496
532
|
if (!parsed || parsed.version !== INDEX_VERSION || typeof parsed.rules !== 'object' || parsed.rules === null)
|
|
@@ -507,15 +543,15 @@ function parseIndex(raw) {
|
|
|
507
543
|
}
|
|
508
544
|
async function readIndex(stateDir) {
|
|
509
545
|
try {
|
|
510
|
-
return parseIndex(await readFile(join(stateDir,
|
|
546
|
+
return parseIndex(await readFile(join(stateDir, MEMORIES_INDEX_FILE), 'utf8'));
|
|
511
547
|
}
|
|
512
548
|
catch {
|
|
513
549
|
return defaultIndex();
|
|
514
550
|
}
|
|
515
551
|
}
|
|
516
|
-
/**
|
|
552
|
+
/** 同步读索引:注入文本的渲染路径不能 await(见文件内「两相扫描」注释)。 */
|
|
517
553
|
function readIndexSync(stateDir) {
|
|
518
|
-
const raw = readFileIfExistsSync(join(stateDir,
|
|
554
|
+
const raw = readFileIfExistsSync(join(stateDir, MEMORIES_INDEX_FILE));
|
|
519
555
|
if (raw === null)
|
|
520
556
|
return defaultIndex();
|
|
521
557
|
try {
|
|
@@ -527,9 +563,9 @@ function readIndexSync(stateDir) {
|
|
|
527
563
|
}
|
|
528
564
|
async function writeIndex(stateDir, index) {
|
|
529
565
|
await mkdir(stateDir, { recursive: true });
|
|
530
|
-
await writeFileAtomically(join(stateDir,
|
|
566
|
+
await writeFileAtomically(join(stateDir, MEMORIES_INDEX_FILE), JSON.stringify(index, null, 2));
|
|
531
567
|
}
|
|
532
|
-
// ── 场景记录(索引内,`
|
|
568
|
+
// ── 场景记录(索引内,`memories-index.json` 的 scenes 切片)+ 旧布局迁移 ────────
|
|
533
569
|
/** 索引条目 → 场景记录(供 UI 直接渲染)。 */
|
|
534
570
|
function sceneRecordOf(name, entry) {
|
|
535
571
|
const e = entry || {};
|
|
@@ -658,7 +694,7 @@ function normalizeActive(raw) {
|
|
|
658
694
|
* 因此"新建即启用"不会发生;存量数据仍按老语义读,避免升级后注入范围突变。
|
|
659
695
|
* - `_shared` 恒常启用(公共基线),不受开关影响
|
|
660
696
|
* - 保留场景 `global` 恒常启用:它的记忆对任何对话都成立
|
|
661
|
-
* 缺失
|
|
697
|
+
* 缺失 memories-index.json 一律按默认值运行,不抛错(§9.3)。
|
|
662
698
|
* 场景生效与否只由 index.active 决定(场景档案/模式也写这一份)。
|
|
663
699
|
*/
|
|
664
700
|
function resolveActiveScenes(index, knownScenes) {
|
|
@@ -766,10 +802,10 @@ async function buildSnapshot(rulesRoot, stateDir) {
|
|
|
766
802
|
}
|
|
767
803
|
// ── 场景记忆段:两相扫描(廉价指纹 → 按需读正文)──────────────────────────
|
|
768
804
|
//
|
|
769
|
-
// 硬约束 1(§5.1
|
|
770
|
-
//
|
|
805
|
+
// 硬约束 1(§5.1):注入通道每个 step 都要求**同步**取一次文本(`text: () => string`,
|
|
806
|
+
// 见 src/context-inject.ts),返回 Promise 会让这一步的注入直接失败(异常被吞)。
|
|
771
807
|
//
|
|
772
|
-
// 缓存策略(§5.4 S3
|
|
808
|
+
// 缓存策略(§5.4 S3):文本出口必须同步返回 string,但不希望每个模型步骤都
|
|
773
809
|
// 读一遍所有记忆正文。因此拆成两相:
|
|
774
810
|
// ① `probeSceneFilesSync()`:只走目录树 + `statSync`(**不读正文**),产出候选文件
|
|
775
811
|
// 与**指纹**(`id|mtime|size` + 影响渲染的索引字段)。
|
|
@@ -781,8 +817,8 @@ async function buildSnapshot(rulesRoot, stateDir) {
|
|
|
781
817
|
// 一次 stat 遍历(个人记忆树是亚毫秒级),换来"永远最新且永不静默失效",优先于"零 IO"。
|
|
782
818
|
//
|
|
783
819
|
// 与 discover() 的关系:discover 是异步全量快照(供 CRUD/列表/体检),这里是同步轻量
|
|
784
|
-
//
|
|
785
|
-
// 目录含 SKILL.md
|
|
820
|
+
// 扫描(供注入通道),两者对"什么是规则"的定义保持一致:
|
|
821
|
+
// 目录含 `<目录名>.md`(或旧版 `SKILL.md`)→ bundle 规则(叶子);否则继续下钻;只认 .md;跳过隐藏项;
|
|
786
822
|
// 同名 flat 与 bundle 冲突时 bundle 优先。
|
|
787
823
|
function readFileIfExistsSync(path) {
|
|
788
824
|
try {
|
|
@@ -856,14 +892,23 @@ function probeSceneFilesSync(rulesRoot, index) {
|
|
|
856
892
|
continue; // 同步扫描不跟随符号链接(防环)
|
|
857
893
|
if (item.isDirectory()) {
|
|
858
894
|
budget.items++;
|
|
859
|
-
|
|
860
|
-
const
|
|
861
|
-
|
|
895
|
+
let docPath = '';
|
|
896
|
+
for (const candidate of [bundleDocName(item.name), LEGACY_BUNDLE_DOC]) {
|
|
897
|
+
const p = join(child, candidate);
|
|
898
|
+
if (fileStampSync(p) !== 'missing') {
|
|
899
|
+
docPath = p;
|
|
900
|
+
break;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
// 指纹带上**目录本身**的 mtime:附件只在段里以「路径 + 文件名」出现,
|
|
904
|
+
// 增删附件不改正文文件,只靠它的话指纹不变、段不会重算,列表就会停在旧值。
|
|
905
|
+
if (docPath !== '') {
|
|
906
|
+
const stamp = `${fileStampSync(docPath)}:${fileStampSync(child)}`;
|
|
862
907
|
// bundle 规则(叶子):父路径为场景,目录名为记忆名。
|
|
863
908
|
const id = scene ? `${scene}/${relChild}` : relChild;
|
|
864
909
|
if (index.rules[id]?.enabled === false)
|
|
865
910
|
continue; // 单条停用 → 不进入段
|
|
866
|
-
add({ id, scene, name: item.name, kind: 'bundle', path:
|
|
911
|
+
add({ id, scene, name: item.name, kind: 'bundle', path: docPath, order: index.rules[id]?.order ?? DEFAULT_ORDER, stamp });
|
|
867
912
|
continue;
|
|
868
913
|
}
|
|
869
914
|
walk(scene, child, relChild, depth + 1);
|
|
@@ -946,6 +991,9 @@ function renderSceneMemory(probe, index, maxBytes) {
|
|
|
946
991
|
descriptionDerived: derived.descriptionDerived,
|
|
947
992
|
order: ref.order,
|
|
948
993
|
body: doc.body,
|
|
994
|
+
kind: ref.kind,
|
|
995
|
+
// bundle 的正文是 `<目录>/<名>.md`,附件是**同目录**的其余文件。
|
|
996
|
+
bundleDir: ref.kind === 'bundle' ? dirname(ref.path) : '',
|
|
949
997
|
});
|
|
950
998
|
}
|
|
951
999
|
const { active } = resolveActiveScenes(index, probe.scenes);
|
|
@@ -968,14 +1016,15 @@ function renderSceneMemory(probe, index, maxBytes) {
|
|
|
968
1016
|
let seq = 0;
|
|
969
1017
|
for (const scene of [...buckets.keys()].sort((a, b) => compareSceneBuckets(a, b, index))) {
|
|
970
1018
|
const sceneFiles = (buckets.get(scene) || []).slice().sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
|
|
971
|
-
const header =
|
|
1019
|
+
const header = sceneHeader(scene, index);
|
|
972
1020
|
for (const file of sceneFiles) {
|
|
973
|
-
const block
|
|
1021
|
+
const { text: block, inline } = memoryBlock(file);
|
|
974
1022
|
candidates.push({
|
|
975
1023
|
seq: seq++,
|
|
976
1024
|
scene,
|
|
977
1025
|
header,
|
|
978
1026
|
block,
|
|
1027
|
+
inline,
|
|
979
1028
|
item: { id: file.id, scene: file.scene, name: file.name, bytes: byteLen(block) },
|
|
980
1029
|
});
|
|
981
1030
|
}
|
|
@@ -983,18 +1032,36 @@ function renderSceneMemory(probe, index, maxBytes) {
|
|
|
983
1032
|
if (candidates.length === 0) {
|
|
984
1033
|
return { text: '', bytes: 0, truncated: probe.truncated, maxBytes, scenes: [], items: [], dropped: [] };
|
|
985
1034
|
}
|
|
986
|
-
/**
|
|
987
|
-
|
|
1035
|
+
/**
|
|
1036
|
+
* 选中块 → 段正文(同一场景的 `## 场景:x` 只在首次出现时发出)。
|
|
1037
|
+
* `dropped` 决定引导语用哪一版:真有条目没注入时不再声称「以下就是全部信息」。
|
|
1038
|
+
*/
|
|
1039
|
+
const renderBody = (selected, dropped) => {
|
|
1040
|
+
if (selected.length === 0)
|
|
1041
|
+
return '';
|
|
1042
|
+
const note = dropped ? SCENE_MEMORY_NOTE_PARTIAL : SCENE_MEMORY_NOTE;
|
|
988
1043
|
const chunks = [];
|
|
989
1044
|
let current = null;
|
|
990
1045
|
let buf = '';
|
|
1046
|
+
// 上一条是不是「单行条目」。连续两条单行条目紧挨着(列表的自然形态),
|
|
1047
|
+
// 其余情况都要空一行 —— 多行正文(含其缩进挂载的附件行)与下一条之间没有空行的话,
|
|
1048
|
+
// 读起来会连成一片、看不出条目边界。
|
|
1049
|
+
let prevInline = false;
|
|
991
1050
|
for (const c of selected) {
|
|
992
1051
|
if (c.scene !== current) {
|
|
993
1052
|
if (buf !== '')
|
|
994
1053
|
chunks.push(buf);
|
|
995
1054
|
current = c.scene;
|
|
996
|
-
|
|
1055
|
+
// 引导语跟着**场景块**走(场景说明之后、条目之前):它管的就是下面这些条目,
|
|
1056
|
+
// 放最顶层会飘在场景之外(用户实测反馈「怎么跑到最顶层了」)。场景数有上限
|
|
1057
|
+
// (`global`/`_shared` 恒常启用 + 至多一个启用场景),最多出现 3 次,代价可接受。
|
|
1058
|
+
buf = `${c.header}${note}\n\n`;
|
|
1059
|
+
prevInline = false;
|
|
997
1060
|
}
|
|
1061
|
+
const inline = c.inline;
|
|
1062
|
+
if (!(inline && prevInline) && !buf.endsWith('\n\n'))
|
|
1063
|
+
buf += '\n';
|
|
1064
|
+
prevInline = inline;
|
|
998
1065
|
buf += c.block;
|
|
999
1066
|
}
|
|
1000
1067
|
if (buf !== '')
|
|
@@ -1032,9 +1099,12 @@ function renderSceneMemory(probe, index, maxBytes) {
|
|
|
1032
1099
|
const selected = [];
|
|
1033
1100
|
const missed = [];
|
|
1034
1101
|
const takenScenes = new Set();
|
|
1102
|
+
// 场景标题与引导语也是开销,按「每个首次出现的场景」计进预算 —— 否则它们会挤掉
|
|
1103
|
+
// 本该放得下的记忆(引导语的字节见 SCENE_NOTE_BYTES)。
|
|
1035
1104
|
let used = 0;
|
|
1105
|
+
const noteBytes = sceneNoteBytes();
|
|
1036
1106
|
for (const c of candidates) {
|
|
1037
|
-
const headerCost = takenScenes.has(c.scene) ? 0 : byteLen(c.header);
|
|
1107
|
+
const headerCost = takenScenes.has(c.scene) ? 0 : byteLen(c.header) + noteBytes;
|
|
1038
1108
|
if (used + headerCost + c.item.bytes + markerReserve > maxBytes) {
|
|
1039
1109
|
missed.push(c);
|
|
1040
1110
|
continue;
|
|
@@ -1045,12 +1115,12 @@ function renderSceneMemory(probe, index, maxBytes) {
|
|
|
1045
1115
|
}
|
|
1046
1116
|
missed.sort((a, b) => a.seq - b.seq);
|
|
1047
1117
|
// ── ② 尾注自身也占字节:放不下就把已入选的块从后往前退回,直到回到预算内 ──
|
|
1048
|
-
let body = renderBody(selected);
|
|
1118
|
+
let body = renderBody(selected, probe.truncated || missed.length > 0);
|
|
1049
1119
|
let tail = renderTail(missed, maxBytes - byteLen(body), probe.truncated || missed.length > 0);
|
|
1050
1120
|
while (byteLen(body) + byteLen(tail) > maxBytes && selected.length > 0) {
|
|
1051
1121
|
missed.push(selected.pop());
|
|
1052
1122
|
missed.sort((a, b) => a.seq - b.seq);
|
|
1053
|
-
body = renderBody(selected);
|
|
1123
|
+
body = renderBody(selected, true);
|
|
1054
1124
|
tail = renderTail(missed, maxBytes - byteLen(body), true);
|
|
1055
1125
|
}
|
|
1056
1126
|
let text = `${body}${tail}`.replace(/^\n+/, '');
|
|
@@ -1105,15 +1175,175 @@ function sceneLabel(scene, index) {
|
|
|
1105
1175
|
const label = index?.scenes?.[scene]?.label;
|
|
1106
1176
|
return label && label !== '' ? label : scene;
|
|
1107
1177
|
}
|
|
1108
|
-
/**
|
|
1109
|
-
const sceneHeading = (scene) => `##
|
|
1178
|
+
/** 单个场景的标题:**场景在最顶层**(`##`,与「子智能体」「MCP 服务器」等段同级)。 */
|
|
1179
|
+
const sceneHeading = (scene) => `## 场景:${sceneLabel(scene)}`;
|
|
1180
|
+
/**
|
|
1181
|
+
* 没填描述时的默认「场景说明」(用户裁定 2026-09-16:全局桶一直没有描述,读起来像缺了一块,
|
|
1182
|
+
* 统一成"每个场景块都有场景说明")。
|
|
1183
|
+
*
|
|
1184
|
+
* 默认句同时承担"这个场景是什么"的答疑(此前只有光秃秃的 `## 场景:X`,模型读不懂 —— 用户实测):
|
|
1185
|
+
* 两个恒常桶说明生效范围,用户场景说明它是当前启用的那份配置。
|
|
1186
|
+
*/
|
|
1187
|
+
const defaultSceneDescription = (scene) => (scene === GLOBAL_SCENE ? '全局记忆,任何对话都生效'
|
|
1188
|
+
: scene === SHARED_GROUP ? '共享记忆,任何对话都生效'
|
|
1189
|
+
: '用户配置的上下文,当前启用');
|
|
1190
|
+
/**
|
|
1191
|
+
* 单个场景的段头:场景标题 + **恒有**的 `场景说明:<描述>`。
|
|
1192
|
+
*
|
|
1193
|
+
* 场景描述(界面「描述(可选)」,≤60 字符)**此前从未注入过** —— 它正是「这个场景是
|
|
1194
|
+
* 干什么的」的答案,属于模型做判断需要的上下文,而不是只给人看的元数据;界面上的文案
|
|
1195
|
+
* 也从没把它标成「只给使用者看」(对比 AGENTS.md 预设的描述,那里是明确标注的)。
|
|
1196
|
+
* 描述为空时给 `defaultSceneDescription` 的默认句(用户裁定:全局桶没描述时读起来像
|
|
1197
|
+
* 缺了一块,统一成每个场景块都有说明)。
|
|
1198
|
+
*/
|
|
1199
|
+
function sceneHeader(scene, index) {
|
|
1200
|
+
const head = sceneHeading(scene);
|
|
1201
|
+
const described = String(index?.scenes?.[scene]?.description ?? '').replaceAll(/\s+/g, ' ').trim();
|
|
1202
|
+
const description = described === '' ? defaultSceneDescription(scene) : described;
|
|
1203
|
+
// 加粗(用户裁定 2026-09-16):与引导语同款强调,别让"场景说明"读起来像可忽略的普通正文。
|
|
1204
|
+
return `${head}\n\n**场景说明:${description}**\n\n`;
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* 段首的引导语:让模型知道下面是**用户提供的参考信息**,而不是对话历史或临时说明
|
|
1208
|
+
* —— 相关就用、无关可忽略。
|
|
1209
|
+
*
|
|
1210
|
+
* 写法(2026-09-16 用户裁定,基于真实注入结果的三次修正):
|
|
1211
|
+
* - **单行、加粗**,不再用括号分两行 —— 括号跨行在真实提示词里读起来像被截断,
|
|
1212
|
+
* 而加粗是 Markdown 里最省字符的强调手段(用户要求「加强模型对此的重视程度」)。
|
|
1213
|
+
* - **提到段首、整段只出现一次**:原来它挂在每个场景的段头里,多场景时会重复注入。
|
|
1214
|
+
* - **不点名任何工具**:模型从工具 schema 就知道 `memory_manager_list` 存在,点名反而
|
|
1215
|
+
* 像在提示它去调;用户裁定「没启用的信息就是不想在当前用」,所以工具指引整句删除。
|
|
1216
|
+
* 真正防探测的是**完整性声明**(「以下就是全部信息」),那半句必须留。
|
|
1217
|
+
* - **完整性声明按截断状态自适应**:真有条目因预算没注入时,段尾会有未注入清单,
|
|
1218
|
+
* 此时不能再声称「全部」,否则和清单自相矛盾 —— 也正因为那时确实有东西没给到,
|
|
1219
|
+
* 模型去查工具是**合理**的,不该再拦。
|
|
1220
|
+
*
|
|
1221
|
+
* 用词:不用「常驻」「注入」这类内部行话(模型没有先验);用户裁定用「信息」而不是
|
|
1222
|
+
* 「记忆」——「记忆」在系统提示词里指代不明,而这段的实质就是用户写的参考信息。
|
|
1223
|
+
*/
|
|
1224
|
+
const SCENE_MEMORY_NOTE = '**用户提供的参考信息:与当前任务相关时直接采用,无关时忽略。以下就是全部信息。**';
|
|
1225
|
+
/** 有未注入条目时的版本:去掉完整性声明(见上)。 */
|
|
1226
|
+
const SCENE_MEMORY_NOTE_PARTIAL = '**用户提供的参考信息:与当前任务相关时直接采用,无关时忽略。**';
|
|
1227
|
+
/**
|
|
1228
|
+
* 段首固定块。以 `\n` 结尾,与场景块 join 后自然空一行。
|
|
1229
|
+
* 预算按**较长**的那版算(见 renderSceneMemory),保守一点只会浪费几个字节。
|
|
1230
|
+
*/
|
|
1231
|
+
/**
|
|
1232
|
+
* 引导语所占的字节(含它后面的一个空行)。跟着场景块走,所以按**每个场景**计入段头开销;
|
|
1233
|
+
* 用较长的那版(带完整性声明)算,保守一点只会浪费几个字节。
|
|
1234
|
+
*
|
|
1235
|
+
* 注意:模块级不能直接算 —— `byteLen` 是后面才声明的 const,模块初始化期取它会 TDZ 报错。
|
|
1236
|
+
*/
|
|
1237
|
+
const sceneNoteBytes = () => byteLen(`${SCENE_MEMORY_NOTE}\n\n`);
|
|
1238
|
+
/** 单行正文的最大长度:超过就退回「标题 + 正文块」,避免出现一条几千字符的列表行。 */
|
|
1239
|
+
const INLINE_BODY_MAX = 120;
|
|
1240
|
+
/** 附件行里最多列几个文件名;多的只报总数(路径已经给了,缺的名字模型自己列目录即可)。 */
|
|
1241
|
+
const ATTACHMENT_LIST_MAX = 10;
|
|
1242
|
+
/**
|
|
1243
|
+
* bundle 记忆的附件名(**同步**版 —— 段渲染必须同步返回,`listAttachments` 是异步的)。
|
|
1244
|
+
* 口径与异步版一致:跳过正文本体(`<名>.md`,旧数据可能是 `SKILL.md`),只认普通文件。
|
|
1245
|
+
*/
|
|
1246
|
+
function attachmentNamesSync(bundleDir, name) {
|
|
1247
|
+
try {
|
|
1248
|
+
const docNames = new Set([bundleDocName(name), LEGACY_BUNDLE_DOC]);
|
|
1249
|
+
return readdirSync(bundleDir, { withFileTypes: true })
|
|
1250
|
+
.filter((e) => e.isFile() && !docNames.has(e.name))
|
|
1251
|
+
.map((e) => e.name)
|
|
1252
|
+
.sort((a, b) => a.localeCompare(b));
|
|
1253
|
+
}
|
|
1254
|
+
catch {
|
|
1255
|
+
return [];
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
/**
|
|
1259
|
+
* bundle 记忆的附件**摘要**(列表页用):数量 / 总体积 / 前几个文件名。
|
|
1260
|
+
*
|
|
1261
|
+
* 口径与 `attachmentNamesSync`(也就是注入给模型的那份清单)逐字一致:跳过正文本体,
|
|
1262
|
+
* 只认普通文件 —— 所以页面上的数字与模型实际看到的一致,不会出现「界面说 3 个、模型只见 2 个」。
|
|
1263
|
+
* `names` 截到 ATTACHMENT_LIST_MAX:tooltip 列不下更多,要全看到编辑弹窗里去看。
|
|
1264
|
+
*
|
|
1265
|
+
* 目录读不到(索引残留了已消失的条目)→ 返回 null,让界面**什么都不显示**,
|
|
1266
|
+
* 而不是谎报「0 个附件」。
|
|
1267
|
+
*/
|
|
1268
|
+
function attachmentSummarySync(bundleDir, name) {
|
|
1269
|
+
let names;
|
|
1270
|
+
try {
|
|
1271
|
+
const docNames = new Set([bundleDocName(name), LEGACY_BUNDLE_DOC]);
|
|
1272
|
+
names = readdirSync(bundleDir, { withFileTypes: true })
|
|
1273
|
+
.filter((e) => e.isFile() && !docNames.has(e.name))
|
|
1274
|
+
.map((e) => e.name)
|
|
1275
|
+
.sort((a, b) => a.localeCompare(b));
|
|
1276
|
+
}
|
|
1277
|
+
catch {
|
|
1278
|
+
return null;
|
|
1279
|
+
}
|
|
1280
|
+
let bytes = 0;
|
|
1281
|
+
for (const entry of names) {
|
|
1282
|
+
try {
|
|
1283
|
+
bytes += statSync(join(bundleDir, entry)).size;
|
|
1284
|
+
}
|
|
1285
|
+
catch { /* 读不到的条目不计体积 */ }
|
|
1286
|
+
}
|
|
1287
|
+
return { count: names.length, bytes, names: names.slice(0, ATTACHMENT_LIST_MAX) };
|
|
1288
|
+
}
|
|
1289
|
+
/**
|
|
1290
|
+
* 附件行(只有 bundle 记忆才有):**给目录与文件名,不给内容**。
|
|
1291
|
+
* 附件可能是图片、二进制、大 md —— 全文注入又贵又会把段预算吃光;给路径,模型需要时自己读。
|
|
1292
|
+
*/
|
|
1293
|
+
function attachmentLine(file) {
|
|
1294
|
+
if (file.kind !== 'bundle')
|
|
1295
|
+
return '';
|
|
1296
|
+
const names = attachmentNamesSync(file.bundleDir, file.name);
|
|
1297
|
+
if (names.length === 0)
|
|
1298
|
+
return '';
|
|
1299
|
+
const shown = names.slice(0, ATTACHMENT_LIST_MAX);
|
|
1300
|
+
const more = names.length - shown.length;
|
|
1301
|
+
return `附件目录:${file.bundleDir}(未注入正文,共 ${names.length} 个:${shown.join('、')}${more > 0 ? `,另 ${more} 个` : ''})`;
|
|
1302
|
+
}
|
|
1110
1303
|
/**
|
|
1111
|
-
*
|
|
1112
|
-
*
|
|
1304
|
+
* 单条信息的渲染形态 —— **能一行就一行,但恒为列表项**。
|
|
1305
|
+
*
|
|
1306
|
+
* 单行且不长的正文 → `- **名称**(描述) — 正文`(与 MCP / 子智能体两个段的列表同形;无显式描述时括号不出现)
|
|
1307
|
+
* 多行或过长的正文 → `- **名称**(描述)` + 空行 + 缩进 2 格的正文(挂在条目下)
|
|
1308
|
+
*
|
|
1309
|
+
* 名称恒为标题:它就是这条记忆的身份(工具 id `<场景>/<名称>`、bundle 目录/文件名都以它为准),
|
|
1310
|
+
* 用户说「记忆里的 X」、模型再调 `memory_manager_*` 时都对得上号;显式描述是括号注解,不抢标题。
|
|
1311
|
+
*
|
|
1312
|
+
* 为什么全都做成列表项:早先多行正文走 `### 名称` 标题块,附件行只能退化成与记忆**同级**的
|
|
1313
|
+
* `- 附件目录:…`(没有父列表项可挂)—— 既可能被读成一条独立记忆,某些渲染器里还会把下一个
|
|
1314
|
+
* `###` 标题吞进列表(与上一条粘连)。统一成「一条记忆 = 一个列表项、正文与附件都缩进挂在
|
|
1315
|
+
* 条目下」后,两种记忆外观完全一致,归属也不再靠位置猜测。
|
|
1316
|
+
*
|
|
1317
|
+
* 为什么要分两种:用户常有十几条「一句话事实」(「提交格式:PDF」),每条都占标题 + 空行 +
|
|
1318
|
+
* 正文三行,整段会散成一长串标题;压成一行后十条信息就是十行。多行正文是**用户写的完整
|
|
1319
|
+
* Markdown**(可能自带标题、代码块、嵌套列表),整体缩进 2 格挂到条目下,结构原样保留。
|
|
1320
|
+
*
|
|
1321
|
+
* 返回值带 `inline`:调用方据此决定下一条记忆前要不要空行(单行条目连续排列,其余空行分隔)。
|
|
1113
1322
|
*/
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1323
|
+
function memoryBlock(file) {
|
|
1324
|
+
const desc = explicitDescriptionOf(file);
|
|
1325
|
+
// 加粗的只有名称:`- **名称**(描述) — 正文`,与 MCP 段 `- **server**(N 个工具) — …` 同形。
|
|
1326
|
+
const title = `**${file.name}**` + (desc === '' ? '' : `(${desc})`);
|
|
1327
|
+
const text = String(file.body ?? '').trim();
|
|
1328
|
+
const inline = text === '' || (!text.includes('\n') && text.length <= INLINE_BODY_MAX);
|
|
1329
|
+
const head = text === ''
|
|
1330
|
+
? `- ${title}`
|
|
1331
|
+
: (inline ? `- ${title} — ${text}` : `- ${title}\n\n${indentBody(text)}`);
|
|
1332
|
+
const attach = attachmentLine(file);
|
|
1333
|
+
if (attach === '')
|
|
1334
|
+
return { text: `${head}\n`, inline };
|
|
1335
|
+
// 附件行恒为缩进子项:只用 `- ` 会被解析成与记忆**同级**的列表项(`- A` / `- 附件目录:A的` /
|
|
1336
|
+
// `- B` … 四条平级,归属读不出来)。单行条目紧跟其后保持列表连续;多行条目前面空一行,
|
|
1337
|
+
// 免得被读成用户正文自己的列表项。
|
|
1338
|
+
return { text: inline ? `${head}\n - ${attach}\n` : `${head}\n\n - ${attach}\n`, inline };
|
|
1339
|
+
}
|
|
1340
|
+
/** 多行正文整体缩进 2 格(列在条目内容列上),空行保持空行、不加尾随空白。 */
|
|
1341
|
+
const indentBody = (text) => text
|
|
1342
|
+
.split('\n')
|
|
1343
|
+
.map((line) => (line === '' ? line : ' ' + line))
|
|
1344
|
+
.join('\n');
|
|
1345
|
+
/** 括号注解用的显式描述:派生描述与正文重复、不进段(只存在于界面投影);换行压成单行,避免把「一行一条」的列表项撑断。 */
|
|
1346
|
+
const explicitDescriptionOf = (f) => (f.descriptionDerived || !f.description ? '' : String(f.description).replaceAll(/\s+/g, ' ').trim());
|
|
1117
1347
|
const byteLen = (s) => Buffer.byteLength(s, 'utf8');
|
|
1118
1348
|
// ── 创建服务 ───────────────────────────────────────────────────────────────
|
|
1119
1349
|
export function createRulesService(ctx, deps) {
|
|
@@ -1175,10 +1405,10 @@ export function createRulesService(ctx, deps) {
|
|
|
1175
1405
|
sceneCache = { signature: probe.signature, value };
|
|
1176
1406
|
return value;
|
|
1177
1407
|
}
|
|
1178
|
-
// ──
|
|
1408
|
+
// ── 场景提示词(绑定预设正文;读盘、只读;写文件由 scene-prompt-sync.ts 负责)──
|
|
1179
1409
|
//
|
|
1180
1410
|
// 语义(用户裁定 2026-09-15):
|
|
1181
|
-
// - 场景可绑定**一个**提示词预设(`scenes[].prompt` → `
|
|
1411
|
+
// - 场景可绑定**一个**提示词预设(`scenes[].prompt` → `prompts/<id>/AGENTS.md`);
|
|
1182
1412
|
// - 除保留场景 `global` 外**同时只能启用一个场景**,所以同一时刻至多一份提示词在场;
|
|
1183
1413
|
// - 没有启用其它场景时才轮到 `global` 自己的绑定(它恒常生效,覆盖"永远在场的提示词");
|
|
1184
1414
|
// - 预设文件不存在 / 未绑定 → 返回 `''`(renderPrompt 会删掉空段,不产生空标题)。
|
|
@@ -1189,7 +1419,7 @@ export function createRulesService(ctx, deps) {
|
|
|
1189
1419
|
const promptFileCache = new Map();
|
|
1190
1420
|
/** 读一个预设正文(同步、带 stat 指纹缓存);不存在返回 `''`。 */
|
|
1191
1421
|
function readPresetTextSync(id) {
|
|
1192
|
-
const file = join(stateDir,
|
|
1422
|
+
const file = join(stateDir, PRESETS_DIR, id, 'AGENTS.md');
|
|
1193
1423
|
let key;
|
|
1194
1424
|
try {
|
|
1195
1425
|
const st = statSync(file);
|
|
@@ -1217,7 +1447,7 @@ export function createRulesService(ctx, deps) {
|
|
|
1217
1447
|
/** 预设文件是否存在(同步;绑定前校验用,避免悬空绑定)。 */
|
|
1218
1448
|
function presetExistsSync(id) {
|
|
1219
1449
|
try {
|
|
1220
|
-
return statSync(join(stateDir,
|
|
1450
|
+
return statSync(join(stateDir, PRESETS_DIR, id, 'AGENTS.md')).isFile();
|
|
1221
1451
|
}
|
|
1222
1452
|
catch {
|
|
1223
1453
|
return false;
|
|
@@ -1254,24 +1484,13 @@ export function createRulesService(ctx, deps) {
|
|
|
1254
1484
|
return text;
|
|
1255
1485
|
}
|
|
1256
1486
|
/**
|
|
1257
|
-
* 当前生效的场景提示词:启用场景的绑定优先,其次 `global`
|
|
1258
|
-
*
|
|
1259
|
-
* 基线里了,再注入一遍只会把同样的内容塞进上下文两次。
|
|
1487
|
+
* 当前生效的场景提示词:启用场景的绑定优先,其次 `global` 的绑定;都没有 → `null`。
|
|
1488
|
+
* 「绑了但预设为空/已删」按没绑处理(不注入空段),继续找下一个。
|
|
1260
1489
|
*/
|
|
1261
|
-
function
|
|
1490
|
+
function resolveScenePreset() {
|
|
1262
1491
|
const index = readIndexSync(stateDir);
|
|
1263
1492
|
const names = Object.keys(index.scenes || {});
|
|
1264
1493
|
const { active } = resolveActiveScenes(index, names);
|
|
1265
|
-
const globalText = readGlobalAgentsMdSync().trim();
|
|
1266
|
-
/** 绑定 → 投影;与文件一致时是 duplicate(不注入),否则原样注入。 */
|
|
1267
|
-
const project = (scene, id) => {
|
|
1268
|
-
const text = readPresetTextSync(id);
|
|
1269
|
-
if (text.trim() === '')
|
|
1270
|
-
return { scene, presetId: id, text: '', missing: true };
|
|
1271
|
-
if (globalText !== '' && text.trim() === globalText)
|
|
1272
|
-
return { scene, presetId: id, text: '', missing: false, duplicate: true };
|
|
1273
|
-
return { scene, presetId: id, text, missing: false };
|
|
1274
|
-
};
|
|
1275
1494
|
// 单选:取唯一一个非保留启用场景(resolveActiveScenes 已保证 ≤1,这里仍做确定性排序兜底)。
|
|
1276
1495
|
const enabled = names
|
|
1277
1496
|
.filter((n) => n !== SHARED_GROUP && n !== GLOBAL_SCENE && active.has(n))
|
|
@@ -1280,27 +1499,35 @@ export function createRulesService(ctx, deps) {
|
|
|
1280
1499
|
const id = index.scenes?.[scene]?.prompt;
|
|
1281
1500
|
if (!id)
|
|
1282
1501
|
continue;
|
|
1283
|
-
const
|
|
1502
|
+
const text = readPresetTextSync(id);
|
|
1284
1503
|
// 绑了但预设为空/已删 → 继续找下一个(不注入空段)。
|
|
1285
|
-
if (
|
|
1504
|
+
if (text.trim() === '')
|
|
1286
1505
|
continue;
|
|
1287
|
-
return
|
|
1506
|
+
return { scene, presetId: id, text };
|
|
1288
1507
|
}
|
|
1289
1508
|
const globalId = index.scenes?.[GLOBAL_SCENE]?.prompt;
|
|
1290
|
-
if (globalId)
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
const providerInvalidators = new Set();
|
|
1295
|
-
/** 通知已注册的 provider 提示词已变化(best-effort)。 */
|
|
1296
|
-
const invalidateProviders = () => {
|
|
1297
|
-
for (const invalidate of providerInvalidators) {
|
|
1298
|
-
try {
|
|
1299
|
-
invalidate();
|
|
1300
|
-
}
|
|
1301
|
-
catch { /* 单个失效失败不影响其余 */ }
|
|
1509
|
+
if (globalId) {
|
|
1510
|
+
const text = readPresetTextSync(globalId);
|
|
1511
|
+
if (text.trim() !== '')
|
|
1512
|
+
return { scene: GLOBAL_SCENE, presetId: globalId, text };
|
|
1302
1513
|
}
|
|
1303
|
-
|
|
1514
|
+
return null;
|
|
1515
|
+
}
|
|
1516
|
+
/**
|
|
1517
|
+
* 当前生效的场景提示词(只读投影,界面用):绑定内容与 `~/.dsh/AGENTS.md` 相同时标记
|
|
1518
|
+
* `duplicate` 且返回空段 —— 那份正文已经在基线里了,界面据此显示「生效中」。
|
|
1519
|
+
* (注入侧不看这个字段:预设挂不到 AGENTS.md 通道时照样要注入,见 `promptText`。)
|
|
1520
|
+
*/
|
|
1521
|
+
function scenePrompt() {
|
|
1522
|
+
const found = resolveScenePreset();
|
|
1523
|
+
if (!found)
|
|
1524
|
+
return { scene: null, presetId: null, text: '', missing: false };
|
|
1525
|
+
const globalText = readGlobalAgentsMdSync().trim();
|
|
1526
|
+
if (globalText !== '' && found.text.trim() === globalText) {
|
|
1527
|
+
return { scene: found.scene, presetId: found.presetId, text: '', missing: false, duplicate: true };
|
|
1528
|
+
}
|
|
1529
|
+
return { scene: found.scene, presetId: found.presetId, text: found.text, missing: false };
|
|
1530
|
+
}
|
|
1304
1531
|
// ── 写操作串行队列(避免并发覆盖同一索引/文件)──
|
|
1305
1532
|
let mutationQueue = Promise.resolve();
|
|
1306
1533
|
const enqueueMutation = (task) => {
|
|
@@ -1322,17 +1549,26 @@ export function createRulesService(ctx, deps) {
|
|
|
1322
1549
|
return null;
|
|
1323
1550
|
return { group, name };
|
|
1324
1551
|
}
|
|
1325
|
-
/**
|
|
1552
|
+
/**
|
|
1553
|
+
* 磁盘定位(bundle 优先)。返回规则文件与条目路径。
|
|
1554
|
+
* 根层(`group === ''`)**没有 bundle**:一级目录恒为场景(见 discover 的注释),
|
|
1555
|
+
* 否则 `memories/1/1.md`(场景 1 的 flat 记忆 1)会被这里读成根层 bundle「1」,
|
|
1556
|
+
* 与列表/注入两端不一致(编辑、删除都会落到错误的文件上)。
|
|
1557
|
+
*/
|
|
1326
1558
|
async function locateRule(group, name) {
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1559
|
+
if (group !== '') {
|
|
1560
|
+
const bundleDir = join(rulesRoot, group, name);
|
|
1561
|
+
for (const candidate of [bundleDocName(name), LEGACY_BUNDLE_DOC]) {
|
|
1562
|
+
const bundleDoc = join(bundleDir, candidate);
|
|
1563
|
+
try {
|
|
1564
|
+
const st = await lstat(bundleDoc);
|
|
1565
|
+
if (st.isFile() && !st.isSymbolicLink()) {
|
|
1566
|
+
return { id: group ? `${group}/${name}` : name, group, name, kind: 'bundle', docPath: bundleDoc, entryPath: bundleDir };
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
catch { /* 该候选名不存在 → 试下一个 */ }
|
|
1333
1570
|
}
|
|
1334
1571
|
}
|
|
1335
|
-
catch { /* 非 bundle */ }
|
|
1336
1572
|
const flatPath = join(rulesRoot, group, name + '.md');
|
|
1337
1573
|
try {
|
|
1338
1574
|
const st = await lstat(flatPath);
|
|
@@ -1407,7 +1643,6 @@ export function createRulesService(ctx, deps) {
|
|
|
1407
1643
|
const refresh = async () => {
|
|
1408
1644
|
invalidateSnapshot();
|
|
1409
1645
|
sceneCache = null;
|
|
1410
|
-
invalidateProviders();
|
|
1411
1646
|
};
|
|
1412
1647
|
/** 场景档案引擎专用:读-改-写 mode/archives/active 切片(写队列内,保持与其余索引写串行)。 */
|
|
1413
1648
|
const patchIndex = (patch) => enqueueMutation(async () => {
|
|
@@ -1467,7 +1702,16 @@ export function createRulesService(ctx, deps) {
|
|
|
1467
1702
|
const index = await readIndex(stateDir);
|
|
1468
1703
|
const rules = snap.rules
|
|
1469
1704
|
.filter((r) => !groupFilter || r.group === groupFilter)
|
|
1470
|
-
.sort((a, b) => a.group.localeCompare(b.group) || a.order - b.order || a.name.localeCompare(b.name))
|
|
1705
|
+
.sort((a, b) => a.group.localeCompare(b.group) || a.order - b.order || a.name.localeCompare(b.name))
|
|
1706
|
+
// bundle 记忆附带附件摘要(用户裁定 2026-09-17):界面要显示「几个附件」。
|
|
1707
|
+
// 每条 bundle 一次 readdirSync + 每个附件一次 statSync —— bundle 记忆通常只有几条,
|
|
1708
|
+
// 这个开销可以忽略;flat 没有目录,直接原样返回(不加字段 = 界面不显示附件标签)。
|
|
1709
|
+
.map((r) => {
|
|
1710
|
+
if (r.form !== 'bundle')
|
|
1711
|
+
return r;
|
|
1712
|
+
const attach = attachmentSummarySync(dirname(r.path), r.name);
|
|
1713
|
+
return attach ? { ...r, attach } : r;
|
|
1714
|
+
});
|
|
1471
1715
|
const groups = groupFilter ? snap.groups.filter((g) => g.name === groupFilter) : snap.groups;
|
|
1472
1716
|
const scenes = sceneRows(snap, index);
|
|
1473
1717
|
// 场景锁定状态(v0.8):任意一个场景锁定 = 五个管理域整体冻结。界面据此禁用各页的写控件。
|
|
@@ -1488,8 +1732,11 @@ export function createRulesService(ctx, deps) {
|
|
|
1488
1732
|
activeScene: enabledSceneOf(index),
|
|
1489
1733
|
sceneMemory: { usedBytes: projection.bytes, maxBytes: projection.maxBytes, truncated: projection.truncated, dropped: projection.dropped },
|
|
1490
1734
|
// 当前生效的场景提示词(绑定的预设正文;与 ~/.dsh/AGENTS.md 一致时不重复注入)。
|
|
1735
|
+
// `label` 是给用户指路用的显示名(「去场景设置里改『全局』的绑定」)——`global`
|
|
1736
|
+
// 这类磁盘名直接甩给用户看没有意义。
|
|
1491
1737
|
scenePrompt: {
|
|
1492
1738
|
scene: promptProjection.scene,
|
|
1739
|
+
label: promptProjection.scene ? sceneLabel(promptProjection.scene, index) : null,
|
|
1493
1740
|
presetId: promptProjection.presetId,
|
|
1494
1741
|
missing: promptProjection.missing,
|
|
1495
1742
|
duplicate: promptProjection.duplicate === true,
|
|
@@ -1525,7 +1772,7 @@ export function createRulesService(ctx, deps) {
|
|
|
1525
1772
|
rule: {
|
|
1526
1773
|
...rule,
|
|
1527
1774
|
body: doc.body,
|
|
1528
|
-
attachments: entry.kind === 'bundle' ? await listAttachments(entry.entryPath) : [],
|
|
1775
|
+
attachments: entry.kind === 'bundle' ? await listAttachments(entry.entryPath, entry.name) : [],
|
|
1529
1776
|
frontmatter: {
|
|
1530
1777
|
name: doc.map.name != null ? String(doc.map.name) : undefined,
|
|
1531
1778
|
description: doc.map.description != null ? String(doc.map.description) : undefined,
|
|
@@ -1648,9 +1895,11 @@ export function createRulesService(ctx, deps) {
|
|
|
1648
1895
|
if (!isValidGroupSegment(name))
|
|
1649
1896
|
return fail('error.rules.invalidName', `记忆名非法:${name}(非空、≤${MAX_GROUP_SEGMENT_LENGTH} 字符、不含 / \\ < > : " | ? *、不以 . 开头)`);
|
|
1650
1897
|
// 目标已存在(bundle 或 flat 皆算)→ 拒绝,避免静默覆盖。
|
|
1898
|
+
// 用 exists 而不是 shadowed:后者说的是「同名 bundle 把 flat 遮蔽了」,用户看到
|
|
1899
|
+
// 「被同名 bundle 遮蔽」会去找一个并不存在的 bundle(2026-09-16 实测)。
|
|
1651
1900
|
const existing = await locateRule(group, name);
|
|
1652
1901
|
if (existing) {
|
|
1653
|
-
return fail('error.rules.
|
|
1902
|
+
return fail('error.rules.exists', `同名记忆已存在(${existing.kind === 'bundle' ? 'bundle' : 'flat'}):${existing.id}`);
|
|
1654
1903
|
}
|
|
1655
1904
|
const body = String((args && args.body) ?? '');
|
|
1656
1905
|
if (body.trim() === '')
|
|
@@ -1679,7 +1928,7 @@ export function createRulesService(ctx, deps) {
|
|
|
1679
1928
|
const text = serializeRuleFile(fields, body);
|
|
1680
1929
|
if (form === 'bundle') {
|
|
1681
1930
|
await mkdir(join(rulesRoot, targetGroup, name), { recursive: true });
|
|
1682
|
-
await writeFileAtomically(join(rulesRoot, targetGroup, name,
|
|
1931
|
+
await writeFileAtomically(join(rulesRoot, targetGroup, name, bundleDocName(name)), text);
|
|
1683
1932
|
}
|
|
1684
1933
|
else {
|
|
1685
1934
|
await mkdir(join(rulesRoot, targetGroup), { recursive: true });
|
|
@@ -1696,7 +1945,6 @@ export function createRulesService(ctx, deps) {
|
|
|
1696
1945
|
index.groups[targetGroup] = { order: DEFAULT_GROUP_ORDER, label: targetGroup };
|
|
1697
1946
|
await writeIndex(stateDir, index);
|
|
1698
1947
|
invalidateSnapshot();
|
|
1699
|
-
invalidateProviders();
|
|
1700
1948
|
return { ok: true, rule: await buildProjected(createdId) };
|
|
1701
1949
|
}
|
|
1702
1950
|
/**
|
|
@@ -1781,10 +2029,10 @@ export function createRulesService(ctx, deps) {
|
|
|
1781
2029
|
const id = `${item.group}/${item.name}`;
|
|
1782
2030
|
try {
|
|
1783
2031
|
if (item.kind === 'bundle') {
|
|
1784
|
-
// 与 rules-create 同落点:bundle = <场景>/<名>/ 目录,正文
|
|
2032
|
+
// 与 rules-create 同落点:bundle = <场景>/<名>/ 目录,正文 <名>.md,附件平铺同层。
|
|
1785
2033
|
const bundleDir = join(rulesRoot, item.group, item.name);
|
|
1786
2034
|
await mkdir(bundleDir, { recursive: true });
|
|
1787
|
-
await writeFileAtomically(join(bundleDir,
|
|
2035
|
+
await writeFileAtomically(join(bundleDir, bundleDocName(item.name)), item.text);
|
|
1788
2036
|
for (const att of item.attachments)
|
|
1789
2037
|
await writeFileAtomicBinary(join(bundleDir, att.name), att.data);
|
|
1790
2038
|
}
|
|
@@ -1887,7 +2135,7 @@ export function createRulesService(ctx, deps) {
|
|
|
1887
2135
|
}
|
|
1888
2136
|
else if (located.kind === 'flat' && newForm === 'bundle') {
|
|
1889
2137
|
await mkdir(join(rulesRoot, parts.group, newName), { recursive: true });
|
|
1890
|
-
await writeFileAtomically(join(rulesRoot, parts.group, newName,
|
|
2138
|
+
await writeFileAtomically(join(rulesRoot, parts.group, newName, bundleDocName(newName)), serialize());
|
|
1891
2139
|
await rm(join(rulesRoot, parts.group, parts.name + '.md'), { force: true });
|
|
1892
2140
|
}
|
|
1893
2141
|
else if (located.kind === 'flat' && newName !== parts.name) {
|
|
@@ -1911,7 +2159,6 @@ export function createRulesService(ctx, deps) {
|
|
|
1911
2159
|
}
|
|
1912
2160
|
await writeIndex(stateDir, index);
|
|
1913
2161
|
invalidateSnapshot();
|
|
1914
|
-
invalidateProviders();
|
|
1915
2162
|
return { ok: true, rule: await buildProjected(newId) };
|
|
1916
2163
|
}
|
|
1917
2164
|
async function rulesRemove(args) {
|
|
@@ -1923,7 +2170,7 @@ export function createRulesService(ctx, deps) {
|
|
|
1923
2170
|
if (!located)
|
|
1924
2171
|
return fail('error.rules.notFound', `规则不存在:${id}`);
|
|
1925
2172
|
const trashId = Date.now().toString(36) + '-' + randomUUID().slice(0, 8);
|
|
1926
|
-
const trashDir = join(stateDir,
|
|
2173
|
+
const trashDir = join(stateDir, MEMORIES_TRASH_DIR, trashId);
|
|
1927
2174
|
const manifest = { group: parts.group, name: parts.name, form: located.kind, deletedAt: new Date().toISOString() };
|
|
1928
2175
|
await mkdir(trashDir, { recursive: true });
|
|
1929
2176
|
if (located.kind === 'bundle') {
|
|
@@ -1940,14 +2187,13 @@ export function createRulesService(ctx, deps) {
|
|
|
1940
2187
|
delete index.rules[id];
|
|
1941
2188
|
await writeIndex(stateDir, index);
|
|
1942
2189
|
invalidateSnapshot();
|
|
1943
|
-
invalidateProviders();
|
|
1944
2190
|
return { ok: true, trashId };
|
|
1945
2191
|
}
|
|
1946
2192
|
async function rulesRestore(args) {
|
|
1947
2193
|
const trashId = String((args && args.trashId) || '');
|
|
1948
2194
|
if (!isValidTrashId(trashId))
|
|
1949
2195
|
return fail('error.rules.notFound', `回收站条目不存在:${trashId}`);
|
|
1950
|
-
const trashDir = join(stateDir,
|
|
2196
|
+
const trashDir = join(stateDir, MEMORIES_TRASH_DIR, trashId);
|
|
1951
2197
|
let manifest;
|
|
1952
2198
|
try {
|
|
1953
2199
|
manifest = JSON.parse(await readFile(join(trashDir, 'manifest.json'), 'utf8'));
|
|
@@ -1961,7 +2207,7 @@ export function createRulesService(ctx, deps) {
|
|
|
1961
2207
|
// 恢复目标已存在(期间用户重建了同名规则)→ 拒绝,避免覆盖。
|
|
1962
2208
|
const conflict = await locateRule(manifest.group, manifest.name);
|
|
1963
2209
|
if (conflict) {
|
|
1964
|
-
return fail('error.rules.
|
|
2210
|
+
return fail('error.rules.exists', `同名记忆已存在:${conflict.id},请先移除后再恢复`);
|
|
1965
2211
|
}
|
|
1966
2212
|
const form = manifest.form === 'bundle' ? 'bundle' : 'flat';
|
|
1967
2213
|
if (form === 'bundle') {
|
|
@@ -1975,7 +2221,6 @@ export function createRulesService(ctx, deps) {
|
|
|
1975
2221
|
}
|
|
1976
2222
|
await rm(trashDir, { recursive: true, force: true });
|
|
1977
2223
|
invalidateSnapshot();
|
|
1978
|
-
invalidateProviders();
|
|
1979
2224
|
return { ok: true, rule: await buildProjected(manifest.group ? `${manifest.group}/${manifest.name}` : manifest.name) };
|
|
1980
2225
|
}
|
|
1981
2226
|
/** trashId 只由本插件生成(时间戳 base36 + uuid 前 8 位):严格白名单,杜绝路径穿越。 */
|
|
@@ -1983,11 +2228,11 @@ export function createRulesService(ctx, deps) {
|
|
|
1983
2228
|
return /^[a-z0-9]+-[a-z0-9]{1,32}$/i.test(trashId);
|
|
1984
2229
|
}
|
|
1985
2230
|
/**
|
|
1986
|
-
* 记忆回收站列表(只读):`<stateDir>/
|
|
2231
|
+
* 记忆回收站列表(只读):`<stateDir>/memories-trash/<trashId>/`(`rulesRemove` 移入,
|
|
1987
2232
|
* `rulesRestore` 恢复)。损坏或内容缺失的条目跳过,不让一个坏条目挡住整份列表。
|
|
1988
2233
|
*/
|
|
1989
2234
|
async function rulesTrashList() {
|
|
1990
|
-
const root = join(stateDir,
|
|
2235
|
+
const root = join(stateDir, MEMORIES_TRASH_DIR);
|
|
1991
2236
|
let ids = [];
|
|
1992
2237
|
try {
|
|
1993
2238
|
ids = await readdir(root);
|
|
@@ -2013,9 +2258,20 @@ export function createRulesService(ctx, deps) {
|
|
|
2013
2258
|
const form = manifest.form === 'bundle' ? 'bundle' : 'flat';
|
|
2014
2259
|
let bytes = 0;
|
|
2015
2260
|
try {
|
|
2016
|
-
|
|
2261
|
+
const bundleDoc = form === 'bundle'
|
|
2262
|
+
? join(dir, 'bundle', bundleDocName(manifest.name))
|
|
2263
|
+
: join(dir, 'rule.md');
|
|
2264
|
+
bytes = (await stat(bundleDoc)).size;
|
|
2265
|
+
}
|
|
2266
|
+
catch {
|
|
2267
|
+
// 兼容旧回收站条目(正文仍叫 SKILL.md);仍读不到就按 0 计,恢复时由 rulesRestore 兜底报错。
|
|
2268
|
+
if (form === 'bundle') {
|
|
2269
|
+
try {
|
|
2270
|
+
bytes = (await stat(join(dir, 'bundle', LEGACY_BUNDLE_DOC))).size;
|
|
2271
|
+
}
|
|
2272
|
+
catch { /* 内容缺失 */ }
|
|
2273
|
+
}
|
|
2017
2274
|
}
|
|
2018
|
-
catch { /* 内容缺失:仍列出,恢复时由 rulesRestore 兜底报错 */ }
|
|
2019
2275
|
entries.push({ trashId, group, name: manifest.name, form, deletedAt: String(manifest.deletedAt || ''), bytes });
|
|
2020
2276
|
}
|
|
2021
2277
|
catch { /* 损坏条目跳过 */ }
|
|
@@ -2028,7 +2284,7 @@ export function createRulesService(ctx, deps) {
|
|
|
2028
2284
|
const trashId = String((args && args.trashId) || '');
|
|
2029
2285
|
if (!isValidTrashId(trashId))
|
|
2030
2286
|
return fail('error.rules.notFound', `回收站条目不存在:${trashId}`);
|
|
2031
|
-
const dir = join(stateDir,
|
|
2287
|
+
const dir = join(stateDir, MEMORIES_TRASH_DIR, trashId);
|
|
2032
2288
|
try {
|
|
2033
2289
|
const st = await lstat(dir);
|
|
2034
2290
|
if (!st.isDirectory())
|
|
@@ -2040,8 +2296,8 @@ export function createRulesService(ctx, deps) {
|
|
|
2040
2296
|
await rm(dir, { recursive: true, force: true });
|
|
2041
2297
|
return { ok: true };
|
|
2042
2298
|
}
|
|
2043
|
-
/** bundle 目录下的附件(顶层普通文件,排除正文 SKILL.md
|
|
2044
|
-
async function listAttachments(bundleDir) {
|
|
2299
|
+
/** bundle 目录下的附件(顶层普通文件,排除正文 `<名>.md`,兼容旧数据 `SKILL.md`);不存在/不可读返回空数组。 */
|
|
2300
|
+
async function listAttachments(bundleDir, name) {
|
|
2045
2301
|
let entries;
|
|
2046
2302
|
try {
|
|
2047
2303
|
entries = await readdir(bundleDir, { withFileTypes: true });
|
|
@@ -2049,9 +2305,10 @@ export function createRulesService(ctx, deps) {
|
|
|
2049
2305
|
catch {
|
|
2050
2306
|
return [];
|
|
2051
2307
|
}
|
|
2308
|
+
const docNames = new Set([bundleDocName(name), LEGACY_BUNDLE_DOC]);
|
|
2052
2309
|
const out = [];
|
|
2053
2310
|
for (const entry of entries) {
|
|
2054
|
-
if (entry.name
|
|
2311
|
+
if (docNames.has(entry.name))
|
|
2055
2312
|
continue;
|
|
2056
2313
|
if (!entry.isFile())
|
|
2057
2314
|
continue; // 子目录 / 符号链接不列出(detach 也只动普通文件)
|
|
@@ -2085,10 +2342,13 @@ export function createRulesService(ctx, deps) {
|
|
|
2085
2342
|
return fail('error.rules.tooManyFiles', `一次最多 ${MAX_ATTACH_ENTRIES} 个附件`, { limit: MAX_ATTACH_ENTRIES });
|
|
2086
2343
|
const pending = [];
|
|
2087
2344
|
let total = 0;
|
|
2345
|
+
const docNames = new Set([bundleDocName(located.name), LEGACY_BUNDLE_DOC]);
|
|
2088
2346
|
for (const file of files) {
|
|
2089
2347
|
const name = String((file && (file.path || file.name)) || '');
|
|
2090
2348
|
if (!isValidGroupSegment(name))
|
|
2091
2349
|
return fail('error.rules.invalidName', `附件名非法:${name || '(空)'}`);
|
|
2350
|
+
if (docNames.has(name))
|
|
2351
|
+
return fail('error.rules.invalidName', `附件名不能与正文文件同名:${name}`);
|
|
2092
2352
|
const data = Buffer.from(String((file && file.data) || ''), 'base64');
|
|
2093
2353
|
if (data.length === 0)
|
|
2094
2354
|
return fail('error.rules.emptyFile', `附件内容为空:${name}`);
|
|
@@ -2101,9 +2361,9 @@ export function createRulesService(ctx, deps) {
|
|
|
2101
2361
|
}
|
|
2102
2362
|
for (const file of pending)
|
|
2103
2363
|
await writeFileAtomicBinary(join(located.entryPath, file.name), file.data);
|
|
2104
|
-
return { ok: true, attachments: await listAttachments(located.entryPath) };
|
|
2364
|
+
return { ok: true, attachments: await listAttachments(located.entryPath, located.name) };
|
|
2105
2365
|
}
|
|
2106
|
-
/** 删除 bundle 记忆的一个附件;正文 SKILL.md
|
|
2366
|
+
/** 删除 bundle 记忆的一个附件;正文 `<名>.md`(或旧数据 `SKILL.md`)不可删。 */
|
|
2107
2367
|
async function rulesDetach(args) {
|
|
2108
2368
|
const id = String((args && args.id) || '');
|
|
2109
2369
|
const parts = parseId(id);
|
|
@@ -2115,8 +2375,9 @@ export function createRulesService(ctx, deps) {
|
|
|
2115
2375
|
if (located.kind !== 'bundle')
|
|
2116
2376
|
return fail('error.rules.notBundle', `「${parts.name}」是 flat(单文件),没有附件`);
|
|
2117
2377
|
const name = String((args && args.name) || '');
|
|
2118
|
-
if (!isValidGroupSegment(name) || name ===
|
|
2378
|
+
if (!isValidGroupSegment(name) || name === bundleDocName(located.name) || name === LEGACY_BUNDLE_DOC) {
|
|
2119
2379
|
return fail('error.rules.invalidName', `附件名非法:${name || '(空)'}`);
|
|
2380
|
+
}
|
|
2120
2381
|
const target = join(located.entryPath, name);
|
|
2121
2382
|
try {
|
|
2122
2383
|
const st = await lstat(target);
|
|
@@ -2127,7 +2388,7 @@ export function createRulesService(ctx, deps) {
|
|
|
2127
2388
|
return fail('error.rules.notFound', `附件不存在:${name}`);
|
|
2128
2389
|
}
|
|
2129
2390
|
await rm(target, { force: true });
|
|
2130
|
-
return { ok: true, attachments: await listAttachments(located.entryPath) };
|
|
2391
|
+
return { ok: true, attachments: await listAttachments(located.entryPath, located.name) };
|
|
2131
2392
|
}
|
|
2132
2393
|
async function rulesToggle(args) {
|
|
2133
2394
|
const id = String((args && args.id) || '');
|
|
@@ -2147,7 +2408,6 @@ export function createRulesService(ctx, deps) {
|
|
|
2147
2408
|
index.rules[id] = { ...idxEntry, enabled, updatedAt: new Date().toISOString() };
|
|
2148
2409
|
await writeIndex(stateDir, index);
|
|
2149
2410
|
invalidateSnapshot();
|
|
2150
|
-
invalidateProviders();
|
|
2151
2411
|
return { ok: true, rule: await buildProjected(id) };
|
|
2152
2412
|
}
|
|
2153
2413
|
/**
|
|
@@ -2192,7 +2452,6 @@ export function createRulesService(ctx, deps) {
|
|
|
2192
2452
|
index.active = names;
|
|
2193
2453
|
await writeIndex(stateDir, index);
|
|
2194
2454
|
invalidateSnapshot();
|
|
2195
|
-
invalidateProviders();
|
|
2196
2455
|
const snap = await snapshot();
|
|
2197
2456
|
return {
|
|
2198
2457
|
ok: true,
|
|
@@ -2250,7 +2509,6 @@ export function createRulesService(ctx, deps) {
|
|
|
2250
2509
|
index.scenes[name] = next;
|
|
2251
2510
|
await writeIndex(stateDir, index);
|
|
2252
2511
|
invalidateSnapshot();
|
|
2253
|
-
invalidateProviders();
|
|
2254
2512
|
return { ok: true, scene: sceneRecordOf(name, next), ...(collapsed ? { collapsedActive: true } : {}) };
|
|
2255
2513
|
}
|
|
2256
2514
|
/**
|
|
@@ -2378,7 +2636,6 @@ export function createRulesService(ctx, deps) {
|
|
|
2378
2636
|
index.scenes[finalName] = next;
|
|
2379
2637
|
await writeIndex(stateDir, index);
|
|
2380
2638
|
invalidateSnapshot();
|
|
2381
|
-
invalidateProviders();
|
|
2382
2639
|
return {
|
|
2383
2640
|
ok: true,
|
|
2384
2641
|
...(finalName === name ? {} : { renamedFrom: name }),
|
|
@@ -2503,7 +2760,6 @@ export function createRulesService(ctx, deps) {
|
|
|
2503
2760
|
if (dirty)
|
|
2504
2761
|
await writeIndex(stateDir, index);
|
|
2505
2762
|
invalidateSnapshot();
|
|
2506
|
-
invalidateProviders();
|
|
2507
2763
|
return { ok: true, name, trashId: trashed.id, movedFiles: moves.length };
|
|
2508
2764
|
}
|
|
2509
2765
|
/** 场景回收站列表(只读):`<hub>/trash/scenes-trash/<id>/manifest.json`。 */
|
|
@@ -2562,7 +2818,6 @@ export function createRulesService(ctx, deps) {
|
|
|
2562
2818
|
await writeIndex(stateDir, index);
|
|
2563
2819
|
await purgeTrashEntry('scenes', id);
|
|
2564
2820
|
invalidateSnapshot();
|
|
2565
|
-
invalidateProviders();
|
|
2566
2821
|
return { ok: true, name, restoredFiles: restored.length };
|
|
2567
2822
|
}
|
|
2568
2823
|
/** 永久删除一条场景回收站条目。 */
|
|
@@ -2575,7 +2830,7 @@ export function createRulesService(ctx, deps) {
|
|
|
2575
2830
|
}
|
|
2576
2831
|
/**
|
|
2577
2832
|
* 提示词预设改名后**同步场景绑定**:把所有 `scenes[].prompt === from` 改成 `to`。
|
|
2578
|
-
* 由 AGENTS.md 预设库的改名路径调用——绑定存在 `
|
|
2833
|
+
* 由 AGENTS.md 预设库的改名路径调用——绑定存在 `memories-index.json` 里,预设库
|
|
2579
2834
|
* 自己看不到它,不叫这一声改名就会留下悬空绑定(场景卡片显示「预设不存在」)。
|
|
2580
2835
|
* @returns 改了几个场景。
|
|
2581
2836
|
*/
|
|
@@ -2597,7 +2852,6 @@ export function createRulesService(ctx, deps) {
|
|
|
2597
2852
|
index.scenes = scenes;
|
|
2598
2853
|
await writeIndex(stateDir, index);
|
|
2599
2854
|
invalidateSnapshot();
|
|
2600
|
-
invalidateProviders();
|
|
2601
2855
|
}
|
|
2602
2856
|
return { ok: true, from, to, changed };
|
|
2603
2857
|
}
|
|
@@ -2625,33 +2879,40 @@ export function createRulesService(ctx, deps) {
|
|
|
2625
2879
|
index.rules[id] = next;
|
|
2626
2880
|
await writeIndex(stateDir, index);
|
|
2627
2881
|
invalidateSnapshot();
|
|
2628
|
-
invalidateProviders();
|
|
2629
2882
|
return { ok: true, rule: await buildProjected(id) };
|
|
2630
2883
|
}
|
|
2631
2884
|
// ── 组装 ─────────────────────────────────────────────────────────────────
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2885
|
+
// 注入文本出口(2026-09-16 第四版)。
|
|
2886
|
+
//
|
|
2887
|
+
// 历史:本服务先后把场景记忆做成"系统提示词段"(会被 persona complete 压掉)与
|
|
2888
|
+
// "写 AGENTS.md 文件"(预设不挂 dsh-agent-instructions 时到不了模型)。现在两条都交给
|
|
2889
|
+
// 注入通道(src/context-inject.ts):每个 step 前把文本并进一条注入消息,任何预设都到得了。
|
|
2890
|
+
// 这里只留**同步取文本**的两个出口,注册/生命周期由 index.ts 的注入器统一管。
|
|
2891
|
+
//
|
|
2892
|
+
// - `memoryText`:场景记忆段(记忆正文;`''` = 没有可注入的内容)。
|
|
2893
|
+
// - `promptText`:全局提示词正文 = 场景期间用场景提示词、平时用 AGENTS.md 正文。
|
|
2894
|
+
// **不判"与文件重复"** —— 预设挂了官方 agent-instructions 时那份正文已经由文件送达
|
|
2895
|
+
// (注入侧会跳过整个域),没挂时(极简)才需要注入兜底;判定在注入器的 `selectInjections` 里。
|
|
2896
|
+
const memoryText = () => sceneMemory().text;
|
|
2897
|
+
/**
|
|
2898
|
+
* `~/.dsh/AGENTS.md` 正文。上限与官方那一行的 `maxBytes` 同量级(64 KiB):超大文件按字节
|
|
2899
|
+
* 截断并留一行标记,免得一份手写的巨型基线把上下文撑爆。
|
|
2900
|
+
*/
|
|
2901
|
+
const AGENTS_MD_MAX_BYTES = 65536;
|
|
2902
|
+
const agentsMdText = () => {
|
|
2903
|
+
const text = readGlobalAgentsMdSync();
|
|
2904
|
+
if (text.trim() === '')
|
|
2905
|
+
return '';
|
|
2906
|
+
if (Buffer.byteLength(text, 'utf8') <= AGENTS_MD_MAX_BYTES)
|
|
2907
|
+
return text;
|
|
2908
|
+
const cut = Buffer.from(text, 'utf8').subarray(0, AGENTS_MD_MAX_BYTES).toString('utf8');
|
|
2909
|
+
return cut + `\n\n(…文件超过 ${Math.floor(AGENTS_MD_MAX_BYTES / 1024)} KiB,已截断。)`;
|
|
2910
|
+
};
|
|
2911
|
+
// 场景提示词与 AGENTS.md 正文在用户眼里就是同一件事("全局提示词"):场景期间前者取代后者,
|
|
2912
|
+
// 与官方语义一致(进场景改写文件、退出恢复)。所以一个域、一份文本,取到非空的场景提示词就用它。
|
|
2913
|
+
const promptText = () => {
|
|
2914
|
+
const scene = resolveScenePreset()?.text ?? '';
|
|
2915
|
+
return scene.trim() !== '' ? scene : agentsMdText();
|
|
2655
2916
|
};
|
|
2656
2917
|
/** 写操作:串行队列内执行,成功后触发 refresh(失效缓存,下一请求即生效)。 */
|
|
2657
2918
|
const runWrite = (task) => enqueueMutation(async () => {
|
|
@@ -2700,7 +2961,8 @@ export function createRulesService(ctx, deps) {
|
|
|
2700
2961
|
const service = {
|
|
2701
2962
|
ops,
|
|
2702
2963
|
writeOps,
|
|
2703
|
-
|
|
2964
|
+
memoryText,
|
|
2965
|
+
promptText,
|
|
2704
2966
|
refresh,
|
|
2705
2967
|
patchIndex,
|
|
2706
2968
|
readArchiveSlice,
|