dsh-plugin-tool-management 0.13.0 → 0.14.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 +45 -0
- package/README.md +91 -123
- package/README_EN.md +99 -126
- 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 +28 -0
- package/lib/client.js +578 -124
- package/lib/compat/preset-reach.js +6 -3
- package/lib/context-inject.js +86 -32
- package/lib/index.js +251 -92
- package/lib/mcp/manager.js +3 -14
- package/lib/mcp/secret-guard.js +21 -0
- package/lib/mcp/state-section.js +13 -11
- package/lib/memories/constants.js +72 -8
- package/lib/memories/index-io.js +1 -1
- package/lib/memories/projection.js +67 -31
- package/lib/memories/service.js +17 -2
- package/lib/memories/snapshot.js +113 -22
- package/lib/op-registry.js +8 -2
- package/lib/ops/candidates.js +11 -0
- package/lib/ops/compat.js +10 -2
- package/lib/ops/sessions.js +1 -1
- package/lib/prompts/service.js +25 -2
- package/lib/scenes/candidates.js +56 -0
- package/lib/skills/core.js +93 -0
- package/lib/skills/service.js +34 -2
- package/lib/subagents/service.js +37 -4
- package/lib/subagents/tools.js +11 -4
- package/lib/tools/deps.js +15 -0
- package/lib/tools/mcp.js +291 -50
- package/lib/tools/memory.js +131 -80
- package/lib/tools/prompt.js +27 -13
- package/lib/tools/scene.js +351 -0
- package/lib/tools/skills.js +93 -17
- package/lib/tools/subagent.js +56 -45
- package/lib/tools/table.js +204 -15
- package/package.json +108 -108
package/lib/mcp/state-section.js
CHANGED
|
@@ -21,13 +21,12 @@
|
|
|
21
21
|
// 不列的三类:**没开**(用户明确要求)、**工具全被停用**(模型调不到,留着只会谎报数量)、
|
|
22
22
|
// **从未连上过**(缓存也空 —— 一直没成功过,不该反复打扰;这条抄的是 Claude Code 的通知
|
|
23
23
|
// 策略:一直没连上的不提示,昨天还好今天挂的才提示)。
|
|
24
|
-
// - 只列 **server 名 +
|
|
24
|
+
// - 只列 **server 名 + 状态(仅②档)+ 备注**;**具体工具名绝不注入** ——
|
|
25
25
|
// 已启用 server 的工具本来就在模型自己的工具 schema 里(`mcp__<server>__<tool>`),
|
|
26
26
|
// 段里再列一遍是重复;模型按前缀就能对上号。
|
|
27
|
-
// -
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
// - 无备注 → 只有名字 + 工具数(正是「不写就只知道名字」)。
|
|
27
|
+
// - **工具数不列**(2026-09-23):模型不能凭"这台有 N 个工具"做任何事(能调哪些在它自己的
|
|
28
|
+
// schema 里),而 17 B/台 × 每轮的开销是白付的。
|
|
29
|
+
// - 无备注 → 只有名字(①档)或名字 + 状态(②档)。
|
|
31
30
|
// - 不写「已启用」字样:能进段的都是可用的,标状态是废话;②档则必须标,否则就是谎报。
|
|
32
31
|
// - 两档都空 → 返回 `''`(renderPrompt 删空段,零 token 成本)。
|
|
33
32
|
//
|
|
@@ -108,11 +107,14 @@ export function renderMcpStateSection(rows, opts = {}) {
|
|
|
108
107
|
const shownOffline = [...offline].sort(byName).slice(0, cap);
|
|
109
108
|
const lineOf = (r, count, isOffline) => {
|
|
110
109
|
const name = String(r.serverName ?? r.id);
|
|
111
|
-
// ②档必须把状态写出来:不写就等于谎报"可用"
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
110
|
+
// ②档必须把状态写出来:不写就等于谎报"可用"。
|
|
111
|
+
//
|
|
112
|
+
// 2026-09-23:两档的**工具数都去掉了**(用户看到实际注入后要求精简)。数字对模型没有
|
|
113
|
+
// 动作价值 —— 它不能凭"这台有 8 个工具"列举出工具名(真正能调的都在它自己的工具
|
|
114
|
+
// schema 里,按 `mcp__<server>__` 前缀就能对上号),而每轮都要付 17 B/台。
|
|
115
|
+
// 顺带消掉一处隐患:②档原来写「上次连上时 N 个工具」,那个数字是**历史事实**,措辞上
|
|
116
|
+
// 必须和现状区分开;数字没了,这个歧义也就不存在了。
|
|
117
|
+
const head = isOffline ? '- **' + name + '**(当前未连上)' : '- **' + name + '**';
|
|
116
118
|
const note = normalizeMcpNote(r.notes, noteMaxLength);
|
|
117
119
|
return note ? head + ' — ' + MCP_NOTE_PREFIX + note : head;
|
|
118
120
|
};
|
|
@@ -120,7 +122,7 @@ export function renderMcpStateSection(rows, opts = {}) {
|
|
|
120
122
|
...shownUsable.map((r) => lineOf(r, liveEnabledOf(r), false)),
|
|
121
123
|
...shownOffline.map((r) => lineOf(r, knownOf(r), true)),
|
|
122
124
|
];
|
|
123
|
-
//
|
|
125
|
+
// 只有清单:标题(`# 本机 MCP 服务器的当前状态`)由注入通道的框架承担
|
|
124
126
|
// (2026-09-18 起,见 context-inject.ts 的 DOMAIN_FRAME)—— 同一件事只有一个出处,
|
|
125
127
|
// 框架已经写了标题,正文再写一遍就是每步多付一行 token。
|
|
126
128
|
const out = [...lines];
|
|
@@ -17,7 +17,16 @@ export const MAX_RULE_BYTES = 1 << 18; // 正文上限 256 KiB
|
|
|
17
17
|
export const DEFAULT_ORDER = 1000; // 默认投影 order(索引无记录时)
|
|
18
18
|
export const DEFAULT_GROUP_ORDER = 1000; // 新场景默认 order
|
|
19
19
|
export const SNAPSHOT_TTL_MS = 1000; // 读路径短 TTL 缓存,吸收 UI 密集轮询
|
|
20
|
-
export const DEFAULT_MAX_BYTES = 1 << 17; //
|
|
20
|
+
export const DEFAULT_MAX_BYTES = 1 << 17; // 记忆段预算上限(字节,=128 KiB)
|
|
21
|
+
/**
|
|
22
|
+
* 场景段(`scene-manager-catalog`)的预算上限(字节,= 4 KiB)。
|
|
23
|
+
*
|
|
24
|
+
* 比记忆段小两个数量级是**有意的**:场景段只列"启用的**非保留**场景 + 场景说明",而场景是
|
|
25
|
+
* **单选**的 —— 除保留场景(`global` / `_shared`,恒常生效、不列)外至多一个启用,正常不到
|
|
26
|
+
* 1 KiB;一个具体场景都没启用时整段不注入(默认状态不占上下文)。给它一个大预算等于给一份
|
|
27
|
+
* 永远用不到的保险;真有场景描述被写爆的那天,截断标记会说清(`renderSceneCatalog`)。
|
|
28
|
+
*/
|
|
29
|
+
export const SCENE_CATALOG_MAX_BYTES = 1 << 12;
|
|
21
30
|
// ── 保留场景名 ─────────────────────────────────────────────────────────────
|
|
22
31
|
/** 保留场景名:界面显示「全局」,恒定存在、不可删除,其记忆注入任何对话。 */
|
|
23
32
|
export const GLOBAL_SCENE = 'global';
|
|
@@ -44,12 +53,18 @@ export const ATTACHMENT_LIST_MAX = 10;
|
|
|
44
53
|
* - **不点名任何工具**:模型从工具 schema 就知道 `memory_manager_list` 存在,点名反而
|
|
45
54
|
* 像在提示它去调;用户裁定「没启用的信息就是不想在当前用」,所以工具指引整句删除。
|
|
46
55
|
* 真正防探测的是**完整性声明**(「以下就是全部信息」),那半句必须留。
|
|
56
|
+
* ⚠️ **那半句已在 2026-09-23 第四次裁定里被删**(见下)—— 所以本段现在**没有**任何
|
|
57
|
+
* 防探测手段,这是有意识付掉的代价,不是漏改。
|
|
47
58
|
* - **完整性声明按截断状态自适应**:真有条目因预算没注入时,段尾会有未注入清单,
|
|
48
59
|
* 此时不能再声称「全部」,否则和清单自相矛盾 —— 也正因为那时确实有东西没给到,
|
|
49
60
|
* 模型去查工具是**合理**的,不该再拦。
|
|
61
|
+
* ⚠️ 声明删掉后这套自适应**失去意义**(两版会完全相同),已合并成一个常量,
|
|
62
|
+
* `renderBody` 的 `dropped` 分叉一并移除。
|
|
50
63
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
64
|
+
* 用词:不用「常驻」「注入」这类内部行话(模型没有先验)。
|
|
65
|
+
* ⚠️ 「用『信息』而不是『记忆』」那条**已被 2026-09-23 第四次裁定推翻**(见下):用户
|
|
66
|
+
* 给定的定稿文案里就是「记忆」,而板块标题本来就是「本机当前的记忆」—— 两者同名反而更好,
|
|
67
|
+
* 当时那条理由(「记忆」在系统提示词里指代不明)在板块标题定名之后也不成立了。
|
|
53
68
|
*
|
|
54
69
|
* 2026-09-17 重写(用户:「当前模式会不重视这些提示词」)。上一版的三个毛病都在**授权**
|
|
55
70
|
* 上,而不在措辞好不好看上:
|
|
@@ -70,17 +85,66 @@ export const ATTACHMENT_LIST_MAX = 10;
|
|
|
70
85
|
* "If a recalled memory conflicts with current information, trust what you observe now"
|
|
71
86
|
* —— 书里(ch11:25)说记忆是 "working notes, not gospel"。上一版把两类塞进一句授权,
|
|
72
87
|
* 对**场景说明**(用户写的约定)是对的,对**记忆条目**(可能是几个月前记下的事实)是错的。
|
|
73
|
-
*
|
|
74
|
-
*
|
|
88
|
+
*
|
|
89
|
+
* 2026-09-23:两类内容**拆成两个注入段**(用户裁定):`scene-manager-catalog`(场景段)
|
|
90
|
+
* 讲"当前启用的是哪个场景、它是什么",`memory-manager-catalog`(记忆段)用 `SCENE_MEMORY_NOTE`
|
|
91
|
+
* 给**记录**的授权。拆段的收益:用户能单独关掉记忆段(省字节)而保留场景说明,且两段各自
|
|
92
|
+
* 完整自洽。代价是场景名在两段各出现一次(**说明只在场景段**,见 `renderSceneCatalog` 的注释)。
|
|
93
|
+
*
|
|
94
|
+
* 2026-09-23 第二次裁定(用户看到**实际注入**之后):**场景段不再带授权语**。上一版把它写成
|
|
95
|
+
* "「场景说明」是用户为本机写的约定,一律照办,覆盖你的默认做法",而这一格的实例是「写代码」
|
|
96
|
+
* 「前端相关」这种**标签** —— 让模型"照办一个标签"正是它读不懂那一段的原因。正文只给
|
|
97
|
+
* "当前启用的是哪个"(`sceneLine`)。
|
|
98
|
+
*
|
|
99
|
+
* 同日晚些时候的第三次裁定把这段又削了一层:替掉授权语的那句"场景是什么"线索(`场景是用户
|
|
100
|
+
* 给这台机器配的工作模式…`)**也删了**,连同六个域的动作句一起(理由见 `DomainFrame`)。
|
|
101
|
+
* 所以这一段最终**没有任何引导语** —— 只有 `sceneLine` 一行。
|
|
75
102
|
*
|
|
76
103
|
* 冲突阶梯(第 4 条)来自 Codex `base_instructions/default.md:22-27` 与 Claude Code 的
|
|
77
104
|
* `caller override > agent definition > parent model > default`:把"谁高于谁"写明,
|
|
78
105
|
* 模型才不会在「用户当场说的 ≠ 本机记录」时悬空。写明它还有一个反直觉的好处 ——
|
|
79
106
|
* 它让授权更可信:这说明本条不是要让记忆压过用户,只是要压过模型的默认假设。
|
|
107
|
+
*
|
|
108
|
+
* 2026-09-23 第三次裁定(用户看到实际注入后:「这段话很多没用信息,浪费 token 和上下文」):
|
|
109
|
+
* **压缩措辞,五个功能一个不动** —— ① 这是什么(本机记录)、② 可能已过期、③ 与实际情况冲突
|
|
110
|
+
* 时以实际情况为准、④ 与用户当场说的冲突时以用户为准、⑤ 无关不必提 + 完整性声明(防探测,
|
|
111
|
+
* 见上,那半句是整句里唯一不能删的)。77 → 48 字符(219 → 144 字节),而它**每个场景块各来
|
|
112
|
+
* 一次**,所以省的是 N 倍。删掉的只有修饰与重复:「以下是用户为本机写的记录」→「本机记录」
|
|
113
|
+
* (板块标题 `# 本机当前的记忆` 已经把"这是本机的记忆"说过了),两句"冲突时以…为准"
|
|
114
|
+
* 合成一句。
|
|
115
|
+
*
|
|
116
|
+
* 2026-09-23 第四次裁定(用户直接给定稿文案,逐字):
|
|
117
|
+
* 「以下是当前场景对应的记忆;冲突时以实际情况和用户目前的对话为准;无关勿提。」
|
|
118
|
+
* 相对第三版有三处变化,都不是措辞偏好:
|
|
119
|
+
* ① 「本机记录」→「以下是**当前场景对应的**记忆」:说清这是**哪个范围**的记忆。记忆本来
|
|
120
|
+
* 就按场景组织,而这一段列出的条目确实全来自"当前启用的场景 + 两个保留桶" —— 比
|
|
121
|
+
* "本机记录"准确,也解释了为什么下面的条目按 `## 场景:X` 分组。
|
|
122
|
+
* ② 「用户当场说的」→「用户**目前的对话**」:同一个意思,后者更贴注入发生的场合。
|
|
123
|
+
* ③ **删掉「可能已过期」,以及完整性声明「以下就是全部信息」**。
|
|
124
|
+
* - 「可能已过期」:用户判断它与紧接的「冲突时以实际情况为准」重复 —— 后半句已经把
|
|
125
|
+
* "记录可能不准"这层说全了。接受。
|
|
126
|
+
* - 「以下就是全部信息」:一度删掉(当时记下的后果是"本段不再声称自己完整,模型在
|
|
127
|
+
* 清单里找不到某件事时可能去调 `memory_manager_list` 找更多"),**同一天又加回来了**
|
|
128
|
+
* —— 见下面的第五次裁定。
|
|
129
|
+
* 因为不再有完整性声明,第三版"按截断状态选长短两版"的自适应失去意义(两版会完全相同),
|
|
130
|
+
* 所以**两个常量合并成一个**。
|
|
131
|
+
*
|
|
132
|
+
* 2026-09-23 第五次裁定(用户看到改后的实际注入,两处):
|
|
133
|
+
* ① **完整性声明加回来**,措辞改成收尾的 `;以下就是全部信息:`(句号 → 分号 + 冒号)。
|
|
134
|
+
* 它引向下文,比原来独立成句的「以下就是全部信息。」更像一句引导语 —— 而它承担的
|
|
135
|
+
* **防探测**功能原样保留(那正是 2026-09-17 那条"必须留"的裁定要的)。
|
|
136
|
+
* 所以 `SCENE_MEMORY_NOTE_PARTIAL` 与 `dropped` 分叉**没有恢复**:这一版是"全量"口吻
|
|
137
|
+
* 的收尾语,真截断时确实会与未注入清单矛盾 —— 但那是预算被配到极小时的极端情形
|
|
138
|
+
* (默认 128 KiB,正常永远碰不到),先不为此重加一层结构。
|
|
139
|
+
* ② **`global` 的分组标题从 `## 场景:全局` 改成 `## 常驻信息`**(见 `sceneHeading`):
|
|
140
|
+
* 全局是恒常生效的保留桶,不是"一个场景",写成场景会让模型以为它是当前场景的另一种
|
|
141
|
+
* 取值。
|
|
142
|
+
*/
|
|
143
|
+
/**
|
|
144
|
+
* 记忆段(`memory-manager-catalog`)的授权语:条目是**记录**(权威等级低于"用户当场说的")。
|
|
145
|
+
* 放在**板块层**(第一个场景块之前),整段只出现一次 —— 见 `renderBody` 尾部。
|
|
80
146
|
*/
|
|
81
|
-
export const SCENE_MEMORY_NOTE = '
|
|
82
|
-
/** 有未注入条目时的版本:去掉完整性声明(见上)。 */
|
|
83
|
-
export const SCENE_MEMORY_NOTE_PARTIAL = '**用户为本机写的参考信息:「场景说明」是用户的约定,一律照办,覆盖你的默认做法;其余条目是记录,可能已过期 —— 与当前实际情况冲突时以你看到的为准,与用户当场说的冲突时以用户为准。无关时不必提及。**';
|
|
147
|
+
export const SCENE_MEMORY_NOTE = '**以下是当前场景对应的记忆;冲突时以实际情况和用户目前的对话为准;无关勿提;以下就是全部信息:**';
|
|
84
148
|
// ── bundle 与索引 ──────────────────────────────────────────────────────────
|
|
85
149
|
// bundle 附件限制(body 走 HTTP JSON + base64,故比技能上传收紧一档)。
|
|
86
150
|
export const MAX_ATTACH_ENTRY_BYTES = 8 << 20; // 单个附件 8 MiB
|
package/lib/memories/index-io.js
CHANGED
|
@@ -254,7 +254,7 @@ export async function readIndex(stateDir) {
|
|
|
254
254
|
*
|
|
255
255
|
* ⚠️ 返回的可能是**缓存实例**,调用方只许读;写索引一律走异步 `readIndex`(它每次重新解析,
|
|
256
256
|
* 拿到新对象)。当前两个调用方(`sceneMemory` / `resolveScenePreset`)及其下游
|
|
257
|
-
* (`signatureOfIndex` / `resolveActiveScenes` / `
|
|
257
|
+
* (`signatureOfIndex` / `resolveActiveScenes` / `sceneLine` / `compareSceneBuckets` /
|
|
258
258
|
* `sceneOrderOf`)都已确认只读 —— 新增调用方请保持这条。
|
|
259
259
|
*/
|
|
260
260
|
export const indexSyncCache = new Map();
|
|
@@ -101,7 +101,7 @@ export function signatureOfIndex(index) {
|
|
|
101
101
|
});
|
|
102
102
|
const groups = Object.keys(index.groups).sort().map((g) => `${g}\u0000${index.groups[g]?.order ?? DEFAULT_GROUP_ORDER}`);
|
|
103
103
|
// 场景顺序决定段内场景的先后 → 必须进指纹,否则改顺序后段文本不会重算。
|
|
104
|
-
// label 与 description
|
|
104
|
+
// label 与 description 同理(`sceneLine` 把 label 与 description 都渲染进正文)——
|
|
105
105
|
// 手改索引文件(带外变更)时只有指纹变化才会触发重算。
|
|
106
106
|
const scenes = Object.keys(index.scenes || {}).sort().map((s) => {
|
|
107
107
|
const e = index.scenes[s];
|
|
@@ -126,34 +126,42 @@ export function sceneLabel(scene, index) {
|
|
|
126
126
|
const label = index?.scenes?.[scene]?.label;
|
|
127
127
|
return label && label !== '' ? label : scene;
|
|
128
128
|
}
|
|
129
|
-
/** 单个场景的标题:**场景在最顶层**(`##`,与「子智能体」「MCP 服务器」等段同级)。 */
|
|
130
|
-
export const sceneHeading = (scene) => `## 场景:${sceneLabel(scene)}`;
|
|
131
129
|
/**
|
|
132
|
-
*
|
|
133
|
-
* 统一成"每个场景块都有场景说明")。
|
|
130
|
+
* 记忆段里的场景**分组**标题。
|
|
134
131
|
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
132
|
+
* `global` 是恒常生效的保留桶,不是"一个场景" —— 叫它 `## 场景:全局` 会让模型以为"全局"
|
|
133
|
+
* 是当前场景的另一种取值(用户 2026-09-23:「全局不需要写成场景」)。所以它单独成一种标题:
|
|
134
|
+
* `## 常驻信息`。其余场景仍是 `## 场景:X`。
|
|
135
|
+
*
|
|
136
|
+
* `_shared`(历史保留名)保持 `## 场景:_shared` 不动 —— 它只在旧数据里出现,且没有
|
|
137
|
+
* "全局"这种"它不是场景"的语义歧义(它确实曾是一个共享场景)。
|
|
137
138
|
*/
|
|
138
|
-
export const
|
|
139
|
-
: scene === SHARED_GROUP ? '共享记忆,任何对话都生效'
|
|
140
|
-
: '用户配置的上下文,当前启用');
|
|
139
|
+
export const sceneHeading = (scene) => scene === GLOBAL_SCENE ? '## 常驻信息' : `## 场景:${sceneLabel(scene)}`;
|
|
141
140
|
/**
|
|
142
|
-
*
|
|
141
|
+
* 场景段(`scene-manager-catalog`)里的一行:`**「<场景名>」—— 用户描述:<场景说明>**`。
|
|
142
|
+
*
|
|
143
|
+
* 2026-09-23 用户裁定,替换了原来的两行式(`## 场景:X` + `**场景说明:X**`)。三条理由都是
|
|
144
|
+
* 用户看到**实际注入**之后提的:
|
|
145
|
+
* - **场景名只出现一次**。上一版里它出现三次(框架线索"现在处在哪个场景"、引导语里的
|
|
146
|
+
* 「代码」、标题里的 `## 场景:代码`),而说明行只是把同一件事换个标签再说一遍。
|
|
147
|
+
* - **场景说明是「标签」,不是「约定」**。它答的是"这个场景是干什么的",用户的实例就是
|
|
148
|
+
* 「写代码」「前端相关」。上一版把它写成"一律照办、覆盖你的默认做法",等于要求模型
|
|
149
|
+
* "照办一个标签" —— 那正是它读不懂这一段的根源。授权语已删,说明只作注解跟在名字后面。
|
|
150
|
+
* - **没填说明就不带后缀**。上一版会补 `defaultSceneDescription` 的默认句,而
|
|
151
|
+
* "用户配置的上下文,当前启用"贴在名字后面,读起来像一句真的说明。
|
|
152
|
+
*
|
|
153
|
+
* `用户描述:` 这个前缀是同日追加的(用户:「防止模型误解这是场景名或者其它」):破折号后面
|
|
154
|
+
* 那截的形态本来有歧义 —— `**「代码」—— 写代码**` 读起来像"名字 —— 别名",而它其实是
|
|
155
|
+
* **用户写的一句说明**。加四个字把来源点明,模型就不会把它当成场景的另一个名字或某种标识符。
|
|
156
|
+
* 前缀只在真有描述时出现(没填描述时整截不出现,不存在"用户描述:"后面空着)。
|
|
143
157
|
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
* 也从没把它标成「只给使用者看」(对比 AGENTS.md 预设的描述,那里是明确标注的)。
|
|
147
|
-
* 描述为空时给 `defaultSceneDescription` 的默认句(用户裁定:全局桶没描述时读起来像
|
|
148
|
-
* 缺了一块,统一成每个场景块都有说明)。
|
|
158
|
+
* 为什么不再用 `##` 标题:这一段只讲"当前启用的是哪个 + 它是什么",一行说完就够。
|
|
159
|
+
* 场景**分组**标题仍在记忆段里用(`sceneHeading`),两段各自承担自己的职责。
|
|
149
160
|
*/
|
|
150
|
-
export
|
|
151
|
-
const head = sceneHeading(scene);
|
|
161
|
+
export const sceneLine = (scene, index) => {
|
|
152
162
|
const described = String(index?.scenes?.[scene]?.description ?? '').replaceAll(/\s+/g, ' ').trim();
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
return `${head}\n\n**场景说明:${description}**\n\n`;
|
|
156
|
-
}
|
|
163
|
+
return `**「${sceneLabel(scene)}」${described === '' ? '' : `—— 用户描述:${described}`}**\n\n`;
|
|
164
|
+
};
|
|
157
165
|
/** 场景渲染顺序:全局 `global` 最先(它的记忆对任何对话都成立,先讲总则),
|
|
158
166
|
* 其次 `_shared`(历史保留名),其余按(索引 scenes.order, 场景名)。 */
|
|
159
167
|
export function compareSceneBuckets(a, b, index) {
|
|
@@ -222,6 +230,13 @@ export function attachmentSummarySync(bundleDir, name) {
|
|
|
222
230
|
/**
|
|
223
231
|
* 附件行(只有 bundle 记忆才有):**给目录与文件名,不给内容**。
|
|
224
232
|
* 附件可能是图片、二进制、大 md —— 全文注入又贵又会把段预算吃光;给路径,模型需要时自己读。
|
|
233
|
+
*
|
|
234
|
+
* 2026-09-23 精简(用户看到实际注入后要求):去掉「未注入正文」这半句解释与「共 N 个」里的
|
|
235
|
+
* 计数词 —— 目录 + 文件名已经把"内容不在这里、要读就照路径去读"说清楚了,而这两处纯修饰
|
|
236
|
+
* 每轮都在付钱(实测两行 289 B ≈73 tok/轮,占记忆段的 23%)。总数只在**列不全**时才需要
|
|
237
|
+
* (否则数一下文件名就知道有几个)。
|
|
238
|
+
*
|
|
239
|
+
* **路径必须绝对**:模型拿到它要去 `Read`,相对路径等于没给。所以前缀省不掉。
|
|
225
240
|
*/
|
|
226
241
|
export function attachmentLine(file) {
|
|
227
242
|
if (file.kind !== 'bundle')
|
|
@@ -231,21 +246,41 @@ export function attachmentLine(file) {
|
|
|
231
246
|
return '';
|
|
232
247
|
const shown = names.slice(0, ATTACHMENT_LIST_MAX);
|
|
233
248
|
const more = names.length - shown.length;
|
|
234
|
-
return
|
|
249
|
+
return `附件:${file.bundleDir}(${shown.join('、')}${more > 0 ? ` 等 ${names.length} 个` : ''})`;
|
|
235
250
|
}
|
|
236
251
|
// ── 单条记忆的渲染 ─────────────────────────────────────────────────────────
|
|
237
|
-
/**
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
252
|
+
/**
|
|
253
|
+
* 多行正文的渲染形态:**围栏代码块**,整体缩进 2 格挂在条目下(2026-09-23 用户裁定)。
|
|
254
|
+
*
|
|
255
|
+
* 为什么不是"缩进 2 格就完事"(这是上一版的实现,被实测推翻):markdown 里缩进 ≤3 格的 `#`
|
|
256
|
+
* **仍然算标题**,所以正文里一句手写的 `# 代码/需求` 会渲染成 H1,**盖过容器自己的
|
|
257
|
+
* `## 场景:X`** —— 用户看到的就是这个:内容看起来"逃出了条目",与场景标题平级甚至更高。
|
|
258
|
+
* 缩到 4 格能压住标题(块级标记都要求 ≤3 格缩进),但正文里的列表/引用只是被"压平",
|
|
259
|
+
* 视觉上仍与条目正文同级,达不到"明显从属"。
|
|
260
|
+
*
|
|
261
|
+
* 围栏块一次解决两件事(用户 2026-09-23 明确要这两条):
|
|
262
|
+
* - **不能逃出**:块内一切都是字面量,正文的 `#` / `>` / `---` / ``` 再也无法参与外围结构;
|
|
263
|
+
* - **明显从属**:`<pre>` 的缩进在渲染视图里可见、复制出来也不丢,正文明确挂在条目下。
|
|
264
|
+
*
|
|
265
|
+
* 代价如实记:块内的行内 markdown(`**粗体**`、链接)按字面显示、不再被渲染。记忆正文的定位
|
|
266
|
+
* 是"记录"(可能过期、以实际情况为准),按字面呈现比让它参与排版更贴合这个定位。
|
|
267
|
+
*
|
|
268
|
+
* 围栏用**反引号**且长度取"正文里最长反引号串 + 1"(最少 3 个):正文自带 ``` 时不会把块
|
|
269
|
+
* 提前闭合(闭合需要**至少**与开栏等长的反引号串)。
|
|
270
|
+
*/
|
|
271
|
+
export const bodyBlock = (text) => {
|
|
272
|
+
const longest = (text.match(/`+/g) ?? []).reduce((n, run) => Math.max(n, run.length), 0);
|
|
273
|
+
const fence = '`'.repeat(Math.max(3, longest + 1));
|
|
274
|
+
const lines = text.split('\n').map((line) => (line === '' ? line : ' ' + line));
|
|
275
|
+
return [' ' + fence, ...lines, ' ' + fence].join('\n');
|
|
276
|
+
};
|
|
242
277
|
/** 括号注解用的显式描述:派生描述与正文重复、不进段(只存在于界面投影);换行压成单行,避免把「一行一条」的列表项撑断。 */
|
|
243
278
|
export const explicitDescriptionOf = (f) => (f.descriptionDerived || !f.description ? '' : String(f.description).replaceAll(/\s+/g, ' ').trim());
|
|
244
279
|
/**
|
|
245
280
|
* 单条信息的渲染形态 —— **能一行就一行,但恒为列表项**。
|
|
246
281
|
*
|
|
247
282
|
* 单行且不长的正文 → `- **名称**(描述) — 正文`(与 MCP / 子智能体两个段的列表同形;无显式描述时括号不出现)
|
|
248
|
-
* 多行或过长的正文 → `- **名称**(描述)` + 空行 +
|
|
283
|
+
* 多行或过长的正文 → `- **名称**(描述)` + 空行 + **围栏代码块里的正文**(挂在条目下;理由见 `bodyBlock`)
|
|
249
284
|
*
|
|
250
285
|
* 名称恒为标题:它就是这条记忆的身份(工具 id `<场景>/<名称>`、bundle 目录/文件名都以它为准),
|
|
251
286
|
* 用户说「记忆里的 X」、模型再调 `memory_manager_*` 时都对得上号;显式描述是括号注解,不抢标题。
|
|
@@ -257,7 +292,8 @@ export const explicitDescriptionOf = (f) => (f.descriptionDerived || !f.descript
|
|
|
257
292
|
*
|
|
258
293
|
* 为什么要分两种:用户常有十几条「一句话事实」(「提交格式:PDF」),每条都占标题 + 空行 +
|
|
259
294
|
* 正文三行,整段会散成一长串标题;压成一行后十条信息就是十行。多行正文是**用户写的完整
|
|
260
|
-
* Markdown
|
|
295
|
+
* Markdown**(可能自带标题、代码块、嵌套列表),放进围栏块挂到条目下 —— 上一版让它以普通
|
|
296
|
+
* 段落参与外围排版,结果正文里的 `#` 会盖过 `## 场景:X`(见 `bodyBlock` 的说明)。
|
|
261
297
|
*
|
|
262
298
|
* 返回值带 `inline`:调用方据此决定下一条记忆前要不要空行(单行条目连续排列,其余空行分隔)。
|
|
263
299
|
*/
|
|
@@ -269,7 +305,7 @@ export function memoryBlock(file) {
|
|
|
269
305
|
const inline = text === '' || (!text.includes('\n') && text.length <= INLINE_BODY_MAX);
|
|
270
306
|
const head = text === ''
|
|
271
307
|
? `- ${title}`
|
|
272
|
-
: (inline ? `- ${title} — ${text}` : `- ${title}\n\n${
|
|
308
|
+
: (inline ? `- ${title} — ${text}` : `- ${title}\n\n${bodyBlock(text)}`);
|
|
273
309
|
const attach = attachmentLine(file);
|
|
274
310
|
if (attach === '')
|
|
275
311
|
return { text: `${head}\n`, inline };
|
package/lib/memories/service.js
CHANGED
|
@@ -44,7 +44,7 @@ import { parseSkillDoc, resolveDshHome } from '../skills/core.js';
|
|
|
44
44
|
import { newTrashId } from '../hub.js';
|
|
45
45
|
// 预算 / 保留场景名 / 段渲染常量(2026-09-19 抽到 ./constants.ts:投影函数与本服务共用,
|
|
46
46
|
// 留在这里会让 projection.ts 反向 import 本文件,形成运行时循环依赖)。
|
|
47
|
-
import { bundleDocName, DEFAULT_MAX_BYTES, GLOBAL_SCENE, LEGACY_BUNDLE_DOC, SHARED_GROUP, SNAPSHOT_TTL_MS, isValidGroupPath, isValidGroupSegment, MEMORIES_TRASH_DIR, fail, } from './constants.js';
|
|
47
|
+
import { bundleDocName, DEFAULT_MAX_BYTES, GLOBAL_SCENE, LEGACY_BUNDLE_DOC, SCENE_CATALOG_MAX_BYTES, SHARED_GROUP, SNAPSHOT_TTL_MS, isValidGroupPath, isValidGroupSegment, MEMORIES_TRASH_DIR, fail, } from './constants.js';
|
|
48
48
|
// 对外契约保持在原路径:本文件此前直接 export 这两个,index.ts 等已按此 import。
|
|
49
49
|
export { GLOBAL_SCENE, GLOBAL_SCENE_LABEL } from './constants.js';
|
|
50
50
|
export { isValidGroupSegment, isValidGroupPath, MEMORIES_INDEX_FILE, MEMORIES_TRASH_DIR } from './constants.js';
|
|
@@ -54,7 +54,7 @@ export { parseModeState } from './index-io.js';
|
|
|
54
54
|
import { enabledSceneOf, normalizeActive, resolveActiveScenes, sceneLabel, sceneOf, sceneOrderOf, } from './projection.js';
|
|
55
55
|
// 索引 IO 组与发现/快照组(2026-09-19 抽出)。
|
|
56
56
|
import { writeFileAtomically, isIndexQuarantined, readIndex, readIndexSync, writeIndex } from './index-io.js';
|
|
57
|
-
import { deriveFromDoc, projectRule, relocateLegacyLayout, buildSnapshot, probeSceneFilesSync, renderSceneMemory } from './snapshot.js';
|
|
57
|
+
import { deriveFromDoc, projectRule, relocateLegacyLayout, buildSnapshot, probeSceneFilesSync, renderSceneCatalog, renderSceneMemory } from './snapshot.js';
|
|
58
58
|
// ── 目录常量与失败约定 ─────────────────────────────────────────────────────
|
|
59
59
|
// 其余跨文件共用的常量与无依赖小工具(message / isValidGroupSegment / isValidGroupPath /
|
|
60
60
|
// MEMORIES_INDEX_FILE)已下沉到 ./constants.ts,避免 index-io / snapshot 反向引本文件。
|
|
@@ -161,6 +161,7 @@ export function createMemoriesService(ctx, deps) {
|
|
|
161
161
|
// 指纹一变(切场景 / 改文件 / 外部编辑器改文件 / 改 enabled)才重读正文并重排。
|
|
162
162
|
// 见文件顶部「两相扫描」注释里为什么不选 fs.watch 方案。
|
|
163
163
|
let sceneCache = null;
|
|
164
|
+
let sceneCatalogCache = null;
|
|
164
165
|
function sceneMemory() {
|
|
165
166
|
const index = readIndexSync(stateDir);
|
|
166
167
|
const probe = probeSceneFilesSync(memoriesRoot, index, isIndexQuarantined(stateDir));
|
|
@@ -540,6 +541,19 @@ export function createMemoriesService(ctx, deps) {
|
|
|
540
541
|
// **不判"与文件重复"** —— 预设挂了官方 agent-instructions 时那份正文已经由文件送达
|
|
541
542
|
// (注入侧会跳过整个域),没挂时(极简)才需要注入兜底;判定在注入器的 `selectInjections` 里。
|
|
542
543
|
const memoryText = () => sceneMemory().text;
|
|
544
|
+
// 场景段。与记忆段是**两次探测**:`probeSceneFilesSync` 没有内部缓存(signature 本身就是
|
|
545
|
+
// 扫描的结果,缓存不了),代价是有界的一次目录树 stat(**不读正文**),换来两段各自渲染、
|
|
546
|
+
// 各自去重 —— 只改场景描述时不会连带重发整段记忆正文。
|
|
547
|
+
const sceneCatalog = () => {
|
|
548
|
+
const index = readIndexSync(stateDir);
|
|
549
|
+
const probe = probeSceneFilesSync(memoriesRoot, index, isIndexQuarantined(stateDir));
|
|
550
|
+
if (sceneCatalogCache && sceneCatalogCache.signature === probe.signature)
|
|
551
|
+
return sceneCatalogCache.value;
|
|
552
|
+
const value = renderSceneCatalog(probe, index, SCENE_CATALOG_MAX_BYTES);
|
|
553
|
+
sceneCatalogCache = { signature: probe.signature, value };
|
|
554
|
+
return value;
|
|
555
|
+
};
|
|
556
|
+
const sceneCatalogText = () => sceneCatalog().text;
|
|
543
557
|
/**
|
|
544
558
|
* `~/.dsh/AGENTS.md` 正文的上限,与官方那一行的 `maxBytes` 同量级(64 KiB):超大文件按字节
|
|
545
559
|
* 截断并留一行标记,免得一份手写的巨型基线把上下文撑爆。
|
|
@@ -676,6 +690,7 @@ export function createMemoriesService(ctx, deps) {
|
|
|
676
690
|
ops,
|
|
677
691
|
writeOps,
|
|
678
692
|
memoryText,
|
|
693
|
+
sceneCatalogText,
|
|
679
694
|
promptText,
|
|
680
695
|
promptFiles,
|
|
681
696
|
refresh,
|
package/lib/memories/snapshot.js
CHANGED
|
@@ -11,8 +11,8 @@ import { readdirSync, statSync } from 'node:fs';
|
|
|
11
11
|
import { cp, lstat, mkdir, readFile, readdir, realpath, rename, rm } from 'node:fs/promises';
|
|
12
12
|
import { dirname, join, resolve } from 'node:path';
|
|
13
13
|
import { parseSkillDoc, resolveDshHome, unquote } from '../skills/core.js';
|
|
14
|
-
import { MAX_SOURCE_DEPTH, MAX_DIRECTORIES, MAX_ENTRIES, MAX_DESCRIPTION_LENGTH, DEFAULT_ORDER, DEFAULT_GROUP_ORDER, GLOBAL_SCENE, TRUNCATION_MARKER, DROPPED_HEADING, SCENE_MEMORY_NOTE,
|
|
15
|
-
import { ensureSceneRecords, resolveActiveScenes, signatureOfIndex, sceneLabel,
|
|
14
|
+
import { MAX_SOURCE_DEPTH, MAX_DIRECTORIES, MAX_ENTRIES, MAX_DESCRIPTION_LENGTH, DEFAULT_ORDER, DEFAULT_GROUP_ORDER, GLOBAL_SCENE, TRUNCATION_MARKER, DROPPED_HEADING, SCENE_MEMORY_NOTE, LEGACY_BUNDLE_DOC, bundleDocName, SEGMENT_RULE_HINT, SHARED_GROUP, byteLen, message, isValidGroupSegment } from './constants.js';
|
|
15
|
+
import { ensureSceneRecords, resolveActiveScenes, signatureOfIndex, sceneLabel, sceneHeading, sceneLine, compareSceneBuckets, memoryBlock } from './projection.js';
|
|
16
16
|
import { isIndexQuarantined, readIndex, writeIndex, pathExists, readFileIfExistsSync } from './index-io.js';
|
|
17
17
|
export const identity = (p) => (process.platform === 'win32' ? p.toLowerCase() : p);
|
|
18
18
|
// ── 文件工具 ───────────────────────────────────────────────────────────────
|
|
@@ -542,7 +542,11 @@ export function renderSceneMemory(probe, index, maxBytes) {
|
|
|
542
542
|
let seq = 0;
|
|
543
543
|
for (const scene of [...buckets.keys()].sort((a, b) => compareSceneBuckets(a, b, index))) {
|
|
544
544
|
const sceneFiles = (buckets.get(scene) || []).slice().sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
|
|
545
|
-
|
|
545
|
+
// 场景块只留标题:**场景说明归场景段**(`renderSceneCatalog`),否则同一句话会在
|
|
546
|
+
// 上下文里出现两遍。记忆段要的是"这些条目属于哪个场景"这个分组标签。
|
|
547
|
+
// `sceneHeading` 只给标题、不自带结尾空行(场景段的 `sceneLine` 才自带),这里补上 ——
|
|
548
|
+
// 否则标题会与紧跟的引导语/条目贴成一行(`## 场景:全局**以下是…**`)。
|
|
549
|
+
const header = sceneHeading(scene) + '\n\n';
|
|
546
550
|
for (const file of sceneFiles) {
|
|
547
551
|
const { text: block, inline } = memoryBlock(file);
|
|
548
552
|
candidates.push({
|
|
@@ -560,12 +564,15 @@ export function renderSceneMemory(probe, index, maxBytes) {
|
|
|
560
564
|
}
|
|
561
565
|
/**
|
|
562
566
|
* 选中块 → 段正文(同一场景的 `## 场景:x` 只在首次出现时发出)。
|
|
563
|
-
*
|
|
567
|
+
*
|
|
568
|
+
* 授权语**只有一版**:2026-09-23 第四次裁定删掉了完整性声明(「以下就是全部信息」),
|
|
569
|
+
* 于是"按截断状态选长短两版"的自适应失去意义(两版会完全相同),`dropped` 参数一并移除。
|
|
570
|
+
* 详见 `SCENE_MEMORY_NOTE` 的注释 —— 代价是这一段不再有防探测手段。
|
|
564
571
|
*/
|
|
565
|
-
const renderBody = (selected
|
|
572
|
+
const renderBody = (selected) => {
|
|
566
573
|
if (selected.length === 0)
|
|
567
574
|
return '';
|
|
568
|
-
const note =
|
|
575
|
+
const note = SCENE_MEMORY_NOTE;
|
|
569
576
|
const chunks = [];
|
|
570
577
|
let current = null;
|
|
571
578
|
let buf = '';
|
|
@@ -578,10 +585,7 @@ export function renderSceneMemory(probe, index, maxBytes) {
|
|
|
578
585
|
if (buf !== '')
|
|
579
586
|
chunks.push(buf);
|
|
580
587
|
current = c.scene;
|
|
581
|
-
|
|
582
|
-
// 放最顶层会飘在场景之外(用户实测反馈「怎么跑到最顶层了」)。场景数有上限
|
|
583
|
-
// (`global`/`_shared` 恒常启用 + 至多一个启用场景),最多出现 3 次,代价可接受。
|
|
584
|
-
buf = `${c.header}${note}\n\n`;
|
|
588
|
+
buf = c.header;
|
|
585
589
|
prevInline = false;
|
|
586
590
|
}
|
|
587
591
|
const inline = c.inline;
|
|
@@ -592,7 +596,17 @@ export function renderSceneMemory(probe, index, maxBytes) {
|
|
|
592
596
|
}
|
|
593
597
|
if (buf !== '')
|
|
594
598
|
chunks.push(buf);
|
|
595
|
-
|
|
599
|
+
// 引导语只出现**一次**,放在板块层(第一个场景块之前)。
|
|
600
|
+
//
|
|
601
|
+
// 为什么挪上来(用户 2026-09-23 看到实际注入后要求):此前它跟着**每个场景块**各来一遍
|
|
602
|
+
// —— 2 个场景就是 272 B ≈68 tok/轮,占记忆段整段的 22%;而且它夹在 `## 场景:X` 与条目
|
|
603
|
+
// 之间,把"分组"和"内容"隔开了。它管的本来就是整段(授权语讲的是这一整段条目的效力,
|
|
604
|
+
// 不是某个场景的),放在最前面才对得上。
|
|
605
|
+
//
|
|
606
|
+
// 2026-09-23 更早那条「放最顶层会飘在场景之外(用户实测)」的约束**已不成立**:那次是
|
|
607
|
+
// 板块标题还是 `##`、与场景分组平级,放顶层确实分不清它管谁;现在板块升成 `#` 一级、
|
|
608
|
+
// 场景分组是 `##` 二级,放在两者之间就是明确的"板块级说明"。
|
|
609
|
+
return `${note}\n\n${chunks.join('\n')}`;
|
|
596
610
|
};
|
|
597
611
|
const sceneLabelOf = (scene) => sceneLabel(scene);
|
|
598
612
|
/** 未注入清单 + 截断标记,在 `space` 字节内尽量列全(放不下的折叠为一行计数)。 */
|
|
@@ -626,11 +640,11 @@ export function renderSceneMemory(probe, index, maxBytes) {
|
|
|
626
640
|
const missed = [];
|
|
627
641
|
const takenScenes = new Set();
|
|
628
642
|
// 场景标题与引导语也是开销,按「每个首次出现的场景」计进预算 —— 否则它们会挤掉
|
|
629
|
-
// 本该放得下的记忆(引导语的字节见 SCENE_NOTE_BYTES
|
|
630
|
-
|
|
631
|
-
|
|
643
|
+
// 本该放得下的记忆(引导语的字节见 SCENE_NOTE_BYTES)。引导语现在**整段只算一次**
|
|
644
|
+
// (放在板块层,见 renderBody),所以直接进初始用量,不再按场景累加。
|
|
645
|
+
let used = sceneNoteBytes();
|
|
632
646
|
for (const c of candidates) {
|
|
633
|
-
const headerCost = takenScenes.has(c.scene) ? 0 : byteLen(c.header)
|
|
647
|
+
const headerCost = takenScenes.has(c.scene) ? 0 : byteLen(c.header);
|
|
634
648
|
if (used + headerCost + c.item.bytes + markerReserve > maxBytes) {
|
|
635
649
|
missed.push(c);
|
|
636
650
|
continue;
|
|
@@ -641,12 +655,12 @@ export function renderSceneMemory(probe, index, maxBytes) {
|
|
|
641
655
|
}
|
|
642
656
|
missed.sort((a, b) => a.seq - b.seq);
|
|
643
657
|
// ── ② 尾注自身也占字节:放不下就把已入选的块从后往前退回,直到回到预算内 ──
|
|
644
|
-
let body = renderBody(selected
|
|
658
|
+
let body = renderBody(selected);
|
|
645
659
|
let tail = renderTail(missed, maxBytes - byteLen(body), probe.truncated || missed.length > 0);
|
|
646
660
|
while (byteLen(body) + byteLen(tail) > maxBytes && selected.length > 0) {
|
|
647
661
|
missed.push(selected.pop());
|
|
648
662
|
missed.sort((a, b) => a.seq - b.seq);
|
|
649
|
-
body = renderBody(selected
|
|
663
|
+
body = renderBody(selected);
|
|
650
664
|
tail = renderTail(missed, maxBytes - byteLen(body), true);
|
|
651
665
|
}
|
|
652
666
|
let text = `${body}${tail}`.replace(/^\n+/, '');
|
|
@@ -669,14 +683,91 @@ export function renderSceneMemory(probe, index, maxBytes) {
|
|
|
669
683
|
};
|
|
670
684
|
}
|
|
671
685
|
/**
|
|
672
|
-
*
|
|
673
|
-
*
|
|
686
|
+
* 引导语所占的字节(含它后面的一个空行)。**整段只算一次** —— 它放在板块层(见 renderBody),
|
|
687
|
+
* 不再跟着场景块重复。
|
|
688
|
+
*
|
|
689
|
+
* 注意:模块级不能直接算 —— `byteLen` 是后面才声明的 const,模块初始化期取它会 TDZ 报错。
|
|
674
690
|
*/
|
|
675
691
|
/**
|
|
676
|
-
*
|
|
677
|
-
*
|
|
692
|
+
* 场景段(`scene-manager-catalog`):列出**当前启用**的场景,一行一个
|
|
693
|
+
* (`**「场景名」—— 场景说明**`,见 `sceneLine`)。
|
|
678
694
|
*
|
|
679
|
-
*
|
|
695
|
+
* 为什么从记忆段里拆出来(2026-09-23 用户裁定):场景说明是"这个场景是干什么的",记忆条目是
|
|
696
|
+
* **记录**(权威等级低于用户当场说的)—— 两者性质不同,而原来的实现靠一句话同时管两者。拆开后
|
|
697
|
+
* 用户能**单独关掉记忆段**(省字节)而保留场景说明。
|
|
698
|
+
*
|
|
699
|
+
* 本段**没有任何引导语**:授权语在 2026-09-23 第二次裁定里删掉(上一版把场景说明写成
|
|
700
|
+
* "一律照办"的约定,而它的实例是「写代码」这种**标签**,让模型"照办一个标签"正是它读不懂
|
|
701
|
+
* 这一段的原因);替它补的那句"场景是什么"线索当天也被删了(第三次裁定,连同六个域的动作句
|
|
702
|
+
* 一起,理由见 `DomainFrame`)。现在本段只有 `sceneLine` 一行 —— 用户的原则是「上下文注入
|
|
703
|
+
* 就是当前的情况,不需要模型知道没用的信息」。
|
|
704
|
+
*
|
|
705
|
+
* 与记忆段的分工:这里给"框架",记忆段给"内容"(各场景下的条目)。**场景说明只在这里出现**
|
|
706
|
+
* —— 记忆段的场景块只留标题(`sceneHeading`),否则同一句话会在上下文里出现两遍。代价是
|
|
707
|
+
* 关掉本段后记忆段少了"这个场景是什么"的语境,但那正是用户关掉它的意思。
|
|
708
|
+
*
|
|
709
|
+
* 场景清单取**磁盘目录 ∪ 索引记录**:两者通常一致(建场景时同时落目录与记录),取并集是
|
|
710
|
+
* 为了手工建的目录 / 手工删过记录的目录也能如实列出。**空场景也列** —— "这个场景存在但还
|
|
711
|
+
* 没有内容"本身就是框架信息(记忆段只列有记忆的场景,两者不是同一份清单)。
|
|
712
|
+
*
|
|
713
|
+
* **只列启用的非保留场景**(排除 `global` / `_shared`):那两个桶恒常生效,说"当前处于全局"
|
|
714
|
+
* 是废话 —— 默认状态不该占上下文(用户 2026-09-23 裁定)。一个具体场景都没启用时整段返回
|
|
715
|
+
* 空串(通道不发这条消息),而不是发一句"当前处于全局"。
|
|
716
|
+
*
|
|
717
|
+
* **场景是单选的**:除保留场景(`global` / `_shared`)外至多一个处于启用状态。这条约束由
|
|
718
|
+
* `rules-set-active`(界面单选)+ `rules-create-scene` 的 `collapseActiveForNewScene`(把
|
|
719
|
+
* 历史「全部启用」收敛成单选)保证,本函数**只读**、不替用户收敛。历史遗留的"同时启用多个"
|
|
720
|
+
* 在收敛之前会如实列出多个 —— 注入反映现状,界面另有 `scenes.legacyAll` 提示与一键收敛。
|
|
680
721
|
*/
|
|
722
|
+
export function renderSceneCatalog(
|
|
723
|
+
// 只要清单与截断标记:场景段不读正文(`refs` 是记忆段才需要的)。
|
|
724
|
+
probe, index, maxBytes) {
|
|
725
|
+
const { active } = resolveActiveScenes(index, probe.scenes);
|
|
726
|
+
const names = new Set([...probe.scenes, ...Object.keys(index.scenes || {})]);
|
|
727
|
+
const wanted = [...names]
|
|
728
|
+
.filter((scene) => scene !== '' && scene !== GLOBAL_SCENE && scene !== SHARED_GROUP && active.has(scene))
|
|
729
|
+
.sort((a, b) => compareSceneBuckets(a, b, index));
|
|
730
|
+
// 默认状态(只有保留桶生效):整段不注入。返回空串而不是"当前处于全局"——后者是废话,
|
|
731
|
+
// 而且会让通道每轮都发一条没有信息量的消息。
|
|
732
|
+
if (wanted.length === 0) {
|
|
733
|
+
return { text: '', bytes: 0, truncated: probe.truncated, maxBytes, scenes: [], items: [], dropped: [] };
|
|
734
|
+
}
|
|
735
|
+
// 这一段**只给当前情况**:启用了哪些非保留场景、各自是什么(`sceneLine`)。
|
|
736
|
+
//
|
|
737
|
+
// 曾经在这里加过一句因果("下面的记忆、MCP、技能与人设都已按它筛过"),已删(用户
|
|
738
|
+
// 2026-09-23 裁定):「上下文注入就是当前的情况,目的是让 agent 知道现在的情况,不需要它
|
|
739
|
+
// 知道没用的信息,反推更是浪费 token」。那句话解释的是**另外四段是怎么产生的**(机制),
|
|
740
|
+
// 不是当前情况本身。留着它的两个额外代价也一并消失:① 场景**启用**(`active`)与场景
|
|
741
|
+
// **进入**(`mode.scene`)是两份状态、两个 op,只启用没进入时那句是假话,得按 mode 分叉
|
|
742
|
+
// 才能不说错;② 它排在场景名之前时「它」没有指代。
|
|
743
|
+
const marker = `\n${TRUNCATION_MARKER}\n`;
|
|
744
|
+
const kept = [];
|
|
745
|
+
let used = 0;
|
|
746
|
+
let dropped = false;
|
|
747
|
+
for (const scene of wanted) {
|
|
748
|
+
const block = sceneLine(scene, index);
|
|
749
|
+
// 场景是单选的(至多一个 + 两个保留场景),正常永远碰不到预算 —— 这一层只是不让
|
|
750
|
+
// "预算被配得极小"变成一段没有边界说明的静默截断。
|
|
751
|
+
if (used + byteLen(block) + marker.length > maxBytes) {
|
|
752
|
+
dropped = true;
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
kept.push(scene);
|
|
756
|
+
used += byteLen(block);
|
|
757
|
+
}
|
|
758
|
+
let text = kept.map((scene) => sceneLine(scene, index)).join('');
|
|
759
|
+
if (dropped)
|
|
760
|
+
text += marker;
|
|
761
|
+
text = text.replace(/^\n+/, '');
|
|
762
|
+
return {
|
|
763
|
+
text,
|
|
764
|
+
bytes: byteLen(text),
|
|
765
|
+
truncated: probe.truncated || dropped,
|
|
766
|
+
maxBytes,
|
|
767
|
+
scenes: kept,
|
|
768
|
+
items: [],
|
|
769
|
+
dropped: [],
|
|
770
|
+
};
|
|
771
|
+
}
|
|
681
772
|
export const sceneNoteBytes = () => byteLen(`${SCENE_MEMORY_NOTE}\n\n`);
|
|
682
773
|
// ── 创建服务 ───────────────────────────────────────────────────────────────
|
package/lib/op-registry.js
CHANGED
|
@@ -55,7 +55,7 @@ export const OP_REGISTRY = Object.freeze({
|
|
|
55
55
|
// 会停在启用态 —— 是写不是读。(0.6.0 / 0.7.0 各有漏列前科,见文件头。)
|
|
56
56
|
'mcpm-tools-refresh': { write: true },
|
|
57
57
|
'mcpm-tools': { readonly: true },
|
|
58
|
-
// ── 技能域(
|
|
58
|
+
// ── 技能域(22,含内联的 skill-open)───────────────────────────────────────
|
|
59
59
|
'skill-state': { annotatesLock: true },
|
|
60
60
|
'skill-detail': { readonly: true },
|
|
61
61
|
'skill-browse': { readonly: true },
|
|
@@ -70,6 +70,9 @@ export const OP_REGISTRY = Object.freeze({
|
|
|
70
70
|
'skill-prefer': { serviceWrite: true, frozen: true },
|
|
71
71
|
'skill-unprefer': { serviceWrite: true, frozen: true },
|
|
72
72
|
'skill-create': { serviceWrite: true, frozen: true },
|
|
73
|
+
// 改写 hub 里已存在的那一份技能(`skill_manager_save` 的改分支)。与 create 同规格:
|
|
74
|
+
// 都是往 hub 落文件。它**不动**官方根里的技能 —— 那层边界在 service 的胜出者判定里。
|
|
75
|
+
'skill-update': { serviceWrite: true, frozen: true },
|
|
73
76
|
'skill-import': { serviceWrite: true, frozen: true },
|
|
74
77
|
'skill-upload': { serviceWrite: true, frozen: true },
|
|
75
78
|
'skill-delete': { serviceWrite: true, frozen: true },
|
|
@@ -180,8 +183,11 @@ export const OP_REGISTRY = Object.freeze({
|
|
|
180
183
|
'scene-settings': { write: true },
|
|
181
184
|
// 清理 patch 备份:删磁盘文件(备份里含明文凭据副本),按写操作门禁。
|
|
182
185
|
'backups-clean': { write: true },
|
|
183
|
-
// ── 场景候选源(
|
|
186
|
+
// ── 场景候选源(4)与内联(1)──────────────────────────────────────────────
|
|
184
187
|
'model-candidates': { readonly: true },
|
|
188
|
+
// 思考强度档位:要问 adapter(`llm.resolveModelInfo`,异步、可能联网),但只读不写任何状态
|
|
189
|
+
// —— 与 model-candidates 同一档。它不进 scene-inventory,是**按需**拉取的。
|
|
190
|
+
'model-reasoning': { readonly: true },
|
|
185
191
|
'scene-inventory': { readonly: true },
|
|
186
192
|
// 跨域悬空引用体检:只读对账(索引走 readIndexSync,权威集合各读一次)。
|
|
187
193
|
'state-doctor': { readonly: true },
|