dsh-plugin-tool-management 0.9.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +119 -1
- package/README.md +227 -201
- package/README_EN.md +227 -199
- package/docs/images/1-EN.png +0 -0
- package/docs/images/1.png +0 -0
- package/docs/images/2-EN.png +0 -0
- package/docs/images/2.png +0 -0
- package/docs/images/3-EN.png +0 -0
- package/docs/images/3.png +0 -0
- package/docs/images/4-EN.png +0 -0
- package/docs/images/4.png +0 -0
- package/docs/images/5-EN.png +0 -0
- package/docs/images/5.png +0 -0
- package/docs/images/6-EN.png +0 -0
- package/docs/images/6.png +0 -0
- package/docs/images/7-EN.png +0 -0
- package/docs/images/7.png +0 -0
- package/docs/images/8-EN.png +0 -0
- package/docs/images/8.png +0 -0
- package/docs/update.md +144 -12
- package/lib/client.js +8437 -5929
- package/lib/compat/preset-reach.js +1 -10
- package/lib/compat/probe.js +158 -25
- package/lib/context-inject.js +396 -53
- package/lib/host-names.js +12 -0
- package/lib/http-fence.js +35 -15
- package/lib/hub.js +31 -3
- package/lib/imports/parsers.js +15 -9
- package/lib/imports/upload.js +43 -4
- package/lib/index.js +940 -3765
- package/lib/mcp/loader-token.js +238 -0
- package/lib/mcp/manager.js +1681 -0
- package/lib/mcp/override-blocks.js +20 -9
- package/lib/mcp/patch-yaml.js +351 -0
- package/lib/mcp/secret-guard.js +145 -0
- package/lib/mcp/state-section.js +64 -21
- package/lib/{rules → memories}/archive-engine.js +65 -10
- package/lib/{rules → memories}/archive.js +6 -7
- package/lib/memories/constants.js +128 -0
- package/lib/memories/index-io.js +330 -0
- package/lib/memories/projection.js +280 -0
- package/lib/memories/service.js +686 -0
- package/lib/memories/snapshot.js +672 -0
- package/lib/ops/candidates.js +64 -0
- package/lib/ops/compat.js +136 -0
- package/lib/ops/ctx.js +9 -0
- package/lib/ops/memory.js +678 -0
- package/lib/ops/prompts.js +107 -0
- package/lib/ops/scene-records.js +460 -0
- package/lib/ops/scene-sync.js +17 -0
- package/lib/ops/sessions.js +603 -0
- package/lib/ops/trash.js +140 -0
- package/lib/paths.js +103 -0
- package/lib/prompts/preset-id.js +49 -0
- package/lib/{agents-md → prompts}/service.js +1 -1
- package/lib/request-gate.js +320 -0
- package/lib/scene-prompt-sync.js +4 -4
- package/lib/scenes/candidates.js +344 -0
- package/lib/{history → sessions}/bridge.js +22 -5
- package/lib/sessions/history.js +323 -0
- package/lib/{history → sessions}/tombstone.js +1 -2
- package/lib/{history → sessions}/workspace.js +92 -24
- package/lib/skills/catalog.js +6 -9
- package/lib/skills/core.js +79 -43
- package/lib/skills/readonly-discovery.js +4 -1
- package/lib/skills/service.js +98 -13
- package/lib/subagents/catalog.js +31 -15
- package/lib/subagents/service.js +506 -89
- package/lib/subagents/tools.js +29 -4
- package/lib/tools/deps.js +8 -0
- package/lib/tools/mcp.js +110 -0
- package/lib/tools/memory.js +87 -0
- package/lib/tools/prompt.js +70 -0
- package/lib/tools/skills.js +139 -0
- package/lib/tools/subagent.js +40 -0
- package/package.json +105 -102
- package/lib/agents-md/preset-id.js +0 -49
- package/lib/history/projcache.js +0 -335
- package/lib/rules/service.js +0 -2971
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// 规则/记忆域的**索引 IO**(2026-09-19 从 memories/service.ts 抽出)。
|
|
2
|
+
//
|
|
3
|
+
// memories-index.json 的解析、校验、原子落盘与损坏隔离都在这里。索引是「启停/排序/标签/
|
|
4
|
+
// 启用场景集合」的真源(记忆文件只负责正文),所以这一层必须 fail-closed:解析不出来就隔离
|
|
5
|
+
// 坏文件并回退默认索引,绝不让半截数据流进界面。
|
|
6
|
+
//
|
|
7
|
+
// 对 service.ts 只做 type-only 引用(`import type`),避免与它形成运行时循环依赖。
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import { readFileSync, renameSync, statSync } from 'node:fs';
|
|
10
|
+
import { lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
11
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
12
|
+
import { renameWithRetry } from '../skills/core.js';
|
|
13
|
+
import { normalizePresetId } from '../prompts/preset-id.js';
|
|
14
|
+
import { normalizeArchive } from './archive.js';
|
|
15
|
+
import { DEFAULT_GROUP_ORDER, GLOBAL_SCENE, GLOBAL_SCENE_LABEL, INDEX_VERSION, isValidGroupPath, MEMORIES_INDEX_FILE } from './constants.js';
|
|
16
|
+
import { normalizeActive } from './projection.js';
|
|
17
|
+
/**
|
|
18
|
+
* 同目录临时文件 + rename 原子写(临时名 `.xxx.dsh-rules-<uuid>.tmp`)。
|
|
19
|
+
* rename 走 `renameWithRetry`:Windows 上杀软/索引器会短暂占住目标文件报
|
|
20
|
+
* EPERM/EACCES/EBUSY(用户实测:进入/退出模式时 memories-index.json 被 rename 撞上,
|
|
21
|
+
* 运行时已切换但状态落盘失败,界面开关停在旧状态、还得再点一次)。这种占用是
|
|
22
|
+
* 瞬时的,重试几轮就能过去;全失败才清理临时文件并把错误抛出。
|
|
23
|
+
*/
|
|
24
|
+
export async function writeFileAtomically(path, content) {
|
|
25
|
+
const temp = join(dirname(path), `.${basename(path)}.dsh-rules-${randomUUID()}.tmp`);
|
|
26
|
+
try {
|
|
27
|
+
await writeFile(temp, content, 'utf8');
|
|
28
|
+
await renameWithRetry(temp, path);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
await rm(temp, { force: true }).catch(() => undefined);
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** 二进制版原子写(附件用):临时文件 + rename(同样带瞬时占用重试),失败清理临时文件。 */
|
|
36
|
+
export async function writeFileAtomicBinary(path, data) {
|
|
37
|
+
const temp = join(dirname(path), `.${basename(path)}.dsh-rules-${randomUUID()}.tmp`);
|
|
38
|
+
try {
|
|
39
|
+
await writeFile(temp, data);
|
|
40
|
+
await renameWithRetry(temp, path);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
await rm(temp, { force: true }).catch(() => undefined);
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// ── 递归发现 ───────────────────────────────────────────────────────────────
|
|
48
|
+
export const defaultIndex = () => ({ version: INDEX_VERSION, rules: {}, groups: {}, scenes: {}, active: null, archives: {}, mode: { scene: null, snapshot: null } });
|
|
49
|
+
/** 场景镜像条目归一化:只保留已知字段,非法值丢弃(容忍脏数据)。 */
|
|
50
|
+
export function parseSceneEntry(raw) {
|
|
51
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
52
|
+
return null;
|
|
53
|
+
const obj = raw;
|
|
54
|
+
const out = {};
|
|
55
|
+
if (typeof obj.label === 'string' && obj.label.trim() !== '')
|
|
56
|
+
out.label = obj.label;
|
|
57
|
+
if (typeof obj.description === 'string' && obj.description !== '')
|
|
58
|
+
out.description = obj.description;
|
|
59
|
+
if (typeof obj.prompt === 'string') {
|
|
60
|
+
const prompt = normalizeScenePromptId(obj.prompt);
|
|
61
|
+
if (prompt)
|
|
62
|
+
out.prompt = prompt;
|
|
63
|
+
}
|
|
64
|
+
if (typeof obj.order === 'number' && Number.isFinite(obj.order))
|
|
65
|
+
out.order = obj.order;
|
|
66
|
+
if (typeof obj.createdAt === 'string' && obj.createdAt !== '')
|
|
67
|
+
out.createdAt = obj.createdAt;
|
|
68
|
+
// 场景锁定(v0.8):读盘必须原样保留 —— 这里曾只重建已知字段,锁了也会被剥成未锁。
|
|
69
|
+
if (obj.locked === true)
|
|
70
|
+
out.locked = true;
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
export function parseScenes(raw) {
|
|
74
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
75
|
+
return {};
|
|
76
|
+
const out = {};
|
|
77
|
+
for (const [name, value] of Object.entries(raw)) {
|
|
78
|
+
if (!isValidGroupPath(name))
|
|
79
|
+
continue;
|
|
80
|
+
const entry = parseSceneEntry(value);
|
|
81
|
+
if (entry)
|
|
82
|
+
out[name] = entry;
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
/** 档案切片归一化:未知形态 → 空对象(容忍脏数据,与 §9.3 同哲学)。 */
|
|
87
|
+
export function parseArchives(raw) {
|
|
88
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
89
|
+
return {};
|
|
90
|
+
const out = {};
|
|
91
|
+
for (const [name, value] of Object.entries(raw)) {
|
|
92
|
+
const archive = normalizeArchive(value);
|
|
93
|
+
if (Object.keys(archive).length)
|
|
94
|
+
out[name] = archive;
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
export function parseModeState(raw) {
|
|
99
|
+
const obj = (raw && typeof raw === 'object' ? raw : {});
|
|
100
|
+
const scene = typeof obj.scene === 'string' && obj.scene.trim() !== '' ? obj.scene : null;
|
|
101
|
+
const snapshotRaw = (obj.snapshot && typeof obj.snapshot === 'object' ? obj.snapshot : {});
|
|
102
|
+
const toFlagMap = (v) => {
|
|
103
|
+
const out = {};
|
|
104
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
105
|
+
for (const [k, b] of Object.entries(v))
|
|
106
|
+
if (typeof b === 'boolean')
|
|
107
|
+
out[k] = b;
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
};
|
|
111
|
+
// v2 快照:mcp = 停用表原文(serverName → ['*'] / 工具名);skills = 启停布尔表。
|
|
112
|
+
const mcp = {};
|
|
113
|
+
const mcpRaw = snapshotRaw.mcp;
|
|
114
|
+
if (mcpRaw && typeof mcpRaw === 'object' && !Array.isArray(mcpRaw)) {
|
|
115
|
+
for (const [server, list] of Object.entries(mcpRaw)) {
|
|
116
|
+
if (Array.isArray(list))
|
|
117
|
+
mcp[server] = list.map((x) => String(x));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
else if (snapshotRaw.tools && typeof snapshotRaw.tools === 'object' && !Array.isArray(snapshotRaw.tools)) {
|
|
121
|
+
// v1 快照兼容(升级前数据):tools = 布尔启停表(key = `<server>/<tool>`,false = 停用)
|
|
122
|
+
// → 折算成 v2 的停用名单;否则退出模式会把「快照启停」错误还原成「全部启用」。
|
|
123
|
+
for (const [key, on] of Object.entries(snapshotRaw.tools)) {
|
|
124
|
+
if (on !== false)
|
|
125
|
+
continue;
|
|
126
|
+
const i = key.indexOf('/');
|
|
127
|
+
if (i <= 0 || i === key.length - 1)
|
|
128
|
+
continue;
|
|
129
|
+
const server = key.slice(0, i);
|
|
130
|
+
const tool = key.slice(i + 1);
|
|
131
|
+
const list = mcp[server] || (mcp[server] = []);
|
|
132
|
+
if (list.indexOf(tool) < 0)
|
|
133
|
+
list.push(tool);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// v2 快照的**服务器级 / 来源级**名单、v0.8 的子智能体名单、v0.9.1 的人设全量映射
|
|
137
|
+
// 必须原样透传 —— 这里曾把它们剥掉,结果退出模式时两张恢复名单全是空的:服务器级 MCP 与
|
|
138
|
+
// 技能来源永远不回滚(用户报的「MCP 不复原」「技能目录不回退」),只有工具级 / 技能级能还原。
|
|
139
|
+
const mcpServers = (Array.isArray(snapshotRaw.mcpServers) ? snapshotRaw.mcpServers : [])
|
|
140
|
+
.filter((x) => !!x && typeof x === 'object')
|
|
141
|
+
.map((x) => ({ id: String(x.id || ''), level: String(x.level || ''), disabled: x.disabled === true }))
|
|
142
|
+
.filter((x) => x.id !== '' && (x.level === 'global' || x.level === 'project'));
|
|
143
|
+
const skillSources = (Array.isArray(snapshotRaw.skillSources) ? snapshotRaw.skillSources : [])
|
|
144
|
+
.filter((x) => !!x && typeof x === 'object')
|
|
145
|
+
.map((x) => ({ root: String(x.root || ''), enabled: x.enabled === true }))
|
|
146
|
+
.filter((x) => x.root !== '');
|
|
147
|
+
const subagents = (Array.isArray(snapshotRaw.subagents) ? snapshotRaw.subagents : []).map((x) => String(x)).filter(Boolean);
|
|
148
|
+
// 同 subagents 一组:进入时被档案**关掉**的人设(退出要重新打开)。漏掉它 = 用户实测的
|
|
149
|
+
// 「进场景关掉了,退出却没开回来」——快照落盘后读回来就只剩「被启用」那一半名单。
|
|
150
|
+
const subagentsOn = (Array.isArray(snapshotRaw.subagentsOn) ? snapshotRaw.subagentsOn : []).map((x) => String(x)).filter(Boolean);
|
|
151
|
+
// v0.9.1 的人设**全量**开关映射(名字 → 进场景时是否开着):同样必须原样透传 ——
|
|
152
|
+
// 剥掉它,退出模式就只能退回上面两个部分名单,「场景里手动开过的人设」不再被还原。
|
|
153
|
+
const subagentsAll = toFlagMap(snapshotRaw.subagentsAll);
|
|
154
|
+
// v0.8.1 的场景备注恢复名单:**必须原样透传**——这里漏掉它,退出模式时备注永不回退
|
|
155
|
+
//(与历史上 mcpServers / skillSources 被剥掉是同一类 bug)。
|
|
156
|
+
const mcpNotes = (Array.isArray(snapshotRaw.mcpNotes) ? snapshotRaw.mcpNotes : [])
|
|
157
|
+
.filter((x) => !!x && typeof x === 'object')
|
|
158
|
+
.map((x) => ({ id: String(x.id || ''), note: typeof x.note === 'string' ? x.note : null }))
|
|
159
|
+
.filter((x) => x.id !== '');
|
|
160
|
+
return {
|
|
161
|
+
scene,
|
|
162
|
+
snapshot: scene
|
|
163
|
+
? {
|
|
164
|
+
mcp,
|
|
165
|
+
skills: toFlagMap(snapshotRaw.skills),
|
|
166
|
+
...(mcpServers.length ? { mcpServers } : {}),
|
|
167
|
+
...(skillSources.length ? { skillSources } : {}),
|
|
168
|
+
...(subagents.length ? { subagents } : {}),
|
|
169
|
+
...(subagentsOn.length ? { subagentsOn } : {}),
|
|
170
|
+
...(Object.keys(subagentsAll).length ? { subagentsAll } : {}),
|
|
171
|
+
...(mcpNotes.length ? { mcpNotes } : {}),
|
|
172
|
+
}
|
|
173
|
+
: null,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/** 解析 memories-index.json 原文;任何异常/版本不符 → 默认索引(容忍缺失,§9.3)。 */
|
|
177
|
+
export function parseIndex(raw) {
|
|
178
|
+
const parsed = JSON.parse(raw);
|
|
179
|
+
if (!parsed || parsed.version !== INDEX_VERSION || typeof parsed.rules !== 'object' || parsed.rules === null)
|
|
180
|
+
throw new Error('bad index');
|
|
181
|
+
return {
|
|
182
|
+
version: INDEX_VERSION,
|
|
183
|
+
rules: (parsed.rules || {}),
|
|
184
|
+
groups: (parsed.groups || {}),
|
|
185
|
+
scenes: parseScenes(parsed.scenes),
|
|
186
|
+
active: normalizeActive(parsed.active),
|
|
187
|
+
archives: parseArchives(parsed.archives),
|
|
188
|
+
mode: parseModeState(parsed.mode),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
// 索引损坏的进程级记录:stateDir → 损坏原文件被改名后的落点。
|
|
192
|
+
//
|
|
193
|
+
// 为什么需要它:原来 readIndex 的 catch 把「文件不存在」「读失败」「解析失败」三者合并成
|
|
194
|
+
// 「返回默认索引」,而索引是启停/排序/标签/场景描述/归档/模式快照的**唯一**真相源 ——
|
|
195
|
+
// 于是任意一次写操作都会把这份空索引落盘,一次解析失败就静默换掉用户全部配置,且没有
|
|
196
|
+
// 任何提示;模式快照丢了之后,运行时 MCP/技能开关再也无法回滚。
|
|
197
|
+
// 触发不限于文件损坏:parseIndex 对字段类型严格,跨版本降级读到新版结构同样会抛。
|
|
198
|
+
//
|
|
199
|
+
// 现在的口径分开处理两条路:
|
|
200
|
+
// - 读侧降级 —— 注入路径(readIndexSync)不能抛,返回默认索引,界面表现为「记忆都没了」,
|
|
201
|
+
// 是可见症状而不是静默改写;
|
|
202
|
+
// - 写侧 fail-closed —— 拒绝落盘并把原因回给界面,用户的数据一个字节不动。
|
|
203
|
+
export const corruptIndexes = new Map();
|
|
204
|
+
/** 把损坏的索引改名留存并记录该 stateDir 已损坏(幂等,只记一次)。 */
|
|
205
|
+
export function quarantineIndex(stateDir) {
|
|
206
|
+
const key = resolve(stateDir);
|
|
207
|
+
if (corruptIndexes.has(key))
|
|
208
|
+
return;
|
|
209
|
+
const abs = join(stateDir, MEMORIES_INDEX_FILE);
|
|
210
|
+
const kept = `${abs}.corrupt-${Date.now()}`;
|
|
211
|
+
try {
|
|
212
|
+
renameSync(abs, kept);
|
|
213
|
+
corruptIndexes.set(key, kept);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
// 改名失败(被占用/权限)不改判定:仍然标记损坏并拒绝写,只是没有留存副本。
|
|
217
|
+
corruptIndexes.set(key, abs);
|
|
218
|
+
}
|
|
219
|
+
console.warn(`[dsh-plugin-tool-management] 记忆索引解析失败,已停止写入以免覆盖你的数据。`
|
|
220
|
+
+ `损坏的原文件已留存为 ${kept} —— 请检查它(或删除后重启 DSH)再继续操作。`);
|
|
221
|
+
}
|
|
222
|
+
/** 该 stateDir 的索引是否已判定损坏(写侧拒绝落盘,读侧按「全部未启用」处理)。 */
|
|
223
|
+
export function isIndexQuarantined(stateDir) {
|
|
224
|
+
return corruptIndexes.has(resolve(stateDir));
|
|
225
|
+
}
|
|
226
|
+
/** 解析索引;失败即隔离该文件并返回默认索引(写侧由 writeIndex 拦截)。 */
|
|
227
|
+
export function parseOrQuarantine(stateDir, raw) {
|
|
228
|
+
try {
|
|
229
|
+
return parseIndex(raw);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
quarantineIndex(stateDir);
|
|
233
|
+
return defaultIndex();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
export async function readIndex(stateDir) {
|
|
237
|
+
let raw;
|
|
238
|
+
try {
|
|
239
|
+
raw = await readFile(join(stateDir, MEMORIES_INDEX_FILE), 'utf8');
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
// 文件不存在 = 全新用户;其余 IO 错误也按「没有索引」处理(与既有语义一致)。
|
|
243
|
+
return defaultIndex();
|
|
244
|
+
}
|
|
245
|
+
return parseOrQuarantine(stateDir, raw);
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* 同步读索引:注入文本的渲染路径不能 await(见文件内「两相扫描」注释)。
|
|
249
|
+
*
|
|
250
|
+
* 带 `mtimeMs:size` 指纹缓存:一次装配里这个函数会被调到 2 次以上,每个模型 step 都重读
|
|
251
|
+
* 重解一遍整份索引没有意义。指纹**必须**在 —— 索引可能被别的 DSH 实例或用户手工改动,
|
|
252
|
+
* 只按自身写入失效会让插件一直用旧值(表现为「记忆停不掉」)。stat 失败(文件被删 / 被
|
|
253
|
+
* 隔离改名)即丢缓存。同款手法见 `readPresetTextSync` 与 subagents 的 enabledStamp。
|
|
254
|
+
*
|
|
255
|
+
* ⚠️ 返回的可能是**缓存实例**,调用方只许读;写索引一律走异步 `readIndex`(它每次重新解析,
|
|
256
|
+
* 拿到新对象)。当前两个调用方(`sceneMemory` / `resolveScenePreset`)及其下游
|
|
257
|
+
* (`signatureOfIndex` / `resolveActiveScenes` / `sceneHeader` / `compareSceneBuckets` /
|
|
258
|
+
* `sceneOrderOf`)都已确认只读 —— 新增调用方请保持这条。
|
|
259
|
+
*/
|
|
260
|
+
export const indexSyncCache = new Map();
|
|
261
|
+
export function readIndexSync(stateDir) {
|
|
262
|
+
const file = join(stateDir, MEMORIES_INDEX_FILE);
|
|
263
|
+
let key;
|
|
264
|
+
try {
|
|
265
|
+
const st = statSync(file);
|
|
266
|
+
key = `${st.mtimeMs}:${st.size}`;
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
// 文件不存在 = 全新用户;其余 IO 错误也按「没有索引」处理(与 readIndex 一致)。
|
|
270
|
+
indexSyncCache.delete(stateDir);
|
|
271
|
+
return defaultIndex();
|
|
272
|
+
}
|
|
273
|
+
const cached = indexSyncCache.get(stateDir);
|
|
274
|
+
if (cached && cached.key === key)
|
|
275
|
+
return cached.value;
|
|
276
|
+
const raw = readFileIfExistsSync(file);
|
|
277
|
+
const value = raw === null ? defaultIndex() : parseOrQuarantine(stateDir, raw);
|
|
278
|
+
indexSyncCache.set(stateDir, { key, value });
|
|
279
|
+
return value;
|
|
280
|
+
}
|
|
281
|
+
export async function writeIndex(stateDir, index) {
|
|
282
|
+
// 索引损坏时拒绝写入:这一次写会把空索引落盘,等于用一次解析失败换掉用户全部配置。
|
|
283
|
+
const kept = corruptIndexes.get(resolve(stateDir));
|
|
284
|
+
if (kept !== undefined) {
|
|
285
|
+
throw new Error(`记忆索引文件损坏,已拒绝写入以免覆盖你的数据(原文件留存为 ${kept})。`
|
|
286
|
+
+ `请检查或删除该损坏文件后重启 DSH,再重试本次操作。`);
|
|
287
|
+
}
|
|
288
|
+
await mkdir(stateDir, { recursive: true });
|
|
289
|
+
await writeFileAtomically(join(stateDir, MEMORIES_INDEX_FILE), JSON.stringify(index, null, 2));
|
|
290
|
+
}
|
|
291
|
+
// ── 场景记录(索引内,`memories-index.json` 的 scenes 切片)+ 旧布局迁移 ────────
|
|
292
|
+
/** 索引条目 → 场景记录(供 UI 直接渲染)。 */
|
|
293
|
+
export function sceneRecordOf(name, entry) {
|
|
294
|
+
const e = entry || {};
|
|
295
|
+
return {
|
|
296
|
+
name,
|
|
297
|
+
...(name === GLOBAL_SCENE ? { label: e.label || GLOBAL_SCENE_LABEL } : (e.label ? { label: e.label } : {})),
|
|
298
|
+
...(e.description ? { description: e.description } : {}),
|
|
299
|
+
...(e.prompt ? { prompt: e.prompt } : {}),
|
|
300
|
+
order: e.order ?? (name === GLOBAL_SCENE ? 0 : DEFAULT_GROUP_ORDER),
|
|
301
|
+
...(e.createdAt ? { createdAt: e.createdAt } : {}),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
/** 提示词预设 id 的口径与提示词预设服务**同源**(用户裁定:id 什么都能写)。 */
|
|
305
|
+
/** 校验场景要绑定的提示词预设 id;返回 `''` 表示解绑,`null` 表示非法。 */
|
|
306
|
+
export function normalizeScenePromptId(value) {
|
|
307
|
+
const id = String(value ?? '').trim();
|
|
308
|
+
if (id === '')
|
|
309
|
+
return '';
|
|
310
|
+
const result = normalizePresetId(id);
|
|
311
|
+
return result.ok ? result.id : null;
|
|
312
|
+
}
|
|
313
|
+
/** 路径是否存在(不跟随符号链接;用于迁移前置判断)。 */
|
|
314
|
+
export async function pathExists(path) {
|
|
315
|
+
try {
|
|
316
|
+
await lstat(path);
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
export function readFileIfExistsSync(path) {
|
|
324
|
+
try {
|
|
325
|
+
return readFileSync(path, 'utf8');
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// 规则/记忆域的**投影与渲染**纯函数(2026-09-19 从 memories/service.ts 抽出)。
|
|
2
|
+
//
|
|
3
|
+
// 界定:这里只放「给定索引 + 已发现的条目,算出界面/注入看到的那份结果」的函数 ——
|
|
4
|
+
// 不读索引文件、不改索引、不碰服务状态。快照构建(buildSnapshot)与 op 实现留在 service.ts。
|
|
5
|
+
//
|
|
6
|
+
// 为什么抽出来:service.ts 里这些函数夹在「索引读写」与「24 个 op」之间,而它们其实是
|
|
7
|
+
// 另一件事——把磁盘事实翻译成模型与用户看到的文本。分开之后改渲染不必在 op 之间穿行。
|
|
8
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { ATTACHMENT_LIST_MAX, bundleDocName, DEFAULT_GROUP_ORDER, DEFAULT_ORDER, GLOBAL_SCENE, GLOBAL_SCENE_LABEL, INLINE_BODY_MAX, LEGACY_BUNDLE_DOC, SHARED_GROUP, } from './constants.js';
|
|
11
|
+
// ── 索引归一化与场景解析 ───────────────────────────────────────────────────
|
|
12
|
+
/** 启用场景集合归一化:非数组 → null(= 全部启用);数组 → 去重后的字符串数组。 */
|
|
13
|
+
export function normalizeActive(raw) {
|
|
14
|
+
if (!Array.isArray(raw))
|
|
15
|
+
return null;
|
|
16
|
+
const out = [];
|
|
17
|
+
const seen = new Set();
|
|
18
|
+
for (const item of raw) {
|
|
19
|
+
if (typeof item !== 'string')
|
|
20
|
+
continue;
|
|
21
|
+
const name = item.trim();
|
|
22
|
+
if (name === '' || seen.has(name))
|
|
23
|
+
continue;
|
|
24
|
+
seen.add(name);
|
|
25
|
+
out.push(name);
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
export function ensureSceneRecords(index, probeScenes) {
|
|
30
|
+
if (!index.scenes)
|
|
31
|
+
index.scenes = {};
|
|
32
|
+
let dirty = false;
|
|
33
|
+
if (!index.scenes[GLOBAL_SCENE]) {
|
|
34
|
+
index.scenes[GLOBAL_SCENE] = { label: GLOBAL_SCENE_LABEL, order: 0 };
|
|
35
|
+
dirty = true;
|
|
36
|
+
}
|
|
37
|
+
// 有目录但没记录(旧布局搬进来的、或用户手工建的目录)→ 补记录,label 用目录名。
|
|
38
|
+
for (const name of probeScenes) {
|
|
39
|
+
if (index.scenes[name])
|
|
40
|
+
continue;
|
|
41
|
+
index.scenes[name] = { order: DEFAULT_GROUP_ORDER };
|
|
42
|
+
dirty = true;
|
|
43
|
+
}
|
|
44
|
+
return dirty;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* 活动场景解析(用户裁定 2026-09-15:**除「全局」外同时只能启用一个场景**):
|
|
48
|
+
* - `index.active` 为数组(新写入的唯一形态)→ 至多一个非保留场景启用;
|
|
49
|
+
* - `index.active` 缺失 / null → 历史默认("全部场景启用");**不再产生**新值,
|
|
50
|
+
* 新建场景时会被收敛成显式数组(见 `collapseActiveForNewScene`),
|
|
51
|
+
* 因此"新建即启用"不会发生;存量数据仍按老语义读,避免升级后注入范围突变。
|
|
52
|
+
* - `_shared` 恒常启用(公共基线),不受开关影响
|
|
53
|
+
* - 保留场景 `global` 恒常启用:它的记忆对任何对话都成立
|
|
54
|
+
* 缺失 memories-index.json 一律按默认值运行,不抛错(§9.3)。
|
|
55
|
+
* 场景生效与否只由 index.active 决定(场景档案/模式也写这一份)。
|
|
56
|
+
*/
|
|
57
|
+
export function resolveActiveScenes(index, knownScenes) {
|
|
58
|
+
const stored = normalizeActive(index.active);
|
|
59
|
+
const mode = stored === null ? 'all' : 'custom';
|
|
60
|
+
const active = new Set(stored === null ? knownScenes : stored);
|
|
61
|
+
active.add(SHARED_GROUP);
|
|
62
|
+
active.add(GLOBAL_SCENE);
|
|
63
|
+
return { active, mode };
|
|
64
|
+
}
|
|
65
|
+
/** 当前启用的**非保留**场景(单选模型下至多一个;多个时按场景顺序取第一个)。 */
|
|
66
|
+
export function enabledSceneOf(index) {
|
|
67
|
+
const names = Object.keys(index.scenes || {});
|
|
68
|
+
const { active } = resolveActiveScenes(index, names);
|
|
69
|
+
const enabled = names
|
|
70
|
+
.filter((n) => n !== SHARED_GROUP && n !== GLOBAL_SCENE && active.has(n))
|
|
71
|
+
.sort((a, b) => sceneOrderOf(index, a) - sceneOrderOf(index, b) || a.localeCompare(b));
|
|
72
|
+
return enabled[0] ?? null;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* 新建场景前把历史默认(`active = null` = 全部启用)收敛成显式数组,保证
|
|
76
|
+
* **新场景默认不启动**、且收敛后仍满足"至多一个非保留场景启用":
|
|
77
|
+
* - 已存在其它非保留场景 → 取顺序第一个作为启用场景(其余收敛掉,返回 `collapsed: true`)
|
|
78
|
+
* - 不存在 → 空数组(什么都不启用)
|
|
79
|
+
* @returns 是否发生了收敛(供 UI 如实提示)。
|
|
80
|
+
*/
|
|
81
|
+
export function collapseActiveForNewScene(index) {
|
|
82
|
+
if (normalizeActive(index.active) !== null)
|
|
83
|
+
return false;
|
|
84
|
+
const names = Object.keys(index.scenes || {}).filter((n) => n !== SHARED_GROUP && n !== GLOBAL_SCENE);
|
|
85
|
+
const first = names.sort((a, b) => sceneOrderOf(index, a) - sceneOrderOf(index, b) || a.localeCompare(b))[0];
|
|
86
|
+
index.active = first === undefined ? [] : [first];
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
/** 场景名 = 分组路径的第一段(`web/frontend` 属于场景 `web`)。 */
|
|
90
|
+
export function sceneOf(group) {
|
|
91
|
+
const idx = String(group || '').indexOf('/');
|
|
92
|
+
return idx >= 0 ? group.slice(0, idx) : group;
|
|
93
|
+
}
|
|
94
|
+
// ── 指纹 ───────────────────────────────────────────────────────────────────
|
|
95
|
+
/** 指纹里必须包含一切影响渲染的索引字段(active / enabled / order / groups.order / scenes.order / label / description)。 */
|
|
96
|
+
export function signatureOfIndex(index) {
|
|
97
|
+
const active = normalizeActive(index.active);
|
|
98
|
+
const rules = Object.keys(index.rules).sort().map((id) => {
|
|
99
|
+
const e = index.rules[id];
|
|
100
|
+
return `${id}\u0000${e.enabled === false ? '0' : '1'}\u0000${e.order ?? DEFAULT_ORDER}`;
|
|
101
|
+
});
|
|
102
|
+
const groups = Object.keys(index.groups).sort().map((g) => `${g}\u0000${index.groups[g]?.order ?? DEFAULT_GROUP_ORDER}`);
|
|
103
|
+
// 场景顺序决定段内场景的先后 → 必须进指纹,否则改顺序后段文本不会重算。
|
|
104
|
+
// label 与 description 同理(sceneHeader 的「场景说明」一行直接渲染 description)——
|
|
105
|
+
// 手改索引文件(带外变更)时只有指纹变化才会触发重算。
|
|
106
|
+
const scenes = Object.keys(index.scenes || {}).sort().map((s) => {
|
|
107
|
+
const e = index.scenes[s];
|
|
108
|
+
return `${s}\u0000${e.order ?? (s === GLOBAL_SCENE ? 0 : DEFAULT_GROUP_ORDER)}\u0000${e.label ?? ''}\u0000${e.description ?? ''}`;
|
|
109
|
+
});
|
|
110
|
+
return `A:${active === null ? '*' : active.join(',')}|R:${rules.join(';')}|G:${groups.join(';')}|S:${scenes.join(';')}`;
|
|
111
|
+
}
|
|
112
|
+
// ── 场景排序与标题 ─────────────────────────────────────────────────────────
|
|
113
|
+
/** 场景排序键:索引 scenes.order 优先,回退到旧 groups.order,再回退默认值。 */
|
|
114
|
+
export function sceneOrderOf(index, scene) {
|
|
115
|
+
if (scene === GLOBAL_SCENE)
|
|
116
|
+
return 0;
|
|
117
|
+
const s = index.scenes?.[scene]?.order;
|
|
118
|
+
if (typeof s === 'number' && Number.isFinite(s))
|
|
119
|
+
return s;
|
|
120
|
+
return index.groups[scene]?.order ?? DEFAULT_GROUP_ORDER;
|
|
121
|
+
}
|
|
122
|
+
/** 场景显示名:`global` → 「全局」(磁盘名保持 ASCII),其余用索引 label 或场景名。 */
|
|
123
|
+
export function sceneLabel(scene, index) {
|
|
124
|
+
if (scene === GLOBAL_SCENE)
|
|
125
|
+
return index?.scenes?.[GLOBAL_SCENE]?.label || GLOBAL_SCENE_LABEL;
|
|
126
|
+
const label = index?.scenes?.[scene]?.label;
|
|
127
|
+
return label && label !== '' ? label : scene;
|
|
128
|
+
}
|
|
129
|
+
/** 单个场景的标题:**场景在最顶层**(`##`,与「子智能体」「MCP 服务器」等段同级)。 */
|
|
130
|
+
export const sceneHeading = (scene) => `## 场景:${sceneLabel(scene)}`;
|
|
131
|
+
/**
|
|
132
|
+
* 没填描述时的默认「场景说明」(用户裁定 2026-09-16:全局桶一直没有描述,读起来像缺了一块,
|
|
133
|
+
* 统一成"每个场景块都有场景说明")。
|
|
134
|
+
*
|
|
135
|
+
* 默认句同时承担"这个场景是什么"的答疑(此前只有光秃秃的 `## 场景:X`,模型读不懂 —— 用户实测):
|
|
136
|
+
* 两个恒常桶说明生效范围,用户场景说明它是当前启用的那份配置。
|
|
137
|
+
*/
|
|
138
|
+
export const defaultSceneDescription = (scene) => (scene === GLOBAL_SCENE ? '全局记忆,任何对话都生效'
|
|
139
|
+
: scene === SHARED_GROUP ? '共享记忆,任何对话都生效'
|
|
140
|
+
: '用户配置的上下文,当前启用');
|
|
141
|
+
/**
|
|
142
|
+
* 单个场景的段头:场景标题 + **恒有**的 `场景说明:<描述>`。
|
|
143
|
+
*
|
|
144
|
+
* 场景描述(界面「描述(可选)」,≤60 字符)**此前从未注入过** —— 它正是「这个场景是
|
|
145
|
+
* 干什么的」的答案,属于模型做判断需要的上下文,而不是只给人看的元数据;界面上的文案
|
|
146
|
+
* 也从没把它标成「只给使用者看」(对比 AGENTS.md 预设的描述,那里是明确标注的)。
|
|
147
|
+
* 描述为空时给 `defaultSceneDescription` 的默认句(用户裁定:全局桶没描述时读起来像
|
|
148
|
+
* 缺了一块,统一成每个场景块都有说明)。
|
|
149
|
+
*/
|
|
150
|
+
export function sceneHeader(scene, index) {
|
|
151
|
+
const head = sceneHeading(scene);
|
|
152
|
+
const described = String(index?.scenes?.[scene]?.description ?? '').replaceAll(/\s+/g, ' ').trim();
|
|
153
|
+
const description = described === '' ? defaultSceneDescription(scene) : described;
|
|
154
|
+
// 加粗(用户裁定 2026-09-16):与引导语同款强调,别让"场景说明"读起来像可忽略的普通正文。
|
|
155
|
+
return `${head}\n\n**场景说明:${description}**\n\n`;
|
|
156
|
+
}
|
|
157
|
+
/** 场景渲染顺序:全局 `global` 最先(它的记忆对任何对话都成立,先讲总则),
|
|
158
|
+
* 其次 `_shared`(历史保留名),其余按(索引 scenes.order, 场景名)。 */
|
|
159
|
+
export function compareSceneBuckets(a, b, index) {
|
|
160
|
+
if (a === b)
|
|
161
|
+
return 0;
|
|
162
|
+
if (a === GLOBAL_SCENE)
|
|
163
|
+
return -1;
|
|
164
|
+
if (b === GLOBAL_SCENE)
|
|
165
|
+
return 1;
|
|
166
|
+
if (a === SHARED_GROUP)
|
|
167
|
+
return -1;
|
|
168
|
+
if (b === SHARED_GROUP)
|
|
169
|
+
return 1;
|
|
170
|
+
const ao = sceneOrderOf(index, a);
|
|
171
|
+
const bo = sceneOrderOf(index, b);
|
|
172
|
+
return ao - bo || a.localeCompare(b);
|
|
173
|
+
}
|
|
174
|
+
// ── bundle 附件 ────────────────────────────────────────────────────────────
|
|
175
|
+
/**
|
|
176
|
+
* bundle 记忆的附件名(**同步**版 —— 段渲染必须同步返回,`listAttachments` 是异步的)。
|
|
177
|
+
* 口径与异步版一致:跳过正文本体(`<名>.md`,旧数据可能是 `SKILL.md`),只认普通文件。
|
|
178
|
+
*/
|
|
179
|
+
export function attachmentNamesSync(bundleDir, name) {
|
|
180
|
+
try {
|
|
181
|
+
const docNames = new Set([bundleDocName(name), LEGACY_BUNDLE_DOC]);
|
|
182
|
+
return readdirSync(bundleDir, { withFileTypes: true })
|
|
183
|
+
.filter((e) => e.isFile() && !docNames.has(e.name))
|
|
184
|
+
.map((e) => e.name)
|
|
185
|
+
.sort((a, b) => a.localeCompare(b));
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return [];
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* bundle 记忆的附件**摘要**(列表页用):数量 / 总体积 / 前几个文件名。
|
|
193
|
+
*
|
|
194
|
+
* 口径与 `attachmentNamesSync`(也就是注入给模型的那份清单)逐字一致:跳过正文本体,
|
|
195
|
+
* 只认普通文件 —— 所以页面上的数字与模型实际看到的一致,不会出现「界面说 3 个、模型只见 2 个」。
|
|
196
|
+
* `names` 截到 ATTACHMENT_LIST_MAX:tooltip 列不下更多,要全看到编辑弹窗里去看。
|
|
197
|
+
*
|
|
198
|
+
* 目录读不到(索引残留了已消失的条目)→ 返回 null,让界面**什么都不显示**,
|
|
199
|
+
* 而不是谎报「0 个附件」。
|
|
200
|
+
*/
|
|
201
|
+
export function attachmentSummarySync(bundleDir, name) {
|
|
202
|
+
let names;
|
|
203
|
+
try {
|
|
204
|
+
const docNames = new Set([bundleDocName(name), LEGACY_BUNDLE_DOC]);
|
|
205
|
+
names = readdirSync(bundleDir, { withFileTypes: true })
|
|
206
|
+
.filter((e) => e.isFile() && !docNames.has(e.name))
|
|
207
|
+
.map((e) => e.name)
|
|
208
|
+
.sort((a, b) => a.localeCompare(b));
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
let bytes = 0;
|
|
214
|
+
for (const entry of names) {
|
|
215
|
+
try {
|
|
216
|
+
bytes += statSync(join(bundleDir, entry)).size;
|
|
217
|
+
}
|
|
218
|
+
catch { /* 读不到的条目不计体积 */ }
|
|
219
|
+
}
|
|
220
|
+
return { count: names.length, bytes, names: names.slice(0, ATTACHMENT_LIST_MAX) };
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* 附件行(只有 bundle 记忆才有):**给目录与文件名,不给内容**。
|
|
224
|
+
* 附件可能是图片、二进制、大 md —— 全文注入又贵又会把段预算吃光;给路径,模型需要时自己读。
|
|
225
|
+
*/
|
|
226
|
+
export function attachmentLine(file) {
|
|
227
|
+
if (file.kind !== 'bundle')
|
|
228
|
+
return '';
|
|
229
|
+
const names = attachmentNamesSync(file.bundleDir, file.name);
|
|
230
|
+
if (names.length === 0)
|
|
231
|
+
return '';
|
|
232
|
+
const shown = names.slice(0, ATTACHMENT_LIST_MAX);
|
|
233
|
+
const more = names.length - shown.length;
|
|
234
|
+
return `附件目录:${file.bundleDir}(未注入正文,共 ${names.length} 个:${shown.join('、')}${more > 0 ? `,另 ${more} 个` : ''})`;
|
|
235
|
+
}
|
|
236
|
+
// ── 单条记忆的渲染 ─────────────────────────────────────────────────────────
|
|
237
|
+
/** 多行正文整体缩进 2 格(列在条目内容列上),空行保持空行、不加尾随空白。 */
|
|
238
|
+
export const indentBody = (text) => text
|
|
239
|
+
.split('\n')
|
|
240
|
+
.map((line) => (line === '' ? line : ' ' + line))
|
|
241
|
+
.join('\n');
|
|
242
|
+
/** 括号注解用的显式描述:派生描述与正文重复、不进段(只存在于界面投影);换行压成单行,避免把「一行一条」的列表项撑断。 */
|
|
243
|
+
export const explicitDescriptionOf = (f) => (f.descriptionDerived || !f.description ? '' : String(f.description).replaceAll(/\s+/g, ' ').trim());
|
|
244
|
+
/**
|
|
245
|
+
* 单条信息的渲染形态 —— **能一行就一行,但恒为列表项**。
|
|
246
|
+
*
|
|
247
|
+
* 单行且不长的正文 → `- **名称**(描述) — 正文`(与 MCP / 子智能体两个段的列表同形;无显式描述时括号不出现)
|
|
248
|
+
* 多行或过长的正文 → `- **名称**(描述)` + 空行 + 缩进 2 格的正文(挂在条目下)
|
|
249
|
+
*
|
|
250
|
+
* 名称恒为标题:它就是这条记忆的身份(工具 id `<场景>/<名称>`、bundle 目录/文件名都以它为准),
|
|
251
|
+
* 用户说「记忆里的 X」、模型再调 `memory_manager_*` 时都对得上号;显式描述是括号注解,不抢标题。
|
|
252
|
+
*
|
|
253
|
+
* 为什么全都做成列表项:早先多行正文走 `### 名称` 标题块,附件行只能退化成与记忆**同级**的
|
|
254
|
+
* `- 附件目录:…`(没有父列表项可挂)—— 既可能被读成一条独立记忆,某些渲染器里还会把下一个
|
|
255
|
+
* `###` 标题吞进列表(与上一条粘连)。统一成「一条记忆 = 一个列表项、正文与附件都缩进挂在
|
|
256
|
+
* 条目下」后,两种记忆外观完全一致,归属也不再靠位置猜测。
|
|
257
|
+
*
|
|
258
|
+
* 为什么要分两种:用户常有十几条「一句话事实」(「提交格式:PDF」),每条都占标题 + 空行 +
|
|
259
|
+
* 正文三行,整段会散成一长串标题;压成一行后十条信息就是十行。多行正文是**用户写的完整
|
|
260
|
+
* Markdown**(可能自带标题、代码块、嵌套列表),整体缩进 2 格挂到条目下,结构原样保留。
|
|
261
|
+
*
|
|
262
|
+
* 返回值带 `inline`:调用方据此决定下一条记忆前要不要空行(单行条目连续排列,其余空行分隔)。
|
|
263
|
+
*/
|
|
264
|
+
export function memoryBlock(file) {
|
|
265
|
+
const desc = explicitDescriptionOf(file);
|
|
266
|
+
// 加粗的只有名称:`- **名称**(描述) — 正文`,与 MCP 段 `- **server**(N 个工具) — …` 同形。
|
|
267
|
+
const title = `**${file.name}**` + (desc === '' ? '' : `(${desc})`);
|
|
268
|
+
const text = String(file.body ?? '').trim();
|
|
269
|
+
const inline = text === '' || (!text.includes('\n') && text.length <= INLINE_BODY_MAX);
|
|
270
|
+
const head = text === ''
|
|
271
|
+
? `- ${title}`
|
|
272
|
+
: (inline ? `- ${title} — ${text}` : `- ${title}\n\n${indentBody(text)}`);
|
|
273
|
+
const attach = attachmentLine(file);
|
|
274
|
+
if (attach === '')
|
|
275
|
+
return { text: `${head}\n`, inline };
|
|
276
|
+
// 附件行恒为缩进子项:只用 `- ` 会被解析成与记忆**同级**的列表项(`- A` / `- 附件目录:A的` /
|
|
277
|
+
// `- B` … 四条平级,归属读不出来)。单行条目紧跟其后保持列表连续;多行条目前面空一行,
|
|
278
|
+
// 免得被读成用户正文自己的列表项。
|
|
279
|
+
return { text: inline ? `${head}\n - ${attach}\n` : `${head}\n\n - ${attach}\n`, inline };
|
|
280
|
+
}
|