dsh-plugin-tool-management 0.17.0 → 0.18.5
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 +70 -0
- package/README.md +19 -4
- package/README_EN.md +19 -4
- 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 +29 -0
- package/lib/client.js +1662 -161
- package/lib/compat/capability-specs.js +321 -0
- package/lib/compat/host-probe.js +261 -0
- package/lib/compat/host-roots.js +168 -0
- package/lib/compat/probe-types.js +6 -0
- package/lib/compat/probe.js +9 -720
- package/lib/context-inject-contract.js +114 -0
- package/lib/context-inject-frame.js +147 -0
- package/lib/context-inject-select.js +127 -0
- package/lib/context-inject-surface.js +97 -0
- package/lib/context-inject.js +10 -478
- package/lib/exports/transcript.js +223 -0
- package/lib/http-api.js +229 -0
- package/lib/hub.js +2 -0
- package/lib/index.js +138 -868
- package/lib/mcp/inspect-probe.js +199 -0
- package/lib/mcp/manager.js +30 -548
- package/lib/mcp/tool-visibility.js +388 -0
- package/lib/memories/archive-engine.js +142 -5
- package/lib/memories/archive.js +10 -1
- package/lib/memories/discovery.js +261 -0
- package/lib/memories/export-plan.js +42 -0
- package/lib/memories/memories-types.js +1 -0
- package/lib/memories/service.js +4 -38
- package/lib/memories/snapshot.js +10 -252
- package/lib/op-registry.js +21 -0
- package/lib/ops/compat.js +11 -0
- package/lib/ops/quick-prompts.js +26 -0
- package/lib/ops/scene-records.js +40 -0
- package/lib/ops/scene-sync.js +71 -1
- package/lib/ops/sessions.js +34 -14
- package/lib/ops/snapshot.js +18 -12
- package/lib/prompts/quick-service.js +211 -0
- package/lib/scene-linkage.js +184 -0
- package/lib/sessions/archive-schemas.js +121 -0
- package/lib/sessions/path-key.js +46 -0
- package/lib/sessions/workspace.js +29 -166
- package/lib/settings-sidecars.js +143 -0
- package/lib/skills/core-types.js +8 -0
- package/lib/skills/core-util.js +81 -0
- package/lib/skills/core.js +56 -2326
- package/lib/skills/dir-watch.js +71 -0
- package/lib/skills/frontmatter.js +96 -0
- package/lib/skills/homes.js +14 -0
- package/lib/skills/import-pipeline.js +231 -0
- package/lib/skills/manager-state.js +800 -0
- package/lib/skills/official.js +269 -0
- package/lib/skills/provider-registration.js +89 -0
- package/lib/skills/service.js +5 -240
- package/lib/skills/skill-roots.js +287 -0
- package/lib/skills/skill-trash.js +265 -0
- package/lib/skills/system-trash.js +97 -0
- package/lib/skills/upload-pipeline.js +374 -0
- package/lib/slash-settings.js +25 -0
- package/lib/subagents/persona-parse.js +312 -0
- package/lib/subagents/persona-types.js +5 -0
- package/lib/subagents/persona-write.js +93 -0
- package/lib/subagents/run-provider.js +159 -0
- package/lib/subagents/service.js +14 -638
- package/lib/subagents/tool-filter.js +98 -0
- package/lib/token-config.js +240 -0
- package/lib/tool-table-switch.js +163 -0
- package/lib/tools/scene.js +40 -9
- package/package.json +3 -2
package/lib/memories/snapshot.js
CHANGED
|
@@ -8,13 +8,17 @@
|
|
|
8
8
|
//
|
|
9
9
|
// 对 service.ts 只做 type-only 引用(`import type`),避免与它形成运行时循环依赖。
|
|
10
10
|
import { readdirSync, statSync } from 'node:fs';
|
|
11
|
-
import {
|
|
12
|
-
import { dirname, join
|
|
13
|
-
import { parseSkillDoc
|
|
14
|
-
import { MAX_SOURCE_DEPTH, MAX_DIRECTORIES, MAX_ENTRIES,
|
|
11
|
+
import { readFile } from 'node:fs/promises';
|
|
12
|
+
import { dirname, join } from 'node:path';
|
|
13
|
+
import { parseSkillDoc } from '../skills/core.js';
|
|
14
|
+
import { MAX_SOURCE_DEPTH, MAX_DIRECTORIES, MAX_ENTRIES, DEFAULT_ORDER, DEFAULT_GROUP_ORDER, GLOBAL_SCENE, TRUNCATION_MARKER, DROPPED_HEADING, SCENE_MEMORY_NOTE, LEGACY_BUNDLE_DOC, bundleDocName, SHARED_GROUP, byteLen, message, isValidGroupSegment } from './constants.js';
|
|
15
15
|
import { ensureSceneRecords, resolveActiveScenes, signatureOfIndex, sceneLabel, sceneHeading, sceneLine, compareSceneBuckets, memoryBlock } from './projection.js';
|
|
16
|
-
import { isIndexQuarantined, readIndex, writeIndex,
|
|
17
|
-
|
|
16
|
+
import { isIndexQuarantined, readIndex, writeIndex, readFileOrThrowSync, isAbsentError } from './index-io.js';
|
|
17
|
+
// 来源发现与投影(扫目录读成条目 / frontmatter 投成规则 / 老布局搬家)2026-10-01 整段搬到
|
|
18
|
+
// ./discovery.js,正文一字未改(moved-verify 逐字对拍)。snapshot.js 的公开面照旧:搬走的
|
|
19
|
+
// 九件全部从这里再出口 —— memories/service.ts 按 ./snapshot.js 引它们。
|
|
20
|
+
export { deriveDescription, deriveFromDoc, discover, identity, projectRule, relocateLegacyLayout, } from './discovery.js';
|
|
21
|
+
import { deriveFromDoc, discover, projectRule, } from './discovery.js';
|
|
18
22
|
// ── 文件工具 ───────────────────────────────────────────────────────────────
|
|
19
23
|
/**
|
|
20
24
|
* BFS 发现(realpath 防环、深度/目录/条目预算),与 readonly-discovery 同构,
|
|
@@ -27,213 +31,10 @@ export const identity = (p) => (process.platform === 'win32' ? p.toLowerCase() :
|
|
|
27
31
|
* 都没有则它是场景/子分类,继续遍历其下 .md(flat,任意层级)
|
|
28
32
|
* 与子目录。同名 flat 与 bundle 冲突时 bundle 优先,flat 记入 shadowed。
|
|
29
33
|
*/
|
|
30
|
-
export async function discover(memoriesRoot) {
|
|
31
|
-
const entries = new Map();
|
|
32
|
-
const shadowed = [];
|
|
33
|
-
const groups = new Set();
|
|
34
|
-
const scenes = [];
|
|
35
|
-
const warnings = [];
|
|
36
|
-
const rootPath = resolve(memoriesRoot);
|
|
37
|
-
try {
|
|
38
|
-
const st = await lstat(rootPath);
|
|
39
|
-
if (!st.isDirectory() || st.isSymbolicLink())
|
|
40
|
-
return { entries, shadowed, groups, scenes, warnings, truncated: false };
|
|
41
|
-
}
|
|
42
|
-
catch {
|
|
43
|
-
return { entries, shadowed, groups, scenes, warnings, truncated: false };
|
|
44
|
-
}
|
|
45
|
-
const queue = [
|
|
46
|
-
{ path: rootPath, group: '', depth: 0, ancestors: new Set() },
|
|
47
|
-
];
|
|
48
|
-
let directories = 0;
|
|
49
|
-
let itemCount = 0;
|
|
50
|
-
let truncated = false;
|
|
51
|
-
const addEntry = (entry) => {
|
|
52
|
-
const existing = entries.get(entry.id);
|
|
53
|
-
if (!existing) {
|
|
54
|
-
entries.set(entry.id, entry);
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
if (entry.kind === 'bundle' && existing.kind === 'flat') {
|
|
58
|
-
shadowed.push({ ...existing, shadowed: true });
|
|
59
|
-
entries.set(entry.id, entry);
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
if (entry.kind === 'flat' && existing.kind === 'bundle') {
|
|
63
|
-
shadowed.push({ ...entry, shadowed: true });
|
|
64
|
-
}
|
|
65
|
-
};
|
|
66
|
-
for (let cursor = 0; cursor < queue.length; cursor++) {
|
|
67
|
-
if (directories >= MAX_DIRECTORIES || itemCount >= MAX_ENTRIES) {
|
|
68
|
-
truncated = true;
|
|
69
|
-
break;
|
|
70
|
-
}
|
|
71
|
-
const current = queue[cursor];
|
|
72
|
-
let realDirectory;
|
|
73
|
-
let dirents = [];
|
|
74
|
-
try {
|
|
75
|
-
realDirectory = await realpath(current.path);
|
|
76
|
-
if (current.ancestors.has(identity(realDirectory)))
|
|
77
|
-
continue;
|
|
78
|
-
const dir = await readdir(current.path, { withFileTypes: true });
|
|
79
|
-
directories++;
|
|
80
|
-
for (const item of dir) {
|
|
81
|
-
if (itemCount >= MAX_ENTRIES) {
|
|
82
|
-
truncated = true;
|
|
83
|
-
break;
|
|
84
|
-
}
|
|
85
|
-
itemCount++;
|
|
86
|
-
dirents.push(item);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
catch {
|
|
90
|
-
continue; // 失效链接或不可读目录不阻断其他来源
|
|
91
|
-
}
|
|
92
|
-
// bundle 检查:当前目录含 `<目录名>.md`(或旧版 `SKILL.md`)→ 它是规则叶子,父路径为场景。
|
|
93
|
-
//
|
|
94
|
-
// **根下的一级目录恒为场景**(`parentGroup === ''` 时不做 bundle 判断)——与
|
|
95
|
-
// `probeSceneFilesSync`(注入段)同口径。场景 `1` 里一条叫 `1` 的记忆(`memories/1/1.md`)
|
|
96
|
-
// 与「根层的 bundle `1`」在磁盘上同形,认成后者会把整个场景目录吃成叶子:同场景其余
|
|
97
|
-
// 记忆全从列表消失、多出一条「未归属场景」的同名记忆,新建记忆还会被误报「同名 bundle
|
|
98
|
-
// 遮蔽」(2026-09-16 用户实测)。根层因此没有 bundle,只有 legacy 裸 .md(见 rulesCheck 的 noScene)。
|
|
99
|
-
if (current.group !== '') {
|
|
100
|
-
const sepIdx = current.group.lastIndexOf('/');
|
|
101
|
-
const parentGroup = sepIdx >= 0 ? current.group.slice(0, sepIdx) : '';
|
|
102
|
-
const name = sepIdx >= 0 ? current.group.slice(sepIdx + 1) : current.group;
|
|
103
|
-
let docPath = '';
|
|
104
|
-
if (parentGroup !== '') {
|
|
105
|
-
for (const candidate of [bundleDocName(name), LEGACY_BUNDLE_DOC]) {
|
|
106
|
-
const p = join(current.path, candidate);
|
|
107
|
-
try {
|
|
108
|
-
const st = await lstat(p);
|
|
109
|
-
if (st.isFile() && !st.isSymbolicLink()) {
|
|
110
|
-
docPath = p;
|
|
111
|
-
break;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
catch { /* 该候选名不存在 → 试下一个 */ }
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
if (docPath !== '') {
|
|
118
|
-
const id = parentGroup ? `${parentGroup}/${name}` : name;
|
|
119
|
-
addEntry({ id, group: parentGroup, name, kind: 'bundle', docPath, entryPath: current.path });
|
|
120
|
-
continue;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
if (current.group !== '') {
|
|
124
|
-
groups.add(current.group);
|
|
125
|
-
// 一级目录 = 场景名(多段场景名用 '/' 连接,见 isValidGroupPath)。
|
|
126
|
-
if (current.depth === 1)
|
|
127
|
-
scenes.push(current.group);
|
|
128
|
-
}
|
|
129
|
-
dirents.sort((a, b) => a.name.localeCompare(b.name));
|
|
130
|
-
const ancestors = new Set(current.ancestors).add(identity(realDirectory));
|
|
131
|
-
for (const item of dirents) {
|
|
132
|
-
if (item.name.startsWith('.'))
|
|
133
|
-
continue; // 隐藏目录/文件跳过
|
|
134
|
-
const path = join(current.path, item.name);
|
|
135
|
-
try {
|
|
136
|
-
const st = await lstat(path);
|
|
137
|
-
// 符号链接一律不跟随:注入路径(`probeSceneFilesSync`)本来就不跟随,两端必须同口径 ——
|
|
138
|
-
// 否则列表里会出现**永远注入不进去**的记忆(用户打开开关却什么也没发生)。顺带也关掉了
|
|
139
|
-
// 「链接指向根外、写操作顺着链接穿透出去」这条路(写入侧另有 realpath 断言兜底)。
|
|
140
|
-
if (st.isSymbolicLink())
|
|
141
|
-
continue;
|
|
142
|
-
if (st.isDirectory()) {
|
|
143
|
-
if (!isValidGroupSegment(item.name)) {
|
|
144
|
-
warnings.push(`跳过非法目录名(${SEGMENT_RULE_HINT}):${current.group ? current.group + '/' : ''}${item.name}`);
|
|
145
|
-
continue;
|
|
146
|
-
}
|
|
147
|
-
if (current.depth >= MAX_SOURCE_DEPTH || queue.length >= MAX_DIRECTORIES) {
|
|
148
|
-
truncated = true;
|
|
149
|
-
continue;
|
|
150
|
-
}
|
|
151
|
-
queue.push({
|
|
152
|
-
path,
|
|
153
|
-
group: current.group ? `${current.group}/${item.name}` : item.name,
|
|
154
|
-
depth: current.depth + 1,
|
|
155
|
-
ancestors,
|
|
156
|
-
});
|
|
157
|
-
continue;
|
|
158
|
-
}
|
|
159
|
-
if (!st.isFile())
|
|
160
|
-
continue;
|
|
161
|
-
if (!item.name.toLowerCase().endsWith('.md'))
|
|
162
|
-
continue; // 只认 .md
|
|
163
|
-
const name = item.name.slice(0, -3);
|
|
164
|
-
const id = current.group ? `${current.group}/${name}` : name;
|
|
165
|
-
addEntry({ id, group: current.group, name, kind: 'flat', docPath: path, entryPath: path });
|
|
166
|
-
}
|
|
167
|
-
catch {
|
|
168
|
-
/* 来源在扫描期间失效时跳过 */
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
return { entries, shadowed, groups, scenes, warnings, truncated };
|
|
173
|
-
}
|
|
174
34
|
// ── 规则解析 + 派生 ────────────────────────────────────────────────────────
|
|
175
35
|
/** 派生 description:首个 `#` 标题 → 首个非空行;截断 500 字符。 */
|
|
176
|
-
export function deriveDescription(body) {
|
|
177
|
-
const text = String(body ?? '').trim();
|
|
178
|
-
if (!text)
|
|
179
|
-
return '';
|
|
180
|
-
const heading = /^#\s+(.+)$/m.exec(text);
|
|
181
|
-
const raw = heading && heading[1].trim() ? heading[1].trim() : text.split(/\r?\n/, 1)[0].trim();
|
|
182
|
-
return raw.slice(0, MAX_DESCRIPTION_LENGTH);
|
|
183
|
-
}
|
|
184
36
|
/** 从 frontmatter/正文派生字段(派生只存在于内存投影,不写回文件)。 */
|
|
185
|
-
export function deriveFromDoc(entry, doc) {
|
|
186
|
-
const declaredName = doc.map.name != null && String(doc.map.name).trim() !== '' ? unquote(String(doc.map.name)).trim() : '';
|
|
187
|
-
const name = declaredName || entry.name;
|
|
188
|
-
let description = doc.map.description != null ? unquote(String(doc.map.description)).trim() : '';
|
|
189
|
-
let descriptionDerived = false;
|
|
190
|
-
if (description === '') {
|
|
191
|
-
description = deriveDescription(doc.body);
|
|
192
|
-
descriptionDerived = true;
|
|
193
|
-
}
|
|
194
|
-
const globs = doc.map.globs != null && String(doc.map.globs).trim() !== '' ? String(doc.map.globs).trim() : undefined;
|
|
195
|
-
let whenToUse;
|
|
196
|
-
if (doc.map.whenToUse != null && String(doc.map.whenToUse).trim() !== '') {
|
|
197
|
-
whenToUse = String(doc.map.whenToUse).trim();
|
|
198
|
-
}
|
|
199
|
-
else if (globs) {
|
|
200
|
-
whenToUse = `适用路径:${globs}`;
|
|
201
|
-
}
|
|
202
|
-
const metadata = doc.map.metadata !== undefined ? doc.map.metadata : undefined;
|
|
203
|
-
return { name, description, descriptionDerived, whenToUse, globs, metadata };
|
|
204
|
-
}
|
|
205
37
|
/** 合并索引字段(enabled/order 等以索引为准;无记录走默认投影)。 */
|
|
206
|
-
export function projectRule(entry, derived, idxEntry) {
|
|
207
|
-
// 体积按**正文文件**算(bundle 的附件不计:附件不进注入正文)。一次 stat 一条记忆,
|
|
208
|
-
// 结果随快照的 1s TTL 缓存一起复用;读不到就不带这个字段,别把"不知道"报成 0 B。
|
|
209
|
-
let size;
|
|
210
|
-
try {
|
|
211
|
-
size = statSync(entry.docPath).size;
|
|
212
|
-
}
|
|
213
|
-
catch {
|
|
214
|
-
size = undefined;
|
|
215
|
-
}
|
|
216
|
-
const rule = {
|
|
217
|
-
id: entry.id,
|
|
218
|
-
group: entry.group,
|
|
219
|
-
name: derived.name,
|
|
220
|
-
form: entry.kind,
|
|
221
|
-
path: entry.docPath,
|
|
222
|
-
...(size !== undefined ? { bytes: size } : {}),
|
|
223
|
-
description: derived.description,
|
|
224
|
-
...(derived.descriptionDerived ? { descriptionDerived: true } : {}),
|
|
225
|
-
...(derived.whenToUse !== undefined ? { whenToUse: derived.whenToUse } : {}),
|
|
226
|
-
...(derived.globs !== undefined ? { globs: derived.globs } : {}),
|
|
227
|
-
...(derived.metadata !== undefined ? { metadata: derived.metadata } : {}),
|
|
228
|
-
enabled: idxEntry?.enabled ?? true,
|
|
229
|
-
order: idxEntry?.order ?? DEFAULT_ORDER,
|
|
230
|
-
tags: Array.isArray(idxEntry?.tags) ? idxEntry.tags : [],
|
|
231
|
-
pinned: idxEntry?.pinned === true,
|
|
232
|
-
note: typeof idxEntry?.note === 'string' ? idxEntry.note : '',
|
|
233
|
-
...(typeof idxEntry?.updatedAt === 'string' ? { updatedAt: idxEntry.updatedAt } : {}),
|
|
234
|
-
};
|
|
235
|
-
return rule;
|
|
236
|
-
}
|
|
237
38
|
// ── 侧车文件(索引 / 场景)─────────────────────────────────────────────────
|
|
238
39
|
/** 把旧布局搬进 `tool-management/`(每个进程只尝试一次,幂等)。
|
|
239
40
|
*
|
|
@@ -246,49 +47,6 @@ export function projectRule(entry, derived, idxEntry) {
|
|
|
246
47
|
* 源目录**保留**(内容已搬走,留空壳不影响正确性,也方便用户核对)。
|
|
247
48
|
* 目标已存在同名项 → 保留目标、跳过该项(绝不覆盖新数据)。
|
|
248
49
|
*/
|
|
249
|
-
export async function relocateLegacyLayout(memoriesRoot) {
|
|
250
|
-
const home = resolveDshHome();
|
|
251
|
-
const legacyRoots = [join(home, 'scene-memory'), join(home, 'rules')];
|
|
252
|
-
for (const legacyRoot of legacyRoots) {
|
|
253
|
-
if (!(await pathExists(legacyRoot)))
|
|
254
|
-
continue;
|
|
255
|
-
if (identity(resolve(legacyRoot)) === identity(resolve(memoriesRoot)))
|
|
256
|
-
continue; // 自定义 roots 落在旧路径 → 不自我搬移
|
|
257
|
-
let items;
|
|
258
|
-
try {
|
|
259
|
-
items = await readdir(legacyRoot, { withFileTypes: true });
|
|
260
|
-
}
|
|
261
|
-
catch {
|
|
262
|
-
continue;
|
|
263
|
-
}
|
|
264
|
-
for (const item of items) {
|
|
265
|
-
if (item.name.startsWith('.'))
|
|
266
|
-
continue;
|
|
267
|
-
const src = join(legacyRoot, item.name);
|
|
268
|
-
// 根层 .md = 旧的「全局」桶 → 搬进 `global/`,文件名保留(否则多套一层目录)。
|
|
269
|
-
// 其余条目(场景目录)整体搬进 memories/,目录名保留。
|
|
270
|
-
const isRootMd = item.isFile() && item.name.toLowerCase().endsWith('.md');
|
|
271
|
-
const destDir = isRootMd ? join(memoriesRoot, GLOBAL_SCENE) : memoriesRoot;
|
|
272
|
-
const dest = join(destDir, item.name);
|
|
273
|
-
try {
|
|
274
|
-
if (await pathExists(dest))
|
|
275
|
-
continue;
|
|
276
|
-
await mkdir(destDir, { recursive: true });
|
|
277
|
-
try {
|
|
278
|
-
await rename(src, dest);
|
|
279
|
-
}
|
|
280
|
-
catch {
|
|
281
|
-
// 跨设备/被占用 → 复制后删源;复制失败则保留源文件(宁可重复,不可丢失)。
|
|
282
|
-
await cp(src, dest, { recursive: true });
|
|
283
|
-
await rm(src, { recursive: true, force: true });
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
catch {
|
|
287
|
-
/* 单项失败不阻断其余项(如文件被外部程序锁住,下次启动再试) */
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
50
|
// ── 快照(发现 + 索引合并 + 索引清理)──────────────────────────────────────
|
|
293
51
|
export async function buildSnapshot(memoriesRoot, stateDir) {
|
|
294
52
|
const discovery = await discover(memoriesRoot);
|
package/lib/op-registry.js
CHANGED
|
@@ -153,6 +153,22 @@ export const OP_REGISTRY = Object.freeze({
|
|
|
153
153
|
'agentsmd-import': { write: true, frozen: true },
|
|
154
154
|
'agentsmd-trash-restore': { write: true, frozen: true },
|
|
155
155
|
'agentsmd-trash-delete': { write: true, frozen: true },
|
|
156
|
+
// ── 快捷提示词域(8)───────────────────────────────────────────────────────
|
|
157
|
+
// 只改插件自己的侧车(`hub/quick-prompts/`),不写 AGENTS.md、不进场景、不进注入;
|
|
158
|
+
// 但"改用户存好的东西"按本文件口径就是写操作(判据不是"会不会写盘",见文件头)。
|
|
159
|
+
// frozen 与五个管理域同一条:**锁定期间整体只读**(用户裁定 2026-09-30,不给它开例外,
|
|
160
|
+
// 免得"锁定"这件事要分两套话术解释)。
|
|
161
|
+
'quickprompt-list': { readonly: true },
|
|
162
|
+
'quickprompt-trash-list': { readonly: true },
|
|
163
|
+
'quickprompt-create': { write: true, frozen: true },
|
|
164
|
+
'quickprompt-update': { write: true, frozen: true },
|
|
165
|
+
// 那颗开关改的是"这条在不在对话框 `/` 菜单里出现",动的仍是用户自己的东西 ——
|
|
166
|
+
// 与 create/update 同档,不因为它不碰正文就免了令牌。`syncsArchive`:场景绑了快捷词时,
|
|
167
|
+
// 在场景里手动开关一条要跟着改进那份勾选集(未绑定的场景不跟,见 index.ts 那一支的注释)。
|
|
168
|
+
'quickprompt-toggle': { write: true, frozen: true, syncsArchive: true },
|
|
169
|
+
'quickprompt-remove': { write: true, frozen: true },
|
|
170
|
+
'quickprompt-trash-restore': { write: true, frozen: true },
|
|
171
|
+
'quickprompt-trash-delete': { write: true, frozen: true },
|
|
156
172
|
// ── 历史会话域(16)────────────────────────────────────────────────────────
|
|
157
173
|
'history-list': { readonly: true },
|
|
158
174
|
'history-sessions': { readonly: true },
|
|
@@ -205,6 +221,11 @@ export const OP_REGISTRY = Object.freeze({
|
|
|
205
221
|
// **刻意不冻结**:它只是界面提示,锁着场景的人在场景页照样该能关掉提醒 —— 冻结的是五个
|
|
206
222
|
// 管理域的改动,不是这个页面的显示偏好(判据见文件头 write/frozen 两段的边界)。
|
|
207
223
|
'scene-settings': { write: true, writeWhen: (a) => a.set === true },
|
|
224
|
+
// 斜杠命令入口的开关(侧车 `slash-settings.json`):与 `scene-settings` 同规格 —— 写侧车所以
|
|
225
|
+
// 按写门禁,但**刻意不冻结**(关掉一个界面入口不属于五个管理域的改动)。读侧不带 `set` 是纯读:
|
|
226
|
+
// 客户端 boot 时必问一次决定挂不挂 `/` 菜单,那一次不能要令牌,否则没配令牌的宿主上这个功能
|
|
227
|
+
// 永远出不来。
|
|
228
|
+
'slash-settings': { write: true, writeWhen: (a) => a.set === true },
|
|
208
229
|
// 清理 patch 备份:删磁盘文件(备份里含明文凭据副本),按写操作门禁。
|
|
209
230
|
'backups-clean': { write: true },
|
|
210
231
|
// ── 场景候选源(4)与内联(1)──────────────────────────────────────────────
|
package/lib/ops/compat.js
CHANGED
|
@@ -259,6 +259,17 @@ export function buildCompatOps(deps) {
|
|
|
259
259
|
key: 'tool-table', label: '模型工具表', tab: 'compat', state: 'ok', detail: offPart,
|
|
260
260
|
hidden: table.hiddenCount, total: table.totalCount, defaults: table.defaultHiddenCount,
|
|
261
261
|
});
|
|
262
|
+
// 斜杠命令入口(`/` 菜单里的「工具」与「快捷提示词」两段,各一颗开关)。服务端只知道
|
|
263
|
+
// **开关开没开**:真正挂没挂上是浏览器那半的事(宿主有没有 `inputTriggers` 服务),那一句
|
|
264
|
+
// 由兼容页就地报,不冒充服务端探测(见 client 48-slash.js 的挂载状态)。两个布尔一起带上,
|
|
265
|
+
// "开着哪一段"由界面按语言说。
|
|
266
|
+
const slash = await deps.readSlashSettings();
|
|
267
|
+
push({
|
|
268
|
+
key: 'slash-commands', label: '斜杠命令入口', tab: 'compat',
|
|
269
|
+
state: slash.tools || slash.quickPrompts ? 'ok' : 'disabled',
|
|
270
|
+
detail: slash.tools || slash.quickPrompts ? '开关已开(在对话输入框打 / 唤出已打开的那几段)' : '两段都已关闭(本页可重新打开)',
|
|
271
|
+
slashTools: slash.tools, slashQuickPrompts: slash.quickPrompts,
|
|
272
|
+
});
|
|
262
273
|
const nativeNote = notes.get('workspace.delete-native');
|
|
263
274
|
push({
|
|
264
275
|
key: 'native-delete', label: '宿主原生删除入口', tab: 'compat',
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// 快捷提示词域的 HTTP ops(实现见 ../prompts/quick-service.ts)。
|
|
2
|
+
//
|
|
3
|
+
// 与 `ops/prompts.ts` 的差别不是"少写了几条",而是**这个域没有宿主状态**:不写
|
|
4
|
+
// `~/.dsh/AGENTS.md`、不参与场景同步、没有"生效中"。所以这里既不需要 `withAgentsMdSync`
|
|
5
|
+
// 也不需要引用探测 —— 反过来,正因为它是纯侧车读写,才更要老老实实进 `op-registry`
|
|
6
|
+
// 的写门禁清单(配了令牌的宿主上,免令牌请求不该能改用户存好的文字)。
|
|
7
|
+
export function buildQuickPromptOps(quick) {
|
|
8
|
+
const text = (args, key) => args && typeof args[key] === 'string' ? String(args[key]) : undefined;
|
|
9
|
+
return {
|
|
10
|
+
// 列表连正文一起回(客户端点用时手里就得有那段字,见 quick-service.ts 头注)。
|
|
11
|
+
'quickprompt-list': async () => quick.list(),
|
|
12
|
+
'quickprompt-create': (args) => quick.create(String((args && args.id) || ''), text(args, 'content') ?? '', text(args, 'description')),
|
|
13
|
+
// 改名不牵连任何别处:快捷提示词不被场景绑定、不被引用,改完就是改完了。
|
|
14
|
+
'quickprompt-update': (args) => quick.update(String((args && args.id) || ''), text(args, 'content') ?? '', args && args.nextId !== undefined ? String(args.nextId) : undefined, text(args, 'description')),
|
|
15
|
+
'quickprompt-remove': (args) => quick.remove(String((args && args.id) || '')),
|
|
16
|
+
// 启停只改侧车的 `enabled`(在不在对话框 `/` 菜单里出现),正文一个字不动。
|
|
17
|
+
// `enabled` 必须是真布尔:省略它不叫"打开",而是调用方写错了,静默按某一种处理会
|
|
18
|
+
// 让界面与落盘各说各话。
|
|
19
|
+
'quickprompt-toggle': (args) => (args && typeof args.enabled === 'boolean')
|
|
20
|
+
? quick.setEnabled(String((args && args.id) || ''), args.enabled === true)
|
|
21
|
+
: Promise.resolve({ ok: false, error: '缺少 enabled(true / false)' }),
|
|
22
|
+
'quickprompt-trash-list': () => quick.trashList(),
|
|
23
|
+
'quickprompt-trash-restore': (args) => quick.trashRestore(String((args && args.id) || '')),
|
|
24
|
+
'quickprompt-trash-delete': (args) => quick.trashDelete(String((args && args.id) || '')),
|
|
25
|
+
};
|
|
26
|
+
}
|
package/lib/ops/scene-records.js
CHANGED
|
@@ -8,6 +8,21 @@ import { listTrashEntries, moveIntoTrash, moveOutOfTrash, moveToTrash, purgeTras
|
|
|
8
8
|
import { MAX_DESCRIPTION_LENGTH, DEFAULT_GROUP_ORDER, GLOBAL_SCENE, GLOBAL_SCENE_LABEL, SHARED_GROUP, SEGMENT_RULE_HINT, isValidGroupSegment, isValidGroupPath, message, fail } from '../memories/constants.js';
|
|
9
9
|
import { normalizeActive, collapseActiveForNewScene } from '../memories/projection.js';
|
|
10
10
|
import { readIndex, writeIndex, sceneRecordOf, normalizeScenePromptId, pathExists } from '../memories/index-io.js';
|
|
11
|
+
/** `quickPrompts` 参数的三种形态:不传 = 完全不碰这一域;null / 非数组 = 解绑(删字段);
|
|
12
|
+
* 数组 = 勾选集(去重去空)。**空数组是合法值**(= 进入后全部停用),不能当成"没绑"。 */
|
|
13
|
+
function quickPromptsArg(args) {
|
|
14
|
+
if (!args || args.quickPrompts === undefined)
|
|
15
|
+
return undefined;
|
|
16
|
+
if (!Array.isArray(args.quickPrompts))
|
|
17
|
+
return null;
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const item of args.quickPrompts) {
|
|
20
|
+
const id = String(item == null ? '' : item).trim();
|
|
21
|
+
if (id && out.indexOf(id) < 0)
|
|
22
|
+
out.push(id);
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
11
26
|
/** 组装本域 op。rc 由 createMemoriesService 组装,契约见 ./ctx.ts。 */
|
|
12
27
|
export function buildSceneRecordOps(rc) {
|
|
13
28
|
const { ensureLayout, invalidateSnapshot, presetExistsSync, memoriesRoot, refuseOutsideRoot, sceneRows, snapshot, stateDir } = rc;
|
|
@@ -132,6 +147,14 @@ export function buildSceneRecordOps(rc) {
|
|
|
132
147
|
index.archives = {};
|
|
133
148
|
index.archives[name] = { ...(index.archives[name] || {}), toolTablePreset };
|
|
134
149
|
}
|
|
150
|
+
// 快捷提示词勾选集(0.18.0):同样存进**档案**。空数组也要落 —— 它说的是"进入后全部停用",
|
|
151
|
+
// 与"没绑"(不碰这一域)是相反的意思。
|
|
152
|
+
const quickCreate = quickPromptsArg(args);
|
|
153
|
+
if (quickCreate) {
|
|
154
|
+
if (!index.archives)
|
|
155
|
+
index.archives = {};
|
|
156
|
+
index.archives[name] = { ...(index.archives[name] || {}), quickPrompts: quickCreate };
|
|
157
|
+
}
|
|
135
158
|
await writeIndex(stateDir, index);
|
|
136
159
|
invalidateSnapshot();
|
|
137
160
|
return { ok: true, scene: sceneRecordOf(name, next), ...(collapsed ? { collapsedActive: true } : {}) };
|
|
@@ -204,6 +227,23 @@ export function buildSceneRecordOps(rc) {
|
|
|
204
227
|
else
|
|
205
228
|
delete index.archives[name];
|
|
206
229
|
}
|
|
230
|
+
if (args && args.quickPrompts !== undefined) {
|
|
231
|
+
// 快捷提示词勾选集(0.18.0,用户裁定入口在「修改场景」表单):与工具表方案同一族 ——
|
|
232
|
+
// 绑定存**档案**,这一次读-改-写顺带落掉。`null` = 解绑(这一域回到"保持现状");
|
|
233
|
+
// 空数组 = 绑定了但一条都没勾(进入后全部停用),**不是**解绑。不传这个参数 = 完全不碰。
|
|
234
|
+
const quick = quickPromptsArg(args);
|
|
235
|
+
if (!index.archives)
|
|
236
|
+
index.archives = {};
|
|
237
|
+
const archive = { ...((index.archives && index.archives[name]) || {}) };
|
|
238
|
+
if (quick === null)
|
|
239
|
+
delete archive.quickPrompts;
|
|
240
|
+
else if (quick !== undefined)
|
|
241
|
+
archive.quickPrompts = quick;
|
|
242
|
+
if (Object.keys(archive).length > 0)
|
|
243
|
+
index.archives[name] = archive;
|
|
244
|
+
else
|
|
245
|
+
delete index.archives[name];
|
|
246
|
+
}
|
|
207
247
|
if (args && args.label !== undefined && name !== GLOBAL_SCENE) {
|
|
208
248
|
const label = String(args.label).trim();
|
|
209
249
|
if (label === '')
|
package/lib/ops/scene-sync.js
CHANGED
|
@@ -2,6 +2,56 @@
|
|
|
2
2
|
//
|
|
3
3
|
// rules 域的**其余 op 由 ./memories/service.ts 直接 spread 进表**,不经过本文件 —— 这里只放
|
|
4
4
|
// 四个要走「场景 ↔ 全局基线同步」包装的写 op。
|
|
5
|
+
import { GLOBAL_SCENE, SHARED_GROUP } from '../memories/constants.js';
|
|
6
|
+
/** 参数形态不归本层管(多场景 / 非法名 / `all`):交给原 op 校验并报错,运行时一律不动。 */
|
|
7
|
+
const SKIP = Symbol('skip');
|
|
8
|
+
/**
|
|
9
|
+
* 「启用集合」与「当前模式」是同一个意图的两片状态:`active` 决定记忆与绑定的提示词按哪个
|
|
10
|
+
* 场景注入,`mode.scene` 决定运行时(MCP / 技能 / 人设 / 工具表 / 快捷词)停在哪份档案上。
|
|
11
|
+
* 两者必须恒等 —— 历史上每次只有一个动,就出现过一个方向各坏一次:拨开关不应用档案(面板
|
|
12
|
+
* 0.9.x 的旧账,见 45-scenes.js 的 ⚠️ 注释),以及 `/` 菜单进 / 出场景只写 `active`
|
|
13
|
+
* (用户 2026-10-01 报,图见 CHANGELOG)。
|
|
14
|
+
*
|
|
15
|
+
* 所以这里**在服务端兜住这条不变量**,而不是要求每个调用方各开两枪:`rules-set-active` 是
|
|
16
|
+
* "我要让哪个场景生效"的那个 op,四个调用方(面板开关、`/` 菜单、模型工具、以后新增的入口)
|
|
17
|
+
* 只要调它,运行时就会跟着对齐。
|
|
18
|
+
*
|
|
19
|
+
* 顺序是**先切运行时、后写启用集合**:引擎会校验场景存在、没被锁、绑的工具表方案还在,
|
|
20
|
+
* 它拒绝时启用集合必须一个字都不动。反过来(先写 active)留下的正是"开关过去了、运行时没切"
|
|
21
|
+
* 那个半截状态 —— 也就是这一版要消灭的东西。
|
|
22
|
+
*/
|
|
23
|
+
async function syncModeToActive(args, deps) {
|
|
24
|
+
const want = targetSceneOf(args);
|
|
25
|
+
if (want === SKIP)
|
|
26
|
+
return null;
|
|
27
|
+
if (want === (await deps.currentModeScene()))
|
|
28
|
+
return null;
|
|
29
|
+
return deps.sceneModeSet({ scene: want });
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* 从 `scenes:[...]` 里取出"这一次要让哪个场景生效"(`null` = 一个都不让 = 退出模式)。
|
|
33
|
+
*
|
|
34
|
+
* 保留名的过滤与去重和 `rulesSetActive` **同源**(同一份常量,不是抄一份):`_shared` 与
|
|
35
|
+
* `global` 恒常生效,不进显式集合 —— 所以 `['global','_shared']` 与原 op 一样读成"清空"。
|
|
36
|
+
* 收敛后仍多于一个 = 单选模型下的非法请求,原 op 会拒,这里回 SKIP 让运行时一动不动
|
|
37
|
+
* (宁可少切一次,也不要按一个原 op 马上要拒绝的参数去改运行时)。
|
|
38
|
+
*/
|
|
39
|
+
function targetSceneOf(args) {
|
|
40
|
+
if (args && args.all === true)
|
|
41
|
+
return SKIP;
|
|
42
|
+
const raw = args && args.scenes;
|
|
43
|
+
if (!Array.isArray(raw))
|
|
44
|
+
return SKIP;
|
|
45
|
+
const names = [];
|
|
46
|
+
for (const item of raw) {
|
|
47
|
+
const name = String(item == null ? '' : item).trim();
|
|
48
|
+
if (!name || name === SHARED_GROUP || name === GLOBAL_SCENE)
|
|
49
|
+
continue;
|
|
50
|
+
if (names.indexOf(name) < 0)
|
|
51
|
+
names.push(name);
|
|
52
|
+
}
|
|
53
|
+
return names.length > 1 ? SKIP : (names[0] ?? null);
|
|
54
|
+
}
|
|
5
55
|
export function buildSceneSyncOps(deps) {
|
|
6
56
|
return {
|
|
7
57
|
// 场景 ↔ 全局基线(AGENTS.md)同步:用户裁定「切换场景,对应的提示词直接把 AGENTS.md
|
|
@@ -9,7 +59,20 @@ export function buildSceneSyncOps(deps) {
|
|
|
9
59
|
// 提示词写进 `~/.dsh/AGENTS.md`(或关掉场景时恢复进场景前的基线),结果并进响应
|
|
10
60
|
// 的 `agentsMd` 字段(`applied` / `restored` / `unchanged` / `error`)。
|
|
11
61
|
// 组装时放在 memoriesService.ops 的 spread **之后**,显式覆盖同名 op。
|
|
12
|
-
|
|
62
|
+
//
|
|
63
|
+
// `rules-set-active` 额外承担「两轴恒等」(见文件头):先把运行时切到位,再写启用集合。
|
|
64
|
+
'rules-set-active': async (args) => {
|
|
65
|
+
const mode = await syncModeToActive(args, deps);
|
|
66
|
+
if (mode && mode.ok === false)
|
|
67
|
+
return mode;
|
|
68
|
+
const res = await deps.rulesOps['rules-set-active'](args);
|
|
69
|
+
if (!res || res.ok === false)
|
|
70
|
+
return res;
|
|
71
|
+
// 引擎跳过的档案键(本机已不存在的服务器 / 技能键)并进来给调用方 —— 面板与 `/` 都要
|
|
72
|
+
// 那句"进了,但没全进"。
|
|
73
|
+
const stale = mode && Array.isArray(mode.stale) && mode.stale.length ? mode.stale : null;
|
|
74
|
+
return deps.withAgentsMdSync(stale ? Object.assign({}, res, { modeStale: stale }) : res);
|
|
75
|
+
},
|
|
13
76
|
'rules-update-scene': async (args) => {
|
|
14
77
|
const res = await deps.rulesOps['rules-update-scene'](args);
|
|
15
78
|
// 工具表方案绑定改动(0.15.0):改的是**当前启用场景**且这次真的带了这一参数时即时生效;
|
|
@@ -20,6 +83,13 @@ export function buildSceneSyncOps(deps) {
|
|
|
20
83
|
}
|
|
21
84
|
catch { /* 即时生效失败不拦保存;下次进/退场景会对齐 */ }
|
|
22
85
|
}
|
|
86
|
+
// 快捷提示词勾选集同理:只有这次真的带了这一参数才动(不传 = 不碰这一域)。
|
|
87
|
+
if (res && res.ok !== false && deps.onQuickPromptsChanged && args && args.quickPrompts !== undefined) {
|
|
88
|
+
try {
|
|
89
|
+
await deps.onQuickPromptsChanged(String(args.name || ''), Array.isArray(args.quickPrompts) ? args.quickPrompts.map((x) => String(x)) : null);
|
|
90
|
+
}
|
|
91
|
+
catch { /* 同上 */ }
|
|
92
|
+
}
|
|
23
93
|
return deps.withAgentsMdSync(res);
|
|
24
94
|
},
|
|
25
95
|
'rules-create-scene': async (args) => deps.withAgentsMdSync(await deps.rulesOps['rules-create-scene'](args)),
|
package/lib/ops/sessions.js
CHANGED
|
@@ -205,9 +205,13 @@ export function buildSessionOps(deps) {
|
|
|
205
205
|
if (!sessionId)
|
|
206
206
|
return { ok: false, error: '缺少 sessionId' };
|
|
207
207
|
try {
|
|
208
|
-
|
|
208
|
+
// 不接 registry 的返回值:官方 `unarchiveSession` 是 `Promise<void>`,而界面只要
|
|
209
|
+
// ok / error / workspace —— 每次单条恢复都把整个归档集合回传一遍纯属浪费。
|
|
210
|
+
// 0.18.5 之前这里读 `r.archivedSessionIds`,于是宿主恢复**成功之后**构造回执时抛
|
|
211
|
+
// TypeError,界面弹「操作失败」,用户以为没恢复、其实已经恢复了。
|
|
212
|
+
await registry.unarchiveSession(sessionId);
|
|
209
213
|
const workspace = await deps.restoreWorkspaceAccounting([sessionId]);
|
|
210
|
-
return { ok: true,
|
|
214
|
+
return { ok: true, sessionId, ...(workspace ? { workspace } : {}) };
|
|
211
215
|
}
|
|
212
216
|
catch (e) {
|
|
213
217
|
return { ok: false, error: deps.message(e) };
|
|
@@ -278,7 +282,8 @@ export function buildSessionOps(deps) {
|
|
|
278
282
|
try {
|
|
279
283
|
const r = await registry.unarchiveSessions(parsed.target);
|
|
280
284
|
const workspace = await deps.restoreWorkspaceAccounting(r.unarchivedSessionIds || []);
|
|
281
|
-
|
|
285
|
+
// 只报「这次恢复了哪些」,不把整个归档集合快照发回界面(同上,界面不读它)。
|
|
286
|
+
return { ok: true, unarchivedSessionIds: r.unarchivedSessionIds, ...(workspace ? { workspace } : {}) };
|
|
282
287
|
}
|
|
283
288
|
catch (e) {
|
|
284
289
|
return { ok: false, error: deps.message(e) };
|
|
@@ -392,15 +397,15 @@ export function buildSessionOps(deps) {
|
|
|
392
397
|
// 打包用已在依赖里的 `fflate.zipSync`(此前只用了 unzipSync)。
|
|
393
398
|
'bundle-export': async (args) => {
|
|
394
399
|
const kind = String((args && args.kind) || '');
|
|
395
|
-
const KINDS = ['skills', 'subagents', 'presets', 'memories'];
|
|
400
|
+
const KINDS = ['skills', 'subagents', 'presets', 'quick-prompts', 'memories'];
|
|
396
401
|
if (KINDS.indexOf(kind) < 0)
|
|
397
402
|
return { ok: false, error: `kind 需为 ${KINDS.join(' / ')}` };
|
|
398
403
|
const rawNames = (args && args.names) || [];
|
|
399
404
|
const names = Array.isArray(rawNames) ? rawNames.map((s) => String(s).trim()).filter(Boolean) : [];
|
|
400
405
|
if (!names.length)
|
|
401
406
|
return { ok: false, error: '请至少选择一项' };
|
|
402
|
-
//
|
|
403
|
-
//
|
|
407
|
+
// 名字逐项校验:子智能体、提示词预设与快捷提示词这三个域的名字会被拼成绝对路径直接进
|
|
408
|
+
// readFile,`..` 或路径分隔符能读到根外(此前只做 trim().filter(Boolean))。
|
|
404
409
|
// 另外两个域**不套单段谓词**,原因不同:
|
|
405
410
|
// - 记忆:名字是 `<场景>/<名>` 的 id,只用来查索引拿路径,本身不参与拼接;
|
|
406
411
|
// - 技能:名字可以是多段相对路径(只读来源的 bundle 用 relative() 拼出名字),
|
|
@@ -408,7 +413,7 @@ export function buildSessionOps(deps) {
|
|
|
408
413
|
// 匹配不上就进 missing,套单段谓词反而会把合法的多段技能名误杀。
|
|
409
414
|
const rejected = [];
|
|
410
415
|
const accepted = names.filter((name) => {
|
|
411
|
-
const ok = kind === 'presets' ? isValidPresetId(name)
|
|
416
|
+
const ok = kind === 'presets' || kind === 'quick-prompts' ? isValidPresetId(name)
|
|
412
417
|
: kind === 'subagents' ? validPersonaName(name)
|
|
413
418
|
: true;
|
|
414
419
|
if (!ok)
|
|
@@ -441,16 +446,20 @@ export function buildSessionOps(deps) {
|
|
|
441
446
|
entries.push({ zip: `${name}.md`, abs });
|
|
442
447
|
}
|
|
443
448
|
}
|
|
444
|
-
else if (kind === 'presets') {
|
|
449
|
+
else if (kind === 'presets' || kind === 'quick-prompts') {
|
|
450
|
+
// 两个提示词域同形(一 id 一目录、正文 + `meta.json` 描述侧车),差别只在根目录与
|
|
451
|
+
// 正文文件名 —— 走同一段代码,别让"导出快捷词"变成第二份会漏改的拷贝。
|
|
452
|
+
const root = kind === 'quick-prompts' ? deps.quickPromptsDir : deps.promptsDir;
|
|
453
|
+
const doc = kind === 'quick-prompts' ? 'PROMPT.md' : 'AGENTS.md';
|
|
445
454
|
for (const id of accepted) {
|
|
446
|
-
const docAbs = join(
|
|
447
|
-
if (!isInsideRoot(
|
|
455
|
+
const docAbs = join(root, id, doc);
|
|
456
|
+
if (!isInsideRoot(root, docAbs)) {
|
|
448
457
|
missing.push(id);
|
|
449
458
|
continue;
|
|
450
459
|
}
|
|
451
|
-
entries.push({ zip: `${id}
|
|
460
|
+
entries.push({ zip: `${id}/${doc}`, abs: docAbs });
|
|
452
461
|
// 描述侧车(预设那句说明)随预设一起走:导出再导入不该把它丢掉。
|
|
453
|
-
const metaAbs = join(
|
|
462
|
+
const metaAbs = join(root, id, 'meta.json');
|
|
454
463
|
try {
|
|
455
464
|
if ((await stat(metaAbs)).isFile())
|
|
456
465
|
entries.push({ zip: `${id}/meta.json`, abs: metaAbs });
|
|
@@ -560,6 +569,8 @@ export function buildSessionOps(deps) {
|
|
|
560
569
|
},
|
|
561
570
|
// 把选中的归档会话导出为可再导入的转录文件(Markdown / JSONL),写入指定目录。
|
|
562
571
|
// 每个会话一个文件;读不到正文的冷会话列入 skipped,不中断其余导出。
|
|
572
|
+
// `detail` 三档**累进**:`simple`(默认)只有正文;`tools` 另加工具调用与结果、附件名;
|
|
573
|
+
// `full` 再加思考。
|
|
563
574
|
'history-export': async (args) => {
|
|
564
575
|
const rawIds = (args && args.sessionIds) || [];
|
|
565
576
|
const ids = Array.isArray(rawIds) ? rawIds.map((s) => String(s).trim()).filter(Boolean) : [];
|
|
@@ -568,6 +579,11 @@ export function buildSessionOps(deps) {
|
|
|
568
579
|
const format = String((args && args.format) || 'markdown').toLowerCase();
|
|
569
580
|
if (format !== 'markdown' && format !== 'jsonl')
|
|
570
581
|
return { ok: false, error: 'format 需为 markdown 或 jsonl' };
|
|
582
|
+
// 收到不认识的档位就出声:静默降级会让用户以为导出的是带工具的版本,而它其实只有正文。
|
|
583
|
+
const rawDetail = String((args && args.detail) || 'simple').toLowerCase();
|
|
584
|
+
if (rawDetail !== 'simple' && rawDetail !== 'tools' && rawDetail !== 'full')
|
|
585
|
+
return { ok: false, error: 'detail 需为 simple / tools / full' };
|
|
586
|
+
const detail = rawDetail;
|
|
571
587
|
const outDir = String((args && args.outDir) || '').trim();
|
|
572
588
|
if (!/^([A-Za-z]:[\\/]|\\\\|\/)/.test(outDir))
|
|
573
589
|
return { ok: false, error: '导出目录需为绝对路径' };
|
|
@@ -623,9 +639,13 @@ export function buildSessionOps(deps) {
|
|
|
623
639
|
const path = join(outDir, fileName);
|
|
624
640
|
try {
|
|
625
641
|
const events = await readEvents(sessionId);
|
|
626
|
-
const turns = deps.extractTurnsFromEvents(events || []);
|
|
642
|
+
const turns = deps.extractTurnsFromEvents(events || [], detail);
|
|
627
643
|
if (!turns.length) {
|
|
628
|
-
|
|
644
|
+
// 简洁档现在**不含思考与工具**(0.18.5 之前思考被混在正文里),所以"只有思考没正文"
|
|
645
|
+
// 的会话会第一次变成导不出来 —— 得说清楚是档位问题,不是内容丢了。
|
|
646
|
+
skipped.push({ sessionId, error: !events ? '无法读取会话内容(不在活动存储且无持久化句柄)'
|
|
647
|
+
: detail !== 'full' ? '会话中没有可导出的正文(思考与工具调用不计入本档 —— 换更高一档可以导出它们)'
|
|
648
|
+
: '会话中没有可导出的消息' });
|
|
629
649
|
continue;
|
|
630
650
|
}
|
|
631
651
|
// `'wx'`:绝不覆盖已存在的同名文件 —— 覆盖是静默的,用户会以为导出成功
|