dsh-plugin-tool-management 0.19.1 → 0.20.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 +915 -829
- package/README.md +237 -254
- package/README_EN.md +234 -223
- 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/images/logo.gif +0 -0
- package/docs/update.md +32 -30
- package/lib/client.js +9480 -6970
- package/lib/compat/preset-reach.js +5 -2
- package/lib/context-inject-frame.js +13 -8
- package/lib/hub.js +104 -11
- package/lib/index.js +72 -24
- package/lib/mcp/manager.js +45 -13
- package/lib/mcp/secret-guard.js +2 -2
- package/lib/mcp/state-section.js +1 -1
- package/lib/memories/archive-engine.js +4 -5
- package/lib/memories/constants.js +11 -4
- package/lib/memories/service.js +6 -1
- package/lib/op-registry.js +13 -11
- package/lib/ops/memory.js +42 -4
- package/lib/ops/scene-records.js +43 -9
- package/lib/ops/sessions.js +67 -46
- package/lib/ops/trash.js +107 -8
- package/lib/request-gate.js +9 -2
- package/lib/scene-prompt-sync.js +48 -23
- package/lib/sessions/history.js +32 -22
- package/lib/sessions/workspace.js +0 -10
- package/lib/skills/catalog.js +17 -5
- package/lib/skills/core.js +189 -7
- package/lib/skills/frontmatter.js +31 -0
- package/lib/skills/manager-state.js +73 -3
- package/lib/skills/service.js +53 -13
- package/lib/skills/skill-roots.js +54 -4
- package/lib/skills/skill-trash.js +118 -12
- package/lib/skills/upload-pipeline.js +110 -26
- package/lib/subagents/catalog.js +9 -5
- package/lib/subagents/service.js +44 -6
- package/lib/subagents/tools.js +45 -15
- package/lib/tool-table-switch.js +6 -6
- package/lib/tools/deps.js +43 -0
- package/lib/tools/mcp.js +74 -27
- package/lib/tools/memory.js +49 -9
- package/lib/tools/prompt.js +38 -9
- package/lib/tools/remove.js +206 -0
- package/lib/tools/scene.js +155 -32
- package/lib/tools/skills.js +162 -55
- package/lib/tools/subagent.js +65 -31
- package/lib/tools/table.js +39 -12
- package/lib/trash-id.js +16 -0
- package/lib/trash-retention.js +3 -3
- package/package.json +115 -114
- package/lib/mcp/inspect-probe.js +0 -199
package/lib/memories/service.js
CHANGED
|
@@ -65,7 +65,7 @@ import { deriveFromDoc, projectRule, relocateLegacyLayout, buildSnapshot, probeS
|
|
|
65
65
|
* 场景/记忆根目录名($DSH_HOME 下),集中在 `tool-management/` 一个目录内:
|
|
66
66
|
* - `memories/<场景>/…` 记忆正文真源
|
|
67
67
|
* - `..`(即 tool-management 根)侧车:memories-index.json(内含 `scenes` 切片 = 场景记录
|
|
68
|
-
* 的**真源**)/ skills-state.json / trash
|
|
68
|
+
* 的**真源**)/ skills-state.json / trash/(六类回收站,含 `trash/memories-trash/`)
|
|
69
69
|
*
|
|
70
70
|
* `scenes/`(`SCENES_DIR`)是 v0.3 遗留的**空壳**:只创建、只回显路径,没有任何读写。
|
|
71
71
|
* 别把场景记录的位置写成它。
|
|
@@ -728,6 +728,7 @@ export function createMemoriesService(ctx, deps) {
|
|
|
728
728
|
// 抽出去的 ops/*.ts 只通过它访问闭包,任何漏挂都会在 tsc 阶段报「Cannot find name」。
|
|
729
729
|
const rc = {
|
|
730
730
|
stateDir, memoriesRoot, scenesRoot, maxBytes,
|
|
731
|
+
...(deps.skillsTrashDir ? { skillsTrashDir: deps.skillsTrashDir } : {}),
|
|
731
732
|
snapshot, invalidateSnapshot, sceneMemory, scenePrompt,
|
|
732
733
|
parseId, locateRule, refuseOutsideRoot, buildProjected, sceneRows,
|
|
733
734
|
copyIntoMemoriesTrash, listAttachments, serializeRuleFile, serializeUpdatedFile,
|
|
@@ -759,6 +760,10 @@ export function createMemoriesService(ctx, deps) {
|
|
|
759
760
|
'rules-remove': (args) => runWrite(() => trashOps.rulesRemove(args || {})),
|
|
760
761
|
'rules-restore': (args) => runWrite(() => trashOps.rulesRestore(args || {})),
|
|
761
762
|
'rules-trash-list': () => trashOps.rulesTrashList(),
|
|
763
|
+
// 回收站「详情」:跨域只读预览(记忆 / 技能 / 提示词 / 人设 / 场景五类的条目都能查)。
|
|
764
|
+
// 只 lstat + 递归读文件,不写任何东西 ⇒ 不进写队列、也不 refresh 快照。
|
|
765
|
+
// 登记在 op-registry 的 `readonly` 一档:**锁定期间照样能看**(锁的是写,不是看)。
|
|
766
|
+
'trash-preview': (args) => trashOps.trashPreview(args || {}),
|
|
762
767
|
'rules-trash-remove': (args) => runWrite(() => trashOps.rulesTrashRemove(args || {})),
|
|
763
768
|
'rules-attach': (args) => runWrite(() => memoryOps.rulesAttach(args || {})),
|
|
764
769
|
'rules-detach': (args) => runWrite(() => memoryOps.rulesDetach(args || {})),
|
package/lib/op-registry.js
CHANGED
|
@@ -57,12 +57,8 @@ export const OP_REGISTRY = Object.freeze({
|
|
|
57
57
|
// 会停在启用态 —— 是写不是读。(0.6.0 / 0.7.0 各有漏列前科,见文件头。)
|
|
58
58
|
'mcpm-tools-refresh': { write: true },
|
|
59
59
|
'mcpm-tools': { readonly: true },
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
// 同口径;`command` 明文,理由同 `mcpm-list`:命令名不是凭据,`args` 两边都不回)。
|
|
63
|
-
// 与 `mcpm-list` 同档,不带令牌 —— 0.16.6 之前这里回的是 URL 原文,等于让免令牌的请求
|
|
64
|
-
// 拿到 `?api_key=…`,绕过了 `mcpm-reveal` 那道防线(审查 P0-5,0.17.0 修)。
|
|
65
|
-
'mcpm-inspect': { readonly: true },
|
|
60
|
+
// 把粘贴的补丁片段读成条目:纯文本运算,不读盘也不写盘(客户端在浏览器里解析不了 YAML)。
|
|
61
|
+
'mcpm-parse-patch': { readonly: true },
|
|
66
62
|
// ── 技能域(22,含内联的 skill-open)───────────────────────────────────────
|
|
67
63
|
'skill-state': { annotatesLock: true },
|
|
68
64
|
'skill-detail': { readonly: true },
|
|
@@ -78,8 +74,10 @@ export const OP_REGISTRY = Object.freeze({
|
|
|
78
74
|
'skill-prefer': { serviceWrite: true, frozen: true },
|
|
79
75
|
'skill-unprefer': { serviceWrite: true, frozen: true },
|
|
80
76
|
'skill-create': { serviceWrite: true, frozen: true },
|
|
81
|
-
//
|
|
82
|
-
//
|
|
77
|
+
// 改写本插件自己写过的那一份技能(`skill_manager_save` 的改分支)。与 create 同规格:
|
|
78
|
+
// 落点只限本插件可写的两个根 —— hub(导入落点)+ dsh(新建默认落点,2026-10-15 裁定,
|
|
79
|
+
// 见 service.ts 的 PLUGIN_WRITABLE_ROOTS)。它**不动**官方根里的技能 —— 那层边界在
|
|
80
|
+
// service 的胜出者判定里。
|
|
83
81
|
'skill-update': { serviceWrite: true, frozen: true },
|
|
84
82
|
'skill-import': { serviceWrite: true, frozen: true },
|
|
85
83
|
'skill-upload': { serviceWrite: true, frozen: true },
|
|
@@ -88,6 +86,9 @@ export const OP_REGISTRY = Object.freeze({
|
|
|
88
86
|
'skill-trash-delete': { serviceWrite: true, frozen: true },
|
|
89
87
|
'skill-custom-add': { serviceWrite: true, frozen: true },
|
|
90
88
|
'skill-custom-remove': { serviceWrite: true, frozen: true },
|
|
89
|
+
// 永久删除一条**按约定发现**的来源记录(agents / codex / claude):只写本地状态里的
|
|
90
|
+
// `forgottenSources`,磁盘源文件一个字节都不动。与 `skill-custom-remove` 同权。
|
|
91
|
+
'skill-source-forget': { serviceWrite: true, frozen: true },
|
|
91
92
|
// 打开系统编辑器改文件:不改宿主状态,但它是"界面按钮"性质的写入口,按写门禁。
|
|
92
93
|
'skill-open': { write: true },
|
|
93
94
|
// ── 记忆与场景域(27:memories 24 + archive-engine 3)──────────────────────
|
|
@@ -96,6 +97,9 @@ export const OP_REGISTRY = Object.freeze({
|
|
|
96
97
|
'rules-budget': { readonly: true },
|
|
97
98
|
'rules-diagnose': { readonly: true },
|
|
98
99
|
'rules-trash-list': { readonly: true },
|
|
100
|
+
// 回收站「详情」:跨域只读(五类回收站共用一条,域由 args.domain 区分)。只读 → 不冻,
|
|
101
|
+
// 锁定的场景里照样能看一眼"删掉的是什么"(看不是改,与「编辑页可打开」同一条口径)。
|
|
102
|
+
'trash-preview': { readonly: true },
|
|
99
103
|
'rules-create': { serviceWrite: true, frozen: true },
|
|
100
104
|
'rules-update': { serviceWrite: true, frozen: true },
|
|
101
105
|
'rules-remove': { serviceWrite: true, frozen: true },
|
|
@@ -171,16 +175,14 @@ export const OP_REGISTRY = Object.freeze({
|
|
|
171
175
|
'quickprompt-remove': { write: true, frozen: true },
|
|
172
176
|
'quickprompt-trash-restore': { write: true, frozen: true },
|
|
173
177
|
'quickprompt-trash-delete': { write: true, frozen: true },
|
|
174
|
-
// ── 历史会话域(
|
|
178
|
+
// ── 历史会话域(14)────────────────────────────────────────────────────────
|
|
175
179
|
'history-list': { readonly: true },
|
|
176
180
|
'history-sessions': { readonly: true },
|
|
177
181
|
'history-export-defaults': { readonly: true },
|
|
178
182
|
'history-retention-get': { readonly: true },
|
|
179
183
|
'dir-list': { readonly: true },
|
|
180
|
-
'history-archive': { write: true },
|
|
181
184
|
'history-unarchive': { write: true },
|
|
182
185
|
'history-delete': { write: true },
|
|
183
|
-
'history-archive-batch': { write: true },
|
|
184
186
|
'history-unarchive-batch': { write: true },
|
|
185
187
|
'history-delete-batch': { write: true },
|
|
186
188
|
'history-retention-set': { write: true },
|
package/lib/ops/memory.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
//
|
|
6
6
|
// ops 表里的包装(enqueueMutation / runWrite)**留在 service.ts**:本文件只导出裸 op,
|
|
7
7
|
// 包装归属集中在一处,改门禁时一眼看全。
|
|
8
|
-
import { lstat, mkdir, readFile, rm } from 'node:fs/promises';
|
|
8
|
+
import { lstat, mkdir, readFile, rename, rm } from 'node:fs/promises';
|
|
9
9
|
import { basename, dirname, join } from 'node:path';
|
|
10
10
|
import { parseSkillDoc, unquote } from '../skills/core.js';
|
|
11
11
|
import { expandUploads, planMemoryImport } from '../imports/upload.js';
|
|
@@ -535,15 +535,46 @@ export function buildMemoryOps(rc) {
|
|
|
535
535
|
await copyIntoMemoriesTrash(located, parts.group, parts.name);
|
|
536
536
|
await rm(join(memoriesRoot, parts.group, parts.name + '.md'), { force: true });
|
|
537
537
|
}
|
|
538
|
+
else if (located.kind === 'bundle' && newName !== parts.name) {
|
|
539
|
+
// bundle 改名 = **目录连同附件一起改**(2026-10-14 对抗性审查 P0-4)。
|
|
540
|
+
//
|
|
541
|
+
// 此前这条落到下面的 else:只把 frontmatter/正文写回**旧**路径,而索引照新名迁移 ——
|
|
542
|
+
// 实测(临时目录内复现)结果是索引里多一条指向不存在路径的记录(`rules-read` 报
|
|
543
|
+
// notFound),真实条目反而丢了索引记录,于是它承载的 `enabled` / `order` / `tags`
|
|
544
|
+
// 一起丢失:**一条停用的 bundle 记忆改个名就被静默重新启用**(重新进入每轮注入)。
|
|
545
|
+
// 面板的编辑框走同一个 op(`client/47-memory.js` 的 `submitUpdate`),所以两边同一条路。
|
|
546
|
+
//
|
|
547
|
+
// 形状与 flat 改名对齐:旧形态先整份进回收站(可恢复),再搬目录,最后把正文改名。
|
|
548
|
+
const oldDir = located.entryPath;
|
|
549
|
+
const newDir = join(memoriesRoot, parts.group, newName);
|
|
550
|
+
// 只改大小写时新旧是同一个目录(Windows / macOS 不区分大小写),不能按"已存在"拒。
|
|
551
|
+
const caseOnly = newName.toLowerCase() === parts.name.toLowerCase();
|
|
552
|
+
if (!caseOnly && await pathExists(newDir)) {
|
|
553
|
+
return fail('error.rules.exists', `同名记忆已存在(bundle):${parts.group}/${newName}`);
|
|
554
|
+
}
|
|
555
|
+
// 正文文件名:新建的 bundle 一律 `<名>.md`;旧数据可能是 `SKILL.md`,那份**保持原名不动**
|
|
556
|
+
// (发现侧两个候选都认,改名时顺手动它只会把兼容数据弄坏)。
|
|
557
|
+
const keepLegacyDoc = basename(located.docPath) === LEGACY_BUNDLE_DOC;
|
|
558
|
+
await copyIntoMemoriesTrash(located, parts.group, parts.name);
|
|
559
|
+
await rename(oldDir, newDir);
|
|
560
|
+
const movedDoc = join(newDir, basename(located.docPath));
|
|
561
|
+
const targetDoc = join(newDir, keepLegacyDoc ? LEGACY_BUNDLE_DOC : bundleDocName(newName));
|
|
562
|
+
if (movedDoc !== targetDoc)
|
|
563
|
+
await rename(movedDoc, targetDoc);
|
|
564
|
+
await writeFileAtomically(targetDoc, serialize());
|
|
565
|
+
}
|
|
538
566
|
else {
|
|
539
|
-
// bundle
|
|
567
|
+
// bundle 不改名:只更新 frontmatter/正文
|
|
540
568
|
await writeFileAtomically(located.docPath, serialize());
|
|
541
569
|
}
|
|
542
570
|
// 索引:改名则迁移记录(保留 order/tags/enabled 等),否则原地更新。
|
|
571
|
+
// 判据只看"名字变没变",不再附加 `index.rules[id]` 存在 —— 磁盘上由外部新增、索引里
|
|
572
|
+
// 还没有记录的条目(发现侧会认它)改名时,旧判据会走到 else 把记录写回**旧名**,
|
|
573
|
+
// 于是新名下没有记录、旧名下多一条悬空的(2026-10-14 对抗性审查 P0-4 同族)。
|
|
543
574
|
const index = await readIndex(stateDir);
|
|
544
575
|
const newId = `${parts.group}/${newName}`;
|
|
545
|
-
if (newId !== id
|
|
546
|
-
index.rules[newId] = { ...index.rules[id], updatedAt: new Date().toISOString() };
|
|
576
|
+
if (newId !== id) {
|
|
577
|
+
index.rules[newId] = { ...(index.rules[id] || {}), updatedAt: new Date().toISOString() };
|
|
547
578
|
delete index.rules[id];
|
|
548
579
|
}
|
|
549
580
|
else {
|
|
@@ -565,6 +596,13 @@ export function buildMemoryOps(rc) {
|
|
|
565
596
|
if (typeof (args && args.enabled) !== 'boolean') {
|
|
566
597
|
return fail('error.rules.invalidArgs', '缺少参数:enabled 必须是布尔值(toggle 只按传入值写入,不做"翻转"推断)');
|
|
567
598
|
}
|
|
599
|
+
// 不存在的 id 必须拒绝(2026-10-15 smoke-test P1-4):`parseId` 只校验 `<场景>/<名字>`
|
|
600
|
+
// 的形状,原先据此直接 `index.rules[id] = {...}` —— 于是一次拼错的 id 会**写进一条幽灵
|
|
601
|
+
// 索引**(磁盘上没有正文、清单里也列不出来,索引里却多一条 enabled),调用方还收到 ok。
|
|
602
|
+
// 判据与 `rulesRead` / `rulesUpdate` 同源:`locateRule` 在磁盘上找得到才算存在。
|
|
603
|
+
const located = await locateRule(parts.group, parts.name);
|
|
604
|
+
if (!located)
|
|
605
|
+
return fail('error.rules.notFound', `规则不存在:${id}`);
|
|
568
606
|
const index = await readIndex(stateDir);
|
|
569
607
|
const idxEntry = index.rules[id] || {};
|
|
570
608
|
const enabled = args.enabled === true;
|
package/lib/ops/scene-records.js
CHANGED
|
@@ -514,9 +514,24 @@ export function buildSceneRecordOps(rc) {
|
|
|
514
514
|
async function sceneTrashList() {
|
|
515
515
|
return { ok: true, trash: await listTrashEntries('scenes') };
|
|
516
516
|
}
|
|
517
|
+
/**
|
|
518
|
+
* 同名的场景里第一个空出来的退号名:`甲` → `甲(1)` → `甲(2)`…(全角括号,与界面
|
|
519
|
+
* `uniqueName` 同一写法)。超过段名长度上限的候选直接跳过 —— 退号名本身也得是合法段名。
|
|
520
|
+
*/
|
|
521
|
+
function nextFreeSceneName(index, name) {
|
|
522
|
+
for (let i = 1; i <= 999; i += 1) {
|
|
523
|
+
const candidate = `${name}(${i})`;
|
|
524
|
+
if (!isValidGroupPath(candidate))
|
|
525
|
+
return null;
|
|
526
|
+
if (!index.scenes?.[candidate])
|
|
527
|
+
return candidate;
|
|
528
|
+
}
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
517
531
|
/**
|
|
518
532
|
* 从回收站恢复一个场景:重建记忆目录 + 写回记录与档案。
|
|
519
|
-
*
|
|
533
|
+
* 同名场景已存在时默认**拒绝**(绝不覆盖既有场景);带 `dedupe: true` 时退号恢复
|
|
534
|
+
* (`甲` → `甲(1)`,用户 2026-10-14 裁定四个域都要能退号)。
|
|
520
535
|
*
|
|
521
536
|
* 场景名按**完整路径**寻址(2026-09-30 审查 P2-4,与删除侧同口径):回收站里的名字来自
|
|
522
537
|
* 当初删掉的那条记录,多段名同样得能恢复回来 —— 否则删得掉、恢复不了,数据就卡在回收站里。
|
|
@@ -526,12 +541,26 @@ export function buildSceneRecordOps(rc) {
|
|
|
526
541
|
const entry = await readTrashEntry('scenes', id);
|
|
527
542
|
if (!entry)
|
|
528
543
|
return fail('error.rules.notFound', `回收站条目不存在:${id}`);
|
|
529
|
-
const
|
|
530
|
-
if (!isValidGroupPath(
|
|
531
|
-
return fail('error.rules.invalidGroup', `回收站里的场景名非法:${
|
|
544
|
+
const oldName = String(entry.name || '').trim();
|
|
545
|
+
if (!isValidGroupPath(oldName))
|
|
546
|
+
return fail('error.rules.invalidGroup', `回收站里的场景名非法:${oldName || '(空)'}`);
|
|
532
547
|
const index = await readIndex(stateDir);
|
|
533
|
-
|
|
534
|
-
|
|
548
|
+
let name = oldName;
|
|
549
|
+
if (index.scenes?.[name]) {
|
|
550
|
+
// 「同名先问一句」(用户 2026-10-14):拒绝时把候选名字一起回(`params.next`),
|
|
551
|
+
// 界面弹窗才能如实写出"确认后会叫 `甲(1)`";用户确认后带 `dedupe: true` 重来。
|
|
552
|
+
const next = nextFreeSceneName(index, name);
|
|
553
|
+
if (args?.dedupe !== true || !next) {
|
|
554
|
+
// 专用错误码(四个回收站统一):界面按它认出"这是撞名"并弹确认窗。原先这里复用
|
|
555
|
+
// `error.rules.invalidGroup`,那一条同时还表示"名字非法",界面分不出来。
|
|
556
|
+
return fail('error.dup.conflict', `已存在同名场景:${name}`, {
|
|
557
|
+
name,
|
|
558
|
+
...(next ? { next } : {}),
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
name = next;
|
|
562
|
+
}
|
|
563
|
+
const renamed = name !== oldName;
|
|
535
564
|
const dir = join(memoriesRoot, name);
|
|
536
565
|
// 恢复是**写入**方向:落点若是指向根外的目录链接,`mkdir` 会顺着链接成功、随后
|
|
537
566
|
// `moveOutOfTrash` 把记忆文件写到**根外**去。与删除侧同一个根因(缺 realpath 根内断言),
|
|
@@ -590,17 +619,22 @@ export function buildSceneRecordOps(rc) {
|
|
|
590
619
|
// 记忆条目随文件一起回来(删除时存进 `data.rules`,见 `rulesRemoveScene`)——
|
|
591
620
|
// 否则恢复出来的记忆全部按 `enabled ?? true` 重置,用户停用的会复活(审查 P1-1)。
|
|
592
621
|
// **只补不覆盖**:同名键若已存在以现有为准(上面已拒绝同名场景,正常情况下不会撞)。
|
|
622
|
+
//
|
|
623
|
+
// 退号恢复时这批记忆的 key 也要**跟着改名**:索引里的 key 是 `<场景>/<名>`,照抄旧 key
|
|
624
|
+
// 会让它们挂在旧场景名上 —— 文件躺在 `甲(1)/` 里、索引却指向 `甲/`,恢复出来的场景
|
|
625
|
+
// 一条记忆都看不见。前缀不匹配的(理论上不会有)原样保留,宁可留着也不猜。
|
|
593
626
|
if (data.rules && typeof data.rules === 'object' && !Array.isArray(data.rules)) {
|
|
594
627
|
const rules = index.rules || (index.rules = {});
|
|
595
628
|
for (const [ruleId, ruleEntry] of Object.entries(data.rules)) {
|
|
596
|
-
|
|
597
|
-
|
|
629
|
+
const mapped = renamed && ruleId.startsWith(oldName + '/') ? name + ruleId.slice(oldName.length) : ruleId;
|
|
630
|
+
if (rules[mapped] === undefined)
|
|
631
|
+
rules[mapped] = ruleEntry;
|
|
598
632
|
}
|
|
599
633
|
}
|
|
600
634
|
await writeIndex(stateDir, index);
|
|
601
635
|
await purgeTrashEntry('scenes', id);
|
|
602
636
|
invalidateSnapshot();
|
|
603
|
-
return { ok: true, name, restoredFiles: restored.length };
|
|
637
|
+
return { ok: true, name, renamed, restoredFiles: restored.length };
|
|
604
638
|
}
|
|
605
639
|
/** 永久删除一条场景回收站条目。 */
|
|
606
640
|
async function sceneTrashDelete(args) {
|
package/lib/ops/sessions.js
CHANGED
|
@@ -4,10 +4,13 @@
|
|
|
4
4
|
// 整张表上(见 index.ts)。
|
|
5
5
|
//
|
|
6
6
|
// 本域的 op 名:history-list / history-sessions / history-export-defaults / dir-list /
|
|
7
|
-
// history-
|
|
7
|
+
// history-unarchive / history-delete /
|
|
8
8
|
// history-unarchive-batch / history-delete-batch / history-workspace-register /
|
|
9
9
|
// history-import / bundle-export / history-export / history-retention-get /
|
|
10
10
|
// history-retention-set。
|
|
11
|
+
//
|
|
12
|
+
// 归档(archive)不在这里:那是宿主的原生入口(官方侧栏),本插件只经 `domain/changed`
|
|
13
|
+
// 事件同步归档时刻(见 src/sessions/bridge.ts),不替用户发起归档。
|
|
11
14
|
import { createHash, randomUUID } from 'node:crypto';
|
|
12
15
|
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
13
16
|
import { basename, dirname, join } from 'node:path';
|
|
@@ -18,10 +21,35 @@ import { isValidPresetId } from '../prompts/preset-id.js';
|
|
|
18
21
|
import { defaultPersonasDir, validPersonaName } from '../subagents/service.js';
|
|
19
22
|
import { isInsideRoot } from '../paths.js';
|
|
20
23
|
import { planMemoryExport } from '../memories/service.js';
|
|
24
|
+
/**
|
|
25
|
+
* Linux 的桌面目录:按 XDG 用户目录规范从 `user-dirs.dirs` 里读 `XDG_DESKTOP_DIR`。
|
|
26
|
+
*
|
|
27
|
+
* 为什么需要它:中文 / 日文等本地化环境里,桌面目录的**真名**不是 `Desktop`,而是
|
|
28
|
+
* `桌面` / `デスクトップ` —— 这时 `join(homedir(), 'Desktop')` 探不到,我们会白白退回主目录。
|
|
29
|
+
* 配置文件里的值形如 `"$HOME/桌面"`(含 `$HOME` 变量与引号),这里做最小解析:
|
|
30
|
+
* 去引号、把 `$HOME` / 前导 `~` 换成真实主目录。
|
|
31
|
+
*
|
|
32
|
+
* 读不到 / 没有这一行 / 值不是绝对路径 → 返回 undefined,调用方继续试下一个候选。
|
|
33
|
+
* 纯只读,任何异常都吞掉(这只是"默认值填哪儿"的增强,不该影响导出本身)。
|
|
34
|
+
*/
|
|
35
|
+
async function xdgDesktopDir() {
|
|
36
|
+
try {
|
|
37
|
+
const base = String(process.env.XDG_CONFIG_HOME || '').trim() || join(homedir(), '.config');
|
|
38
|
+
const text = await readFile(join(base, 'user-dirs.dirs'), 'utf8');
|
|
39
|
+
const m = /^\s*XDG_DESKTOP_DIR\s*=\s*"([^"]*)"/m.exec(text);
|
|
40
|
+
if (!m)
|
|
41
|
+
return undefined;
|
|
42
|
+
const raw = m[1].replace(/^\$HOME(?=[\\/]|$)/, homedir()).replace(/^~(?=[\\/]|$)/, homedir());
|
|
43
|
+
return raw && (raw.startsWith('/') || /^[A-Za-z]:[\\/]/.test(raw)) ? raw : undefined;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
21
49
|
export function buildSessionOps(deps) {
|
|
22
50
|
return {
|
|
23
51
|
// History(归档会话管理)ops。只读:history-list / history-retention-get;
|
|
24
|
-
// 写:history-
|
|
52
|
+
// 写:history-unarchive / history-delete / history-retention-set。
|
|
25
53
|
// workspaceRegistry 缺失(补丁未生效)时返回明确错误,不崩页面。
|
|
26
54
|
'history-list': async () => {
|
|
27
55
|
const registry = deps.getSessionsRegistry();
|
|
@@ -114,16 +142,35 @@ export function buildSessionOps(deps) {
|
|
|
114
142
|
return { ok: false, error: deps.message(e) };
|
|
115
143
|
}
|
|
116
144
|
},
|
|
117
|
-
//
|
|
145
|
+
// 导入 / 导出的默认目录:桌面(存在时)否则用户主目录。只读,不门控。
|
|
146
|
+
//
|
|
147
|
+
// 名字里带 `history` 是历史原因,但它早就是**全插件共用的**默认 IO 目录:整机迁移导出
|
|
148
|
+
// / 快照位置 / 会话导出 / MCP JSON 导出 / 技能自定义来源目录都取这一份。之所以必须问宿主、
|
|
149
|
+
// 不能让客户端写死:桌面在哪取决于**跑宿主的那台机器 + 那个用户**——
|
|
150
|
+
// Windows C:\Users\<用户名>\Desktop
|
|
151
|
+
// macOS /Users/<用户名>/Desktop
|
|
152
|
+
// Linux /home/<用户名>/Desktop,且桌面目录**未必存在**(也可能是本地化名字,
|
|
153
|
+
// 例如中文环境下的 ~/桌面 —— 那种走 XDG 配置,见下)
|
|
154
|
+
// 全部用 `os.homedir()` 起算,换用户 / 换系统自动跟着走。
|
|
118
155
|
'history-export-defaults': async () => {
|
|
119
156
|
let dir = homedir();
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
157
|
+
// Linux 的桌面目录名可能被本地化:先按 XDG 用户目录规范问一次,再退回惯用的 Desktop。
|
|
158
|
+
// 两条都探不到就用主目录 —— 主目录一定存在,且导出的只是子目录,不影响可用性。
|
|
159
|
+
const candidates = [join(homedir(), 'Desktop')];
|
|
160
|
+
if (process.platform === 'linux') {
|
|
161
|
+
candidates.unshift(await xdgDesktopDir());
|
|
162
|
+
}
|
|
163
|
+
for (const candidate of candidates) {
|
|
164
|
+
if (!candidate)
|
|
165
|
+
continue;
|
|
166
|
+
try {
|
|
167
|
+
if ((await stat(candidate)).isDirectory()) {
|
|
168
|
+
dir = candidate;
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
catch { /* 这个候选不可用 → 试下一个 */ }
|
|
125
173
|
}
|
|
126
|
-
catch { /* 桌面路径不可用 → 回退主目录 */ }
|
|
127
174
|
return { ok: true, defaultDir: dir };
|
|
128
175
|
},
|
|
129
176
|
// 列出目录的子目录,供客户端「选择文件夹」弹窗逐级浏览。dir 为空时返回根
|
|
@@ -160,43 +207,6 @@ export function buildSessionOps(deps) {
|
|
|
160
207
|
return { ok: false, error: deps.message(e) };
|
|
161
208
|
}
|
|
162
209
|
},
|
|
163
|
-
'history-archive': async (args) => {
|
|
164
|
-
const registry = deps.getSessionsRegistry();
|
|
165
|
-
if (!registry)
|
|
166
|
-
return { ok: false, error: '归档服务未挂载' };
|
|
167
|
-
const sessionId = String((args && args.sessionId) || '').trim();
|
|
168
|
-
if (!sessionId)
|
|
169
|
-
return { ok: false, error: '缺少 sessionId' };
|
|
170
|
-
try {
|
|
171
|
-
await registry.archiveSession(sessionId);
|
|
172
|
-
return { ok: true, sessionId };
|
|
173
|
-
}
|
|
174
|
-
catch (e) {
|
|
175
|
-
return { ok: false, error: deps.message(e) };
|
|
176
|
-
}
|
|
177
|
-
},
|
|
178
|
-
// 批量归档(导出弹窗「归档所选」):把选中的会话收进 History,纳入保留期管理。
|
|
179
|
-
'history-archive-batch': async (args) => {
|
|
180
|
-
const registry = deps.getSessionsRegistry();
|
|
181
|
-
if (!registry)
|
|
182
|
-
return { ok: false, error: '归档服务未挂载' };
|
|
183
|
-
const rawIds = (args && args.sessionIds) || [];
|
|
184
|
-
const ids = Array.isArray(rawIds) ? rawIds.map((s) => String(s).trim()).filter(Boolean) : [];
|
|
185
|
-
if (!ids.length)
|
|
186
|
-
return { ok: false, error: '请至少选择一个会话' };
|
|
187
|
-
const archived = [];
|
|
188
|
-
const failed = [];
|
|
189
|
-
for (const sessionId of ids) {
|
|
190
|
-
try {
|
|
191
|
-
await registry.archiveSession(sessionId);
|
|
192
|
-
archived.push(sessionId);
|
|
193
|
-
}
|
|
194
|
-
catch (e) {
|
|
195
|
-
failed.push({ sessionId, error: deps.message(e) });
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
return { ok: true, archived, failed };
|
|
199
|
-
},
|
|
200
210
|
'history-unarchive': async (args) => {
|
|
201
211
|
const registry = deps.getSessionsRegistry();
|
|
202
212
|
if (!registry)
|
|
@@ -321,6 +331,17 @@ export function buildSessionOps(deps) {
|
|
|
321
331
|
return { ok: false, error: '当前环境不支持重新登记工作区(registry 未实现 registerWorkspace)' };
|
|
322
332
|
}
|
|
323
333
|
const title = args && args.title ? String(args.title) : undefined;
|
|
334
|
+
// 目录已被删除时提前拦下(用户 2026-10-12:工作区文件夹删掉后不该还能「重新登记」)。
|
|
335
|
+
// 宿主 registerWorkspace 内部的 realpath 本来就会拒绝,但报错文本是底层的;这里给
|
|
336
|
+
// 一句能直接读懂的提示。界面在 dirMissing 时已不再渲染该按钮,这条是防御性兜底。
|
|
337
|
+
try {
|
|
338
|
+
const st = await stat(path);
|
|
339
|
+
if (!st.isDirectory())
|
|
340
|
+
return { ok: false, error: '目录已不存在,无法重新登记:' + path };
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return { ok: false, error: '目录已不存在,无法重新登记:' + path };
|
|
344
|
+
}
|
|
324
345
|
try {
|
|
325
346
|
const workspace = await registry.registerWorkspace(path, title);
|
|
326
347
|
return { ok: true, workspace };
|
package/lib/ops/trash.js
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
// 但共用 copyIntoMemoriesTrash 等入口,经 MemoriesOpsCtx 显式传入。
|
|
5
5
|
import { copyFile, cp, lstat, mkdir, readFile, readdir, rm, stat } from 'node:fs/promises';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
|
-
import { isValidTrashId } from '../hub.js';
|
|
7
|
+
import { isValidTrashId, readTrashPayloadFiles, trashEntryPath } from '../hub.js';
|
|
8
8
|
import { LEGACY_BUNDLE_DOC, bundleDocName, isValidGroupSegment, isValidGroupPath, fail, MEMORIES_TRASH_DIR } from '../memories/constants.js';
|
|
9
9
|
import { readIndex, writeIndex } from '../memories/index-io.js';
|
|
10
|
+
import { isValidSegment } from '../paths.js';
|
|
10
11
|
/** 组装本域 op。rc 由 createMemoriesService 组装,契约见 ./ctx.ts。 */
|
|
11
12
|
export function buildTrashOps(rc) {
|
|
12
13
|
const { buildProjected, copyIntoMemoriesTrash, invalidateSnapshot, locateRule, parseId, refuseOutsideRoot, memoriesRoot, stateDir } = rc;
|
|
@@ -33,6 +34,21 @@ export function buildTrashOps(rc) {
|
|
|
33
34
|
invalidateSnapshot();
|
|
34
35
|
return { ok: true, trashId };
|
|
35
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* 同组里第一个空出来的退号名:`测` → `测(1)` → `测(2)`…(全角括号,与界面 `uniqueName`
|
|
39
|
+
* 同一写法)。超过段名长度上限的候选直接跳过 —— 退号名本身也必须是合法段名,否则
|
|
40
|
+
* `locateRule` 查不到、恢复时还会写出一个磁盘不接受的名字。
|
|
41
|
+
*/
|
|
42
|
+
async function nextFreeRuleName(group, name) {
|
|
43
|
+
for (let i = 1; i <= 999; i += 1) {
|
|
44
|
+
const candidate = `${name}(${i})`;
|
|
45
|
+
if (!isValidGroupSegment(candidate))
|
|
46
|
+
return null;
|
|
47
|
+
if (!(await locateRule(group, candidate)))
|
|
48
|
+
return candidate;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
36
52
|
async function rulesRestore(args) {
|
|
37
53
|
const trashId = String((args && args.trashId) || '');
|
|
38
54
|
if (!isValidTrashId(trashId))
|
|
@@ -55,11 +71,29 @@ export function buildTrashOps(rc) {
|
|
|
55
71
|
|| (manifest.group !== '' && !isValidGroupPath(manifest.group))) {
|
|
56
72
|
return fail('error.rules.notFound', `回收站条目损坏:${trashId}`);
|
|
57
73
|
}
|
|
58
|
-
// 恢复目标已存在(期间用户重建了同名规则)→
|
|
74
|
+
// 恢复目标已存在(期间用户重建了同名规则)→ 默认拒绝,避免覆盖。
|
|
75
|
+
//
|
|
76
|
+
// 「同名先问一句」(用户 2026-10-14):界面上不该静默退号、也不该只丢一句错误让人自己
|
|
77
|
+
// 回去改名。所以拒绝时**连候选名字一起回**(`params.next`)—— 弹窗才能如实写出"确认后
|
|
78
|
+
// 会叫 `测(1)`"。用户点确认后带 `dedupe: true` 再来一次,这一次真的按退号的名字恢复。
|
|
79
|
+
//
|
|
80
|
+
// `dedupe` 是**可选入参、缺省即旧行为**:模型工具(memory_manager_*)走的是同一个 op,
|
|
81
|
+
// 它们依赖"撞名就拒"来决定新建还是更新,不能因为界面想要退号就改掉这条语义。
|
|
59
82
|
const conflict = await locateRule(manifest.group, manifest.name);
|
|
83
|
+
let name = manifest.name;
|
|
60
84
|
if (conflict) {
|
|
61
|
-
|
|
85
|
+
const next = await nextFreeRuleName(manifest.group, manifest.name);
|
|
86
|
+
if (args?.dedupe !== true || !next) {
|
|
87
|
+
// 专用错误码(四个回收站统一):界面按它认出"这是撞名",据此弹确认窗 —— 而不是靠
|
|
88
|
+
// 比文案。`error.rules.exists` 留给 `rules-create`,那条是"新建撞名",语义不同。
|
|
89
|
+
return fail('error.dup.conflict', `已存在同名记忆:${conflict.id}`, {
|
|
90
|
+
name: manifest.name,
|
|
91
|
+
...(next ? { next } : {}),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
name = next;
|
|
62
95
|
}
|
|
96
|
+
const renamed = name !== manifest.name;
|
|
63
97
|
const form = manifest.form === 'bundle' ? 'bundle' : 'flat';
|
|
64
98
|
// 落点根内断言(realpath 口径):`isValidGroupPath` 只挡 `..` 与分隔符,挡不住
|
|
65
99
|
// 「中间目录是指向根外的链接」。与 `rulesRemove` 的删除前断言同一道闸。
|
|
@@ -73,8 +107,8 @@ export function buildTrashOps(rc) {
|
|
|
73
107
|
// 去删目录。场景回收站(`sceneTrashRestore`)早有这条回滚(`moveIntoTrash` 反向搬回),
|
|
74
108
|
// 记忆回收站这条缺了。这里方向相反:源还在回收站,所以回滚是**删掉没放完整的副本**。
|
|
75
109
|
const target = form === 'bundle'
|
|
76
|
-
? join(groupDir,
|
|
77
|
-
: join(groupDir,
|
|
110
|
+
? join(groupDir, name)
|
|
111
|
+
: join(groupDir, name + '.md');
|
|
78
112
|
try {
|
|
79
113
|
await mkdir(groupDir, { recursive: true });
|
|
80
114
|
if (form === 'bundle')
|
|
@@ -101,10 +135,17 @@ export function buildTrashOps(rc) {
|
|
|
101
135
|
}
|
|
102
136
|
await rm(trashDir, { recursive: true, force: true });
|
|
103
137
|
invalidateSnapshot();
|
|
104
|
-
|
|
138
|
+
// 回传**最终落盘的名字**:退号恢复时它与回收站里的原名不同,界面要按它来回执
|
|
139
|
+
// (见 42-shared-ui.js 的 `resultRestore`),否则用户看到的是"已恢复:测"而磁盘上叫「测(1)」。
|
|
140
|
+
return {
|
|
141
|
+
ok: true,
|
|
142
|
+
name,
|
|
143
|
+
renamed,
|
|
144
|
+
rule: await buildProjected(manifest.group ? `${manifest.group}/${name}` : name),
|
|
145
|
+
};
|
|
105
146
|
}
|
|
106
147
|
/**
|
|
107
|
-
* 记忆回收站列表(只读):`<stateDir>/memories-trash/<trashId>/`(`rulesRemove` 移入,
|
|
148
|
+
* 记忆回收站列表(只读):`<stateDir>/trash/memories-trash/<trashId>/`(`rulesRemove` 移入,
|
|
108
149
|
* `rulesRestore` 恢复)。损坏或内容缺失的条目跳过,不让一个坏条目挡住整份列表。
|
|
109
150
|
*/
|
|
110
151
|
async function rulesTrashList() {
|
|
@@ -172,5 +213,63 @@ export function buildTrashOps(rc) {
|
|
|
172
213
|
await rm(dir, { recursive: true, force: true });
|
|
173
214
|
return { ok: true };
|
|
174
215
|
}
|
|
175
|
-
|
|
216
|
+
/**
|
|
217
|
+
* 回收站「详情」(只读):把一条条目的内容读出来给界面看一眼"删掉的到底是什么"。
|
|
218
|
+
*
|
|
219
|
+
* 用户 2026-10-12:「回收站的每条信息应该增加一个详情按键,点开就能看这个信息是什么样子,
|
|
220
|
+
* 不能更改内容,没有保存按键」。所以这里**只有读**:不写盘、不改名、不动回收站条目。
|
|
221
|
+
*
|
|
222
|
+
* 为什么是一条跨域的 op 而不是五个域各加一条:五个回收站落在**三套不同的目录布局**里 ——
|
|
223
|
+
* hub 四类:`<hub>/trash/<kind>-trash/<id>/`(`trashEntryPath`)
|
|
224
|
+
* 记忆: `<stateDir>/trash/memories-trash/<trashId>/`
|
|
225
|
+
* 技能: `<hub>/trash/skills-trash/<id>/`(skills 域的 `trashRootPath()`,由 index 注入)
|
|
226
|
+
* 但"读出来给人看"这件事完全一样,交给 `readTrashPayloadFiles` 一个函数做完,
|
|
227
|
+
* 上限 / 二进制判据 / 哪些名字算清单只写一遍。
|
|
228
|
+
*
|
|
229
|
+
* 条目目录不存在 / 不是目录 → 明确报"条目不存在"(与"目录在、但里面没有可显示的文件"
|
|
230
|
+
* 分开:前者是条死链,后者是一条正常但没内容的记录)。读不动的单个文件由
|
|
231
|
+
* `readTrashPayloadFiles` 自己跳过,不抛 —— 详情打不开不该影响用户对这条目做恢复 / 永久删除。
|
|
232
|
+
*/
|
|
233
|
+
async function trashPreview(args) {
|
|
234
|
+
const domain = String((args && args.domain) || '');
|
|
235
|
+
const id = String((args && args.id) || '').trim();
|
|
236
|
+
const hubKinds = { presets: 'prompts', quick: 'quick-prompts', agents: 'subagents', scenes: 'scenes' };
|
|
237
|
+
let dir = null;
|
|
238
|
+
if (hubKinds[domain] || domain === 'memories') {
|
|
239
|
+
// hub 四类与记忆回收站的条目名都由 `newTrashId()` 生成(时间戳 base36 + uuid 前 8 位),
|
|
240
|
+
// 走那一套严格白名单 —— 它同时也是 `rulesRestore` / 硬删除用的同一道闸。
|
|
241
|
+
if (!isValidTrashId(id))
|
|
242
|
+
return fail('error.rules.notFound', `回收站条目不存在:${id}`);
|
|
243
|
+
dir = hubKinds[domain]
|
|
244
|
+
? trashEntryPath(hubKinds[domain], id)
|
|
245
|
+
: join(stateDir, MEMORIES_TRASH_DIR, id);
|
|
246
|
+
}
|
|
247
|
+
else if (domain === 'skills') {
|
|
248
|
+
// ⚠️ 技能回收站的条目名是 `${Date.now()}-${randomUUID()}`(见 core.ts 的移入回收站那段),
|
|
249
|
+
// **不满足** `isValidTrashId` 那条白名单(多段连字符 + 大写十六进制)—— 拿它去判会把
|
|
250
|
+
// 每一条技能条目都判成"不存在",界面上就是"详情点了没反应"。
|
|
251
|
+
// 这里换成与 `skill-trash.ts` 的 `entryPath` 同一个口径:能安全当**一个路径段**即可
|
|
252
|
+
//(不含分隔符 / 不是 `.` `..` / 首尾无空白与点)。以点开头的一律拒 —— `listTrash()`
|
|
253
|
+
// 本来就把 `.stage-*` 那类临时目录排除在外,界面拿不到它们,这里也不该读得到。
|
|
254
|
+
if (!isValidSegment(id, 128) || id.startsWith('.'))
|
|
255
|
+
return fail('error.rules.notFound', `回收站条目不存在:${id}`);
|
|
256
|
+
dir = rc.skillsTrashDir ? join(rc.skillsTrashDir, id) : null;
|
|
257
|
+
}
|
|
258
|
+
else
|
|
259
|
+
return fail('error.rules.notFound', `这个回收站不支持查看详情:${domain || '(空)'}`);
|
|
260
|
+
if (!dir)
|
|
261
|
+
return fail('error.rules.notFound', `回收站条目不存在:${id}`);
|
|
262
|
+
// 条目目录本身必须真实存在(`readTrashPayloadFiles` 对不存在的目录返回空数组,
|
|
263
|
+
// 那种"空"与"条目不存在"要分开:前者能恢复、后者是条死链)。
|
|
264
|
+
try {
|
|
265
|
+
const st = await lstat(dir);
|
|
266
|
+
if (!st.isDirectory() || st.isSymbolicLink())
|
|
267
|
+
return fail('error.rules.notFound', `回收站条目不存在:${id}`);
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
return fail('error.rules.notFound', `回收站条目不存在:${id}`);
|
|
271
|
+
}
|
|
272
|
+
return { ok: true, id, domain, files: await readTrashPayloadFiles(dir) };
|
|
273
|
+
}
|
|
274
|
+
return { rulesRemove, rulesRestore, rulesTrashList, rulesTrashRemove, trashPreview };
|
|
176
275
|
}
|
package/lib/request-gate.js
CHANGED
|
@@ -138,13 +138,20 @@ export function createSceneLock(deps) {
|
|
|
138
138
|
return null;
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
|
-
/**
|
|
141
|
+
/**
|
|
142
|
+
* 「当前场景已锁定」的拒绝文案(模型工具用;无活动场景 / 未锁定 → null)。
|
|
143
|
+
*
|
|
144
|
+
* ⚠️ 读者是**模型**,不是面板用户:这句只陈述事实(哪个场景、被拒、可预期的规则),
|
|
145
|
+
* **不指路 UI**。「到场景页解锁」是给坐在界面前的人看的,模型照着做不了 —— 它只会
|
|
146
|
+
* 把这句读成"再试一次也许就好了",于是重试同一次写入。要用户去解锁,由模型转述。
|
|
147
|
+
* (与 `src/tools/*.ts` 里那批回执同一条纪律;面板侧的中文报错是另一回事,那些读者是人。)
|
|
148
|
+
*/
|
|
142
149
|
async function lockedSceneGuard() {
|
|
143
150
|
const scene = await activeSceneName();
|
|
144
151
|
if (!scene)
|
|
145
152
|
return null;
|
|
146
153
|
const locked = await lockedSceneNames();
|
|
147
|
-
return locked.includes(scene) ? `场景「${scene}
|
|
154
|
+
return locked.includes(scene) ? `场景「${scene}」已锁定,本次写入被拒:锁定期间该域不可修改,需用户解锁后再试。` : null;
|
|
148
155
|
}
|
|
149
156
|
return { lockedSceneNames, activeSceneName, lockedSceneGuard };
|
|
150
157
|
}
|