dsh-plugin-tool-management 0.20.3 → 0.21.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 +72 -0
- package/README.md +9 -5
- package/README_EN.md +9 -5
- package/docs/images/icon.png +0 -0
- package/docs/images/icon.svg +1 -0
- package/docs/update.md +2 -0
- package/lib/client.js +829 -153
- package/lib/compat/capability-specs.js +60 -1
- package/lib/compat/host-contracts.js +72 -0
- package/lib/compat/host-probe.js +59 -0
- package/lib/compat/host-roots.js +1 -1
- package/lib/compat/probe.js +84 -13
- package/lib/context-inject-frame.js +168 -29
- package/lib/context-inject.js +13 -10
- package/lib/http-api.js +3 -2
- package/lib/http-fence.js +26 -1
- package/lib/index.js +34 -7
- package/lib/mcp/manager.js +6 -1
- package/lib/mcp/state-section.js +75 -34
- package/lib/memories/constants.js +39 -7
- package/lib/memories/projection.js +18 -18
- package/lib/memories/service.js +8 -7
- package/lib/memories/snapshot.js +39 -21
- package/lib/op-registry.js +3 -0
- package/lib/ops/compat.js +64 -2
- package/lib/ops/memory.js +2 -2
- package/lib/ops/prompts.js +30 -2
- package/lib/ops/trash.js +12 -1
- package/lib/request-gate.js +5 -4
- package/lib/settings-sidecars.js +37 -0
- package/lib/sidebar-settings.js +13 -0
- package/lib/skills/catalog.js +2 -2
- package/lib/subagents/catalog.js +4 -4
- package/lib/subagents/child-preset.js +120 -0
- package/lib/subagents/persona-parse.js +9 -9
- package/lib/subagents/run-provider.js +218 -62
- package/lib/subagents/tool-filter.js +12 -12
- package/lib/subagents/tools.js +29 -22
- package/lib/tools/deps.js +4 -4
- package/lib/tools/mcp.js +34 -34
- package/lib/tools/memory.js +34 -34
- package/lib/tools/prompt.js +5 -5
- package/lib/tools/remove.js +17 -17
- package/lib/tools/scene.js +66 -66
- package/lib/tools/skills.js +21 -21
- package/lib/tools/subagent.js +13 -13
- package/lib/tools/table.js +2 -2
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +28 -24
package/lib/mcp/state-section.js
CHANGED
|
@@ -9,26 +9,30 @@
|
|
|
9
9
|
// 这类信息在任何工具 schema 里都拿不到:工具只暴露 `mcp__<server>__<tool>` 的名字与
|
|
10
10
|
// 参数,永远不会说「A 挂了改用 B」。所以「列出 server」只是载体。
|
|
11
11
|
//
|
|
12
|
-
// 范围(2026-09-15 用户裁定;2026-09-17 按实测修正"可用"
|
|
13
|
-
// -
|
|
12
|
+
// 范围(2026-09-15 用户裁定;2026-09-17 按实测修正"可用"的判据;2026-10-18 加第三档):
|
|
13
|
+
// - 分三档,判据全部可验证:
|
|
14
14
|
// ① **可用**:配置上已启用 **且** 当前真实注册了未被停用的工具
|
|
15
15
|
// (`liveEnabledToolCount > 0`;场景档案收窄与手动逐工具开关都会写停用表,这里如实扣减)。
|
|
16
|
-
// ②
|
|
16
|
+
// ② **连着但工具全被停用**:当前注册了工具、但停用表把它们全扣光(`liveToolCount > 0`
|
|
17
|
+
// 而 `liveEnabledToolCount === 0`)。2026-10-18 起列出来:它是"模型手上一条 MCP 工具
|
|
18
|
+
// 都没有,可 server 确实在跑"的唯一线索,而此前不列的理由("留着只会谎报数量")
|
|
19
|
+
// 随工具数一起作废了。
|
|
20
|
+
// ③ **曾经连上、现在不可用**:已启用、当前一个工具都没注册,但「已知工具」缓存非空。
|
|
17
21
|
// 这是**状态变化**,要报 —— 模型据此给"检查网络/命令/凭据"的建议,而不是把
|
|
18
22
|
// "配了但连不上"读成"本机没配"(后者会让它建议用户去装一个)。同时,用户写在那台
|
|
19
23
|
// server 上的备注(placeholder 就是「A 不可用时改用 B 兜底」)恰恰只在此时才有用,
|
|
20
24
|
// 不列就永远送不到。
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
// - 只列 **server 名 + 状态(仅②档)+ 备注**;**具体工具名绝不注入** ——
|
|
25
|
+
// 不列的两类:**没开**(用户明确要求)、**从未连上过**(缓存也空 —— 一直没成功过,不该反复
|
|
26
|
+
// 打扰;这条抄的是 Claude Code 的通知策略:一直没连上的不提示,昨天还好今天挂的才提示)。
|
|
27
|
+
// - 只列 **server 名 + 状态(②③档)+ 备注**;**具体工具名绝不注入** ——
|
|
25
28
|
// 已启用 server 的工具本来就在模型自己的工具 schema 里(`mcp__<server>__<tool>`),
|
|
26
29
|
// 段里再列一遍是重复;模型按前缀就能对上号。
|
|
27
30
|
// - **工具数不列**(2026-09-23):模型不能凭"这台有 N 个工具"做任何事(能调哪些在它自己的
|
|
28
31
|
// schema 里),而 17 B/台 × 每轮的开销是白付的。
|
|
29
|
-
// - 无备注 → 只有名字(①档)或名字 +
|
|
30
|
-
// -
|
|
31
|
-
// -
|
|
32
|
+
// - 无备注 → 只有名字(①档)或名字 + 状态(②③档)。
|
|
33
|
+
// - 不写「已启用」字样:能进段的都是已启用的,标状态是废话;②③档则必须标,否则就是谎报。
|
|
34
|
+
// - **三档都空才返回 `''`**(无 server 可讲时零 token 成本)。注意 2026-10-18 的
|
|
35
|
+
// 「无备注就不发」已被同日的用户裁定推翻,理由见 `renderMcpStateSection` 的注释。
|
|
32
36
|
//
|
|
33
37
|
// 一条**测不出来**的边界(写在这里免得后来者以为它是 bug):宿主没有暴露"MCP 已连接"这个信号
|
|
34
38
|
// (插件清单只有 entryId / moduleName / enabled / fiberPhase),所以本模块只能读"工具有没有注册"。
|
|
@@ -49,11 +53,11 @@ export const DEFAULT_MCP_NOTE_MAX_LENGTH = 300;
|
|
|
49
53
|
* 备注的固定前缀(模型侧口径,用户裁定 2026-09-16:字段与段统一叫 user-hint / 用户提示)。
|
|
50
54
|
*
|
|
51
55
|
* 原为「备注:」,取「降低被读成指令的概率」——备注是自由文本,注入后**由插件背书**地到达模型。
|
|
52
|
-
*
|
|
56
|
+
* 改叫 `user hint:` 是有意为之:它本来就该被读成**用户的决策提示**(「A 挂了改用 B」),
|
|
53
57
|
* 而不是系统策略。威胁模型不变(内容由用户自己写、仅本机可写,属**自伤**而非被攻击),
|
|
54
58
|
* 但前缀如实标明来源,模型据此权衡即可。
|
|
55
59
|
*/
|
|
56
|
-
export const MCP_NOTE_PREFIX = '
|
|
60
|
+
export const MCP_NOTE_PREFIX = 'user hint: ';
|
|
57
61
|
/**
|
|
58
62
|
* 备注压成单行(换行 → 空格)并截断。
|
|
59
63
|
*
|
|
@@ -70,12 +74,17 @@ export function normalizeMcpNote(value, maxLength) {
|
|
|
70
74
|
/**
|
|
71
75
|
* 渲染 MCP 状态段(纯函数,便于单独推理)。
|
|
72
76
|
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
77
|
+
* **整段的取舍**(2026-10-18 用户裁定「无备注不发这样不好」,推翻了同日的 A 方案):
|
|
78
|
+
* 只要有一台该说的 server 就发,不因为"没有备注"而整段省略。理由:
|
|
79
|
+
* - 这一域是**唯一**说明"这台机器上配了哪些 server、各自什么状态"的地方,而模型手里的工具表
|
|
80
|
+
* 只说得出"我现在能调什么"。少了它,"配过但没连上 / 连着但工具被关掉"与"根本没配过"
|
|
81
|
+
* 在模型看来完全等价 —— 那正是本模块存在的理由(见文件头)。
|
|
82
|
+
* - 代价已经不是问题:`how` 行按需发、权威声明只在替换时发之后,只有名字的那种段在上下文里
|
|
83
|
+
* 只剩标题 + 一两行,且内容不变就不重发。
|
|
75
84
|
*
|
|
76
|
-
*
|
|
85
|
+
* @param listToolVisible `mcp_manager_list` 在这个 agent 手里吗(尾句那个出口的前提)
|
|
77
86
|
*/
|
|
78
|
-
export function renderMcpStateSection(rows, opts = {}) {
|
|
87
|
+
export function renderMcpStateSection(rows, opts = {}, listToolVisible = true) {
|
|
79
88
|
const maxEntries = opts.maxEntries ?? DEFAULT_MCP_MAX_ENTRIES;
|
|
80
89
|
const noteMaxLength = opts.noteMaxLength ?? DEFAULT_MCP_NOTE_MAX_LENGTH;
|
|
81
90
|
// 当前真实可用的工具数(停用表扣减后)。**优先读 live 那个数** —— 旧调用方只给
|
|
@@ -91,12 +100,20 @@ export function renderMcpStateSection(rows, opts = {}) {
|
|
|
91
100
|
const liveOf = (r) => (Number.isFinite(Number(r.liveToolCount)) ? Number(r.liveToolCount) : liveEnabledOf(r));
|
|
92
101
|
// 曾经连上过的证据。缺省 0 = 从未连上过(旧调用方给不出这个信号,按"不列"处理 —— 少说不错)。
|
|
93
102
|
const knownOf = (r) => Number(r.knownToolCount) || 0;
|
|
103
|
+
// 三档,判据全部可验证:
|
|
104
|
+
// ① `usable` —— 已启用,当前真注册了未被停用的工具(模型现在就能调)。
|
|
105
|
+
// ② `muted` —— 已启用、也真的连着(注册过工具),但**工具全被停用**:场景档案收窄或
|
|
106
|
+
// 手动逐工具开关的结果。2026-10-18 起列出来并标注(此前不列):它是
|
|
107
|
+
// "模型手上一条工具都没有,但 server 确实在跑"的**唯一**线索,而旧理由
|
|
108
|
+
// ("留着只会谎报数量")随着工具数一起消失了 —— 现在只给名字与状态。
|
|
109
|
+
// ③ `offline` —— 已启用、当前一个工具都没注册,但「已知工具」缓存非空(曾经连上过)。
|
|
94
110
|
const usable = rows.filter((r) => !r.disabled && liveEnabledOf(r) > 0);
|
|
111
|
+
const muted = rows.filter((r) => !r.disabled && liveOf(r) > 0 && liveEnabledOf(r) === 0);
|
|
95
112
|
const offline = rows.filter((r) => !r.disabled && liveOf(r) === 0 && knownOf(r) > 0);
|
|
96
|
-
if (!usable.length && !offline.length)
|
|
113
|
+
if (!usable.length && !muted.length && !offline.length)
|
|
97
114
|
return '';
|
|
98
115
|
// 按展示名排序:即使补丁文件里的行序变化,段文本也保持逐字节稳定(前缀缓存契约)。
|
|
99
|
-
//
|
|
116
|
+
// 三档各自排序,可用的一律排在前面(它才是模型当下能用的东西)。
|
|
100
117
|
const byName = (a, b) => {
|
|
101
118
|
const x = String(a.serverName ?? '');
|
|
102
119
|
const y = String(b.serverName ?? '');
|
|
@@ -104,48 +121,65 @@ export function renderMcpStateSection(rows, opts = {}) {
|
|
|
104
121
|
};
|
|
105
122
|
const cap = Math.max(0, maxEntries);
|
|
106
123
|
const shownUsable = [...usable].sort(byName).slice(0, cap);
|
|
124
|
+
const shownMuted = [...muted].sort(byName).slice(0, cap);
|
|
107
125
|
const shownOffline = [...offline].sort(byName).slice(0, cap);
|
|
108
|
-
const lineOf = (
|
|
109
|
-
const name = String(
|
|
110
|
-
//
|
|
126
|
+
const lineOf = (row, status) => {
|
|
127
|
+
const name = String(row.serverName ?? row.id);
|
|
128
|
+
// 非"可用"的两档必须把状态写出来:不写就等于谎报"可用"。
|
|
111
129
|
//
|
|
112
130
|
// 2026-09-23:两档的**工具数都去掉了**(用户看到实际注入后要求精简)。数字对模型没有
|
|
113
131
|
// 动作价值 —— 它不能凭"这台有 8 个工具"列举出工具名(真正能调的都在它自己的工具
|
|
114
132
|
// schema 里,按 `mcp__<server>__` 前缀就能对上号),而每轮都要付 17 B/台。
|
|
115
133
|
// 顺带消掉一处隐患:②档原来写「上次连上时 N 个工具」,那个数字是**历史事实**,措辞上
|
|
116
134
|
// 必须和现状区分开;数字没了,这个歧义也就不存在了。
|
|
117
|
-
const head =
|
|
118
|
-
|
|
135
|
+
const head = status === 'offline'
|
|
136
|
+
? '- **' + name + '** (not connected now)'
|
|
137
|
+
: status === 'muted'
|
|
138
|
+
? '- **' + name + '** (no tools enabled here)'
|
|
139
|
+
: '- **' + name + '**';
|
|
140
|
+
const note = normalizeMcpNote(row.notes, noteMaxLength);
|
|
119
141
|
return note ? head + ' — ' + MCP_NOTE_PREFIX + note : head;
|
|
120
142
|
};
|
|
121
143
|
const lines = [
|
|
122
|
-
...shownUsable.map((
|
|
123
|
-
...
|
|
144
|
+
...shownUsable.map((row) => lineOf(row, 'ok')),
|
|
145
|
+
...shownMuted.map((row) => lineOf(row, 'muted')),
|
|
146
|
+
...shownOffline.map((row) => lineOf(row, 'offline')),
|
|
124
147
|
];
|
|
125
|
-
// 只有清单:标题(`#
|
|
148
|
+
// 只有清单:标题(`# Current MCP servers`)由注入通道的框架承担
|
|
126
149
|
// (2026-09-18 起,见 context-inject.ts 的 DOMAIN_FRAME)—— 同一件事只有一个出处,
|
|
127
150
|
// 框架已经写了标题,正文再写一遍就是每步多付一行 token。
|
|
128
151
|
const out = [...lines];
|
|
129
|
-
const hidden = (usable.length +
|
|
130
|
-
|
|
131
|
-
|
|
152
|
+
const hidden = (usable.length + muted.length + offline.length)
|
|
153
|
+
- (shownUsable.length + shownMuted.length + shownOffline.length);
|
|
154
|
+
// 超上限时给出口(2026-10-18 审查 B):技能与子智能体两个目录早就这么写了,这是唯一漏掉
|
|
155
|
+
// 的一处。工具被关掉(兼容页 / 人设 toolsDeny)时不点名 —— 点名一个模型手里没有的工具,
|
|
156
|
+
// 只会让它去猜名字;数量照报,那是实话。
|
|
157
|
+
if (hidden > 0) {
|
|
158
|
+
out.push('', listToolVisible
|
|
159
|
+
? `(${hidden} more not listed — see them with \`mcp_manager_list\`.)`
|
|
160
|
+
: `(${hidden} more not listed.)`);
|
|
161
|
+
}
|
|
132
162
|
return out.join('\n');
|
|
133
163
|
}
|
|
134
164
|
/** 创建 SWR 的 MCP 状态目录。计算失败时保留上一次的值,不把段清空。 */
|
|
135
165
|
export function createMcpStateCatalog(deps, opts = {}) {
|
|
136
166
|
const ttlMs = opts.ttlMs ?? 3000;
|
|
137
167
|
const now = opts.now ?? (() => Date.now());
|
|
138
|
-
|
|
168
|
+
// 缓存的是**行数据**而不是渲染结果(与技能 / 子智能体两个目录同一手法):清单只依赖磁盘,
|
|
169
|
+
// 而尾句要不要点名查询工具取决于**这个 agent 手里有没有它**。渲染是纯字符串操作,代价可忽略。
|
|
170
|
+
let data = null;
|
|
139
171
|
let loadedAt = Number.NEGATIVE_INFINITY;
|
|
140
172
|
let inflight = null;
|
|
173
|
+
let rendered = null;
|
|
141
174
|
const recompute = async () => {
|
|
142
175
|
try {
|
|
143
176
|
const result = await deps.rows();
|
|
144
177
|
if (result && result.ok !== false) {
|
|
145
|
-
|
|
178
|
+
data = result.rows ?? [];
|
|
179
|
+
rendered = null;
|
|
146
180
|
}
|
|
147
181
|
}
|
|
148
|
-
catch { /* 保留上一次的值;首次失败则维持
|
|
182
|
+
catch { /* 保留上一次的值;首次失败则维持 null */ }
|
|
149
183
|
loadedAt = now();
|
|
150
184
|
};
|
|
151
185
|
const revalidate = () => {
|
|
@@ -155,10 +189,17 @@ export function createMcpStateCatalog(deps, opts = {}) {
|
|
|
155
189
|
return inflight;
|
|
156
190
|
};
|
|
157
191
|
return {
|
|
158
|
-
text: () => {
|
|
192
|
+
text: (agent) => {
|
|
159
193
|
if (now() - loadedAt > ttlMs)
|
|
160
194
|
void revalidate();
|
|
161
|
-
|
|
195
|
+
if (data === null)
|
|
196
|
+
return '';
|
|
197
|
+
const visible = deps.listToolVisible ? deps.listToolVisible(agent) : true;
|
|
198
|
+
if (rendered && rendered.visible === visible)
|
|
199
|
+
return rendered.text;
|
|
200
|
+
const out = renderMcpStateSection(data, opts, visible);
|
|
201
|
+
rendered = { visible, text: out };
|
|
202
|
+
return out;
|
|
162
203
|
},
|
|
163
204
|
refresh: () => {
|
|
164
205
|
loadedAt = Number.NEGATIVE_INFINITY;
|
|
@@ -34,10 +34,22 @@ export const GLOBAL_SCENE = 'global';
|
|
|
34
34
|
export const GLOBAL_SCENE_LABEL = '全局';
|
|
35
35
|
export const SHARED_GROUP = '_shared'; // 保留场景名:公共基线(历史语义,仍可使用)
|
|
36
36
|
// ── 段渲染 ─────────────────────────────────────────────────────────────────
|
|
37
|
-
|
|
37
|
+
/**
|
|
38
|
+
* 截断标记。**给模型看的一句英文**,不再是 `<!-- truncated -->`(2026-10-18 审查)。
|
|
39
|
+
*
|
|
40
|
+
* 旧值是个 HTML 注释:对人眼不可见(要的就是"别显示"),但模型读的是**原始 markdown** ——
|
|
41
|
+
* 它看到的是一串 `<!-- truncated -->`,既不知道这是什么、也不知道该拿它怎么办。注入正文里
|
|
42
|
+
* 没有"注释"这个概念,凡是进上下文的东西都该是给模型的一句话。
|
|
43
|
+
*
|
|
44
|
+
* 现在的写法说清两件事:有内容没进来(`Truncated`)、进来的是少于全部的(`not shown`)。
|
|
45
|
+
* 具体漏了哪些由段尾的 `## Not injected` 清单回答,标记只在清单本身也放不下时才独自出现。
|
|
46
|
+
*/
|
|
47
|
+
export const TRUNCATION_MARKER = '(Truncated: some entries are not shown.)';
|
|
38
48
|
// 段尾清单:让模型知道自己漏了什么。去掉伞标题后这里也不再挂「场景记忆」前缀 ——
|
|
39
|
-
//
|
|
40
|
-
|
|
49
|
+
// 它紧跟在场景块之后,与段首引导语同一说法。2026-10-18 起两者都是英文(见 SCENE_MEMORY_NOTE)。
|
|
50
|
+
// 每行只有 `场景/名称`:原先还带 `(N B)`,那个字节数对人有用、对模型零动作价值
|
|
51
|
+
// (它不能凭"这条 800 B"决定读不读)—— 2026-10-18 审查按"注入只写模型能用的信息"删掉。
|
|
52
|
+
export const DROPPED_HEADING = '## Not injected (over the size limit)';
|
|
41
53
|
/** 单行正文的最大长度:超过就退回「标题 + 正文块」,避免出现一条几千字符的列表行。 */
|
|
42
54
|
export const INLINE_BODY_MAX = 120;
|
|
43
55
|
/** 附件行里最多列几个文件名;多的只报总数(路径已经给了,缺的名字模型自己列目录即可)。 */
|
|
@@ -63,7 +75,7 @@ export const ATTACHMENT_LIST_MAX = 10;
|
|
|
63
75
|
*
|
|
64
76
|
* 用词:不用「常驻」「注入」这类内部行话(模型没有先验)。
|
|
65
77
|
* ⚠️ 「用『信息』而不是『记忆』」那条**已被 2026-09-23 第四次裁定推翻**(见下):用户
|
|
66
|
-
*
|
|
78
|
+
* 给定的定稿文案里就是「记忆」,而板块标题本来就是 `Current memories` —— 两者同名反而更好,
|
|
67
79
|
* 当时那条理由(「记忆」在系统提示词里指代不明)在板块标题定名之后也不成立了。
|
|
68
80
|
*
|
|
69
81
|
* 2026-09-17 重写(用户:「当前模式会不重视这些提示词」)。上一版的三个毛病都在**授权**
|
|
@@ -110,7 +122,7 @@ export const ATTACHMENT_LIST_MAX = 10;
|
|
|
110
122
|
* 时以实际情况为准、④ 与用户当场说的冲突时以用户为准、⑤ 无关不必提 + 完整性声明(防探测,
|
|
111
123
|
* 见上,那半句是整句里唯一不能删的)。77 → 48 字符(219 → 144 字节),而它**每个场景块各来
|
|
112
124
|
* 一次**,所以省的是 N 倍。删掉的只有修饰与重复:「以下是用户为本机写的记录」→「本机记录」
|
|
113
|
-
* (板块标题 `#
|
|
125
|
+
* (板块标题 `# Current memories` 已经把"这是本机的记忆"说过了),两句"冲突时以…为准"
|
|
114
126
|
* 合成一句。
|
|
115
127
|
*
|
|
116
128
|
* 2026-09-23 第四次裁定(用户直接给定稿文案,逐字):
|
|
@@ -118,7 +130,7 @@ export const ATTACHMENT_LIST_MAX = 10;
|
|
|
118
130
|
* 相对第三版有三处变化,都不是措辞偏好:
|
|
119
131
|
* ① 「本机记录」→「以下是**当前场景对应的**记忆」:说清这是**哪个范围**的记忆。记忆本来
|
|
120
132
|
* 就按场景组织,而这一段列出的条目确实全来自"当前启用的场景 + 两个保留桶" —— 比
|
|
121
|
-
* "本机记录"准确,也解释了为什么下面的条目按 `##
|
|
133
|
+
* "本机记录"准确,也解释了为什么下面的条目按 `## Scene: X` 分组。
|
|
122
134
|
* ② 「用户当场说的」→「用户**目前的对话**」:同一个意思,后者更贴注入发生的场合。
|
|
123
135
|
* ③ **删掉「可能已过期」,以及完整性声明「以下就是全部信息」**。
|
|
124
136
|
* - 「可能已过期」:用户判断它与紧接的「冲突时以实际情况为准」重复 —— 后半句已经把
|
|
@@ -139,12 +151,32 @@ export const ATTACHMENT_LIST_MAX = 10;
|
|
|
139
151
|
* ② **`global` 的分组标题从 `## 场景:全局` 改成 `## 常驻信息`**(见 `sceneHeading`):
|
|
140
152
|
* 全局是恒常生效的保留桶,不是"一个场景",写成场景会让模型以为它是当前场景的另一种
|
|
141
153
|
* 取值。
|
|
154
|
+
*
|
|
155
|
+
* 2026-10-18(用户裁定):**模型侧模板一律英文**,这句引导语的五个功能一个不动 ——
|
|
156
|
+
* ① 这是当前场景对应的记忆 ② 冲突时以实际情况与用户当场说的为准 ③ 无关勿提(不必提及,
|
|
157
|
+
* 不是不采用)④ 完整性声明(防探测)。**用户写的内容(记忆正文、场景名与说明)原样注入。**
|
|
158
|
+
*
|
|
159
|
+
* 同日晚些时候二次收紧(用户:「让 agent 更能注意到信息,信息高效,也不要冗余」):
|
|
160
|
+
* `when` → `where`,`do not bring them up unless relevant` → `bring them up only when relevant`。
|
|
161
|
+
* 后者不是同义改写:否定式读起来像"对内容的态度许可"(正是第三次裁定要拆掉的那个毛病),
|
|
162
|
+
* 正向式说的是"可以提,只是别主动提"。四个功能一个没少,字数少 11 个。
|
|
142
163
|
*/
|
|
143
164
|
/**
|
|
144
165
|
* 记忆段(`memory-manager-catalog`)的授权语:条目是**记录**(权威等级低于"用户当场说的")。
|
|
145
166
|
* 放在**板块层**(第一个场景块之前),整段只出现一次 —— 见 `renderBody` 尾部。
|
|
146
167
|
*/
|
|
147
|
-
export const SCENE_MEMORY_NOTE = '
|
|
168
|
+
export const SCENE_MEMORY_NOTE = '**Memories for the current scene; where they conflict with what you observe or with what the user says in this conversation, trust that instead; bring them up only when relevant; this is the complete set:**';
|
|
169
|
+
/**
|
|
170
|
+
* 同一条授权语,**去掉完整性声明**的版本 —— 只在真发生截断时用(2026-10-18 审查恢复分叉)。
|
|
171
|
+
*
|
|
172
|
+
* 第五次裁定当时判定"真截断时的矛盾可以接受",理由是默认 128 KiB 正常碰不到。但
|
|
173
|
+
* `rulesMaxBytes` 是公开旋钮(`index.ts` 的 config),预算配小时这个矛盾就是**每一条注入都在
|
|
174
|
+
* 说谎**:段首说"这就是全部",段尾的 `## Not injected` 紧接着列出没进来的条目。模型按段首
|
|
175
|
+
* 那句判断"没有别的记忆了",正好是这条声明本来要防的那个误判。
|
|
176
|
+
*
|
|
177
|
+
* 预算仍按全量版预留(`sceneNoteBytes`),而截断态这句更短,所以恢复分叉不会挤掉任何条目。
|
|
178
|
+
*/
|
|
179
|
+
export const SCENE_MEMORY_NOTE_PARTIAL = '**Memories for the current scene; where they conflict with what you observe or with what the user says in this conversation, trust that instead; bring them up only when relevant:**';
|
|
148
180
|
// ── bundle 与索引 ──────────────────────────────────────────────────────────
|
|
149
181
|
// bundle 附件限制(body 走 HTTP JSON + base64,故比技能上传收紧一档)。
|
|
150
182
|
export const MAX_ATTACH_ENTRY_BYTES = 8 << 20; // 单个附件 8 MiB
|
|
@@ -129,38 +129,38 @@ export function sceneLabel(scene, index) {
|
|
|
129
129
|
/**
|
|
130
130
|
* 记忆段里的场景**分组**标题。
|
|
131
131
|
*
|
|
132
|
-
* `global` 是恒常生效的保留桶,不是"一个场景" —— 叫它 `##
|
|
132
|
+
* `global` 是恒常生效的保留桶,不是"一个场景" —— 叫它 `## Scene: 全局` 会让模型以为"全局"
|
|
133
133
|
* 是当前场景的另一种取值(用户 2026-09-23:「全局不需要写成场景」)。所以它单独成一种标题:
|
|
134
|
-
* `##
|
|
134
|
+
* `## Always-on memories`。其余场景仍是 `## Scene: X`。
|
|
135
135
|
*
|
|
136
|
-
* `_shared`(历史保留名)保持 `##
|
|
136
|
+
* `_shared`(历史保留名)保持 `## Scene: _shared` 不动 —— 它只在旧数据里出现,且没有
|
|
137
137
|
* "全局"这种"它不是场景"的语义歧义(它确实曾是一个共享场景)。
|
|
138
138
|
*/
|
|
139
|
-
export const sceneHeading = (scene) => scene === GLOBAL_SCENE ? '##
|
|
139
|
+
export const sceneHeading = (scene) => scene === GLOBAL_SCENE ? '## Always-on memories' : `## Scene: ${sceneLabel(scene)}`;
|
|
140
140
|
/**
|
|
141
|
-
* 场景段(`scene-manager-catalog
|
|
141
|
+
* 场景段(`scene-manager-catalog`)里的一行:`**"<场景名>" — user description: <场景说明>**`。
|
|
142
142
|
*
|
|
143
|
-
* 2026-09-23 用户裁定,替换了原来的两行式(`##
|
|
143
|
+
* 2026-09-23 用户裁定,替换了原来的两行式(`## Scene: X` + `**场景说明:X**`)。三条理由都是
|
|
144
144
|
* 用户看到**实际注入**之后提的:
|
|
145
145
|
* - **场景名只出现一次**。上一版里它出现三次(框架线索"现在处在哪个场景"、引导语里的
|
|
146
|
-
* 「代码」、标题里的 `##
|
|
146
|
+
* 「代码」、标题里的 `## Scene: 代码`),而说明行只是把同一件事换个标签再说一遍。
|
|
147
147
|
* - **场景说明是「标签」,不是「约定」**。它答的是"这个场景是干什么的",用户的实例就是
|
|
148
148
|
* 「写代码」「前端相关」。上一版把它写成"一律照办、覆盖你的默认做法",等于要求模型
|
|
149
149
|
* "照办一个标签" —— 那正是它读不懂这一段的根源。授权语已删,说明只作注解跟在名字后面。
|
|
150
150
|
* - **没填说明就不带后缀**。上一版会补 `defaultSceneDescription` 的默认句,而
|
|
151
151
|
* "用户配置的上下文,当前启用"贴在名字后面,读起来像一句真的说明。
|
|
152
152
|
*
|
|
153
|
-
*
|
|
153
|
+
* `user description:` 这半句是同日追加的(用户:「防止模型误解这是场景名或者其它」):破折号后面
|
|
154
154
|
* 那截的形态本来有歧义 —— `**「代码」—— 写代码**` 读起来像"名字 —— 别名",而它其实是
|
|
155
|
-
*
|
|
156
|
-
* 前缀只在真有描述时出现(没填描述时整截不出现,不存在"
|
|
155
|
+
* **用户写的一句说明**。加这半句把来源点明,模型就不会把它当成场景的另一个名字或某种标识符。
|
|
156
|
+
* 前缀只在真有描述时出现(没填描述时整截不出现,不存在"user description:"后面空着)。
|
|
157
157
|
*
|
|
158
158
|
* 为什么不再用 `##` 标题:这一段只讲"当前启用的是哪个 + 它是什么",一行说完就够。
|
|
159
159
|
* 场景**分组**标题仍在记忆段里用(`sceneHeading`),两段各自承担自己的职责。
|
|
160
160
|
*/
|
|
161
161
|
export const sceneLine = (scene, index) => {
|
|
162
162
|
const described = String(index?.scenes?.[scene]?.description ?? '').replaceAll(/\s+/g, ' ').trim();
|
|
163
|
-
return
|
|
163
|
+
return `**"${sceneLabel(scene)}"${described === '' ? '' : ` — user description: ${described}`}**\n\n`;
|
|
164
164
|
};
|
|
165
165
|
/** 场景渲染顺序:全局 `global` 最先(它的记忆对任何对话都成立,先讲总则),
|
|
166
166
|
* 其次 `_shared`(历史保留名),其余按(索引 scenes.order, 场景名)。 */
|
|
@@ -246,7 +246,7 @@ export function attachmentLine(file) {
|
|
|
246
246
|
return '';
|
|
247
247
|
const shown = names.slice(0, ATTACHMENT_LIST_MAX);
|
|
248
248
|
const more = names.length - shown.length;
|
|
249
|
-
return
|
|
249
|
+
return `Attachments: ${file.bundleDir} (${shown.join(', ')}${more > 0 ? `, ${names.length} total` : ''})`;
|
|
250
250
|
}
|
|
251
251
|
// ── 单条记忆的渲染 ─────────────────────────────────────────────────────────
|
|
252
252
|
/**
|
|
@@ -254,7 +254,7 @@ export function attachmentLine(file) {
|
|
|
254
254
|
*
|
|
255
255
|
* 为什么不是"缩进 2 格就完事"(这是上一版的实现,被实测推翻):markdown 里缩进 ≤3 格的 `#`
|
|
256
256
|
* **仍然算标题**,所以正文里一句手写的 `# 代码/需求` 会渲染成 H1,**盖过容器自己的
|
|
257
|
-
* `##
|
|
257
|
+
* `## Scene: X`** —— 用户看到的就是这个:内容看起来"逃出了条目",与场景标题平级甚至更高。
|
|
258
258
|
* 缩到 4 格能压住标题(块级标记都要求 ≤3 格缩进),但正文里的列表/引用只是被"压平",
|
|
259
259
|
* 视觉上仍与条目正文同级,达不到"明显从属"。
|
|
260
260
|
*
|
|
@@ -279,8 +279,8 @@ export const explicitDescriptionOf = (f) => (f.descriptionDerived || !f.descript
|
|
|
279
279
|
/**
|
|
280
280
|
* 单条信息的渲染形态 —— **能一行就一行,但恒为列表项**。
|
|
281
281
|
*
|
|
282
|
-
* 单行且不长的正文 → `-
|
|
283
|
-
* 多行或过长的正文 → `-
|
|
282
|
+
* 单行且不长的正文 → `- **名称** (描述) — 正文`(与 MCP / 子智能体两个段的列表同形;无显式描述时括号不出现)
|
|
283
|
+
* 多行或过长的正文 → `- **名称** (描述)` + 空行 + **围栏代码块里的正文**(挂在条目下;理由见 `bodyBlock`)
|
|
284
284
|
*
|
|
285
285
|
* 名称恒为标题:它就是这条记忆的身份(工具 id `<场景>/<名称>`、bundle 目录/文件名都以它为准),
|
|
286
286
|
* 用户说「记忆里的 X」、模型再调 `memory_manager_*` 时都对得上号;显式描述是括号注解,不抢标题。
|
|
@@ -293,14 +293,14 @@ export const explicitDescriptionOf = (f) => (f.descriptionDerived || !f.descript
|
|
|
293
293
|
* 为什么要分两种:用户常有十几条「一句话事实」(「提交格式:PDF」),每条都占标题 + 空行 +
|
|
294
294
|
* 正文三行,整段会散成一长串标题;压成一行后十条信息就是十行。多行正文是**用户写的完整
|
|
295
295
|
* Markdown**(可能自带标题、代码块、嵌套列表),放进围栏块挂到条目下 —— 上一版让它以普通
|
|
296
|
-
* 段落参与外围排版,结果正文里的 `#` 会盖过 `##
|
|
296
|
+
* 段落参与外围排版,结果正文里的 `#` 会盖过 `## Scene: X`(见 `bodyBlock` 的说明)。
|
|
297
297
|
*
|
|
298
298
|
* 返回值带 `inline`:调用方据此决定下一条记忆前要不要空行(单行条目连续排列,其余空行分隔)。
|
|
299
299
|
*/
|
|
300
300
|
export function memoryBlock(file) {
|
|
301
301
|
const desc = explicitDescriptionOf(file);
|
|
302
|
-
// 加粗的只有名称:`-
|
|
303
|
-
const title = `**${file.name}**` + (desc === '' ? '' :
|
|
302
|
+
// 加粗的只有名称:`- **名称** (描述) — 正文`,与 MCP 段 `- **server**(N 个工具) — …` 同形。
|
|
303
|
+
const title = `**${file.name}**` + (desc === '' ? '' : ` (${desc})`);
|
|
304
304
|
const text = String(file.body ?? '').trim();
|
|
305
305
|
const inline = text === '' || (!text.includes('\n') && text.length <= INLINE_BODY_MAX);
|
|
306
306
|
const head = text === ''
|
package/lib/memories/service.js
CHANGED
|
@@ -661,9 +661,10 @@ export function createMemoriesService(ctx, deps) {
|
|
|
661
661
|
* sha1 hex):算的是**文件内容**(AGENTS.md 取截断前的原文),是"文件这一版"的身份,
|
|
662
662
|
* 不是"这次注入了什么";界面只拿它当悬停提示,不参与任何判定。
|
|
663
663
|
*
|
|
664
|
-
* `text` 是**模型侧**正文,开头一行
|
|
665
|
-
*
|
|
666
|
-
*
|
|
664
|
+
* `text` 是**模型侧**正文,开头一行 `Instructions from: <展示路径>` —— 与官方 agent-instructions
|
|
665
|
+
* 逐字同形(`render.js` 的 `sectionText`;2026-10-18 由 `Source:` 改过来:官方注入本来就用
|
|
666
|
+
* 这个措辞,模型对它有现成的先验)。模型据此知道这些规则写在哪个文件里:用户说"把这条记下来"
|
|
667
|
+
* 时它知道该改哪份文件,而不是只能凭印象回答。
|
|
667
668
|
*/
|
|
668
669
|
const resolvePrompt = () => {
|
|
669
670
|
// 场景提示词与 AGENTS.md 正文在用户眼里就是同一件事("全局提示词"):场景期间前者取代后者,
|
|
@@ -678,11 +679,11 @@ export function createMemoriesService(ctx, deps) {
|
|
|
678
679
|
const body = Buffer.byteLength(scene.text, 'utf8') <= AGENTS_MD_MAX_BYTES
|
|
679
680
|
? scene.text
|
|
680
681
|
: Buffer.from(scene.text, 'utf8').subarray(0, AGENTS_MD_MAX_BYTES).toString('utf8') +
|
|
681
|
-
`\n\n
|
|
682
|
+
`\n\n(Truncated: the preset body is over ${Math.floor(AGENTS_MD_MAX_BYTES / 1024)} KiB.)`;
|
|
682
683
|
return {
|
|
683
684
|
path,
|
|
684
685
|
digest: createHash('sha1').update(scene.text).digest('hex'),
|
|
685
|
-
text:
|
|
686
|
+
text: `Instructions from: ${path}\n\n${body}`,
|
|
686
687
|
};
|
|
687
688
|
}
|
|
688
689
|
const raw = readGlobalAgentsMdSync();
|
|
@@ -692,8 +693,8 @@ export function createMemoriesService(ctx, deps) {
|
|
|
692
693
|
const body = Buffer.byteLength(raw, 'utf8') <= AGENTS_MD_MAX_BYTES
|
|
693
694
|
? raw
|
|
694
695
|
: Buffer.from(raw, 'utf8').subarray(0, AGENTS_MD_MAX_BYTES).toString('utf8') +
|
|
695
|
-
`\n\n
|
|
696
|
-
return { path, digest: createHash('sha1').update(raw).digest('hex'), text:
|
|
696
|
+
`\n\n(Truncated: the file is over ${Math.floor(AGENTS_MD_MAX_BYTES / 1024)} KiB.)`;
|
|
697
|
+
return { path, digest: createHash('sha1').update(raw).digest('hex'), text: `Instructions from: ${path}\n\n${body}` };
|
|
697
698
|
};
|
|
698
699
|
/**
|
|
699
700
|
* 模型侧的提示词正文(`''` = 没有可注入的内容)。
|
package/lib/memories/snapshot.js
CHANGED
|
@@ -11,7 +11,7 @@ import { readdirSync, statSync } from 'node:fs';
|
|
|
11
11
|
import { readFile } from 'node:fs/promises';
|
|
12
12
|
import { dirname, join } from 'node:path';
|
|
13
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';
|
|
14
|
+
import { MAX_SOURCE_DEPTH, MAX_DIRECTORIES, MAX_ENTRIES, DEFAULT_ORDER, DEFAULT_GROUP_ORDER, GLOBAL_SCENE, TRUNCATION_MARKER, DROPPED_HEADING, SCENE_MEMORY_NOTE, SCENE_MEMORY_NOTE_PARTIAL, 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
16
|
import { isIndexQuarantined, readIndex, writeIndex, readFileOrThrowSync, isAbsentError } from './index-io.js';
|
|
17
17
|
// 来源发现与投影(扫目录读成条目 / frontmatter 投成规则 / 老布局搬家)2026-10-01 整段搬到
|
|
@@ -326,7 +326,7 @@ export function renderSceneMemory(probe, index, maxBytes) {
|
|
|
326
326
|
// 场景块只留标题:**场景说明归场景段**(`renderSceneCatalog`),否则同一句话会在
|
|
327
327
|
// 上下文里出现两遍。记忆段要的是"这些条目属于哪个场景"这个分组标签。
|
|
328
328
|
// `sceneHeading` 只给标题、不自带结尾空行(场景段的 `sceneLine` 才自带),这里补上 ——
|
|
329
|
-
// 否则标题会与紧跟的引导语/条目贴成一行(`##
|
|
329
|
+
// 否则标题会与紧跟的引导语/条目贴成一行(`## Always-on memories**Memories for…**`)。
|
|
330
330
|
const header = sceneHeading(scene) + '\n\n';
|
|
331
331
|
for (const file of sceneFiles) {
|
|
332
332
|
const { text: block, inline } = memoryBlock(file);
|
|
@@ -344,16 +344,16 @@ export function renderSceneMemory(probe, index, maxBytes) {
|
|
|
344
344
|
return { text: '', bytes: 0, truncated: probe.truncated, maxBytes, scenes: [], items: [], dropped: [] };
|
|
345
345
|
}
|
|
346
346
|
/**
|
|
347
|
-
* 选中块 → 段正文(同一场景的 `##
|
|
347
|
+
* 选中块 → 段正文(同一场景的 `## Scene: x` 只在首次出现时发出)。
|
|
348
348
|
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
349
|
+
* 授权语**两版**(2026-10-18 审查恢复分叉,理由见 `SCENE_MEMORY_NOTE_PARTIAL`):不截断时带
|
|
350
|
+
* "this is the complete set",截断时去掉 —— 否则段首的完整性与段尾的 `## Not injected` 直接
|
|
351
|
+
* 打架。预算仍按全量版预留(`sceneNoteBytes()`),截断态那句更短,不会多占。
|
|
352
352
|
*/
|
|
353
|
-
const renderBody = (selected) => {
|
|
353
|
+
const renderBody = (selected, truncated) => {
|
|
354
354
|
if (selected.length === 0)
|
|
355
355
|
return '';
|
|
356
|
-
const note = SCENE_MEMORY_NOTE;
|
|
356
|
+
const note = truncated ? SCENE_MEMORY_NOTE_PARTIAL : SCENE_MEMORY_NOTE;
|
|
357
357
|
const chunks = [];
|
|
358
358
|
let current = null;
|
|
359
359
|
let buf = '';
|
|
@@ -380,7 +380,7 @@ export function renderSceneMemory(probe, index, maxBytes) {
|
|
|
380
380
|
// 引导语只出现**一次**,放在板块层(第一个场景块之前)。
|
|
381
381
|
//
|
|
382
382
|
// 为什么挪上来(用户 2026-09-23 看到实际注入后要求):此前它跟着**每个场景块**各来一遍
|
|
383
|
-
// —— 2 个场景就是 272 B ≈68 tok/轮,占记忆段整段的 22%;而且它夹在 `##
|
|
383
|
+
// —— 2 个场景就是 272 B ≈68 tok/轮,占记忆段整段的 22%;而且它夹在 `## Scene: X` 与条目
|
|
384
384
|
// 之间,把"分组"和"内容"隔开了。它管的本来就是整段(授权语讲的是这一整段条目的效力,
|
|
385
385
|
// 不是某个场景的),放在最前面才对得上。
|
|
386
386
|
//
|
|
@@ -401,7 +401,8 @@ export function renderSceneMemory(probe, index, maxBytes) {
|
|
|
401
401
|
const lines = [];
|
|
402
402
|
let used = byteLen(head) + byteLen(marker);
|
|
403
403
|
for (const c of missed) {
|
|
404
|
-
|
|
404
|
+
// 只给 `场景/名称`:字节数对人有用、对模型没有动作价值(2026-10-18 审查)。
|
|
405
|
+
const line = `- ${sceneLabelOf(c.scene)}/${c.item.name}\n`;
|
|
405
406
|
if (used + byteLen(line) > space)
|
|
406
407
|
break;
|
|
407
408
|
lines.push(line);
|
|
@@ -410,8 +411,13 @@ export function renderSceneMemory(probe, index, maxBytes) {
|
|
|
410
411
|
if (lines.length === 0)
|
|
411
412
|
return byteLen(marker) <= space ? marker : '';
|
|
412
413
|
const rest = missed.length - lines.length;
|
|
413
|
-
const fold = `-
|
|
414
|
-
|
|
414
|
+
const fold = `- ... (${rest} more not listed)\n`;
|
|
415
|
+
// 标记与折叠行的关系(2026-10-18 审查收口):
|
|
416
|
+
// - 清单列全(rest === 0)→ 不发标记也不发折叠行:`## Not injected` 标题已说清。
|
|
417
|
+
// - 清单不全 + 折叠行放得下 → 只发折叠行(给了具体计数),不发标记(重复)。
|
|
418
|
+
// - 清单不全 + 折叠行放不下 → 只发标记(折叠行是更具体的版本,但放不下时标记是唯一信号)。
|
|
419
|
+
const folded = rest > 0 && used + byteLen(fold) <= space;
|
|
420
|
+
const tail = `${head}${lines.join('')}${folded ? fold : ''}${rest > 0 && !folded ? marker : ''}`;
|
|
415
421
|
return tail;
|
|
416
422
|
};
|
|
417
423
|
// ── ① 贪心:放得下就放,越界**跳过**(而不是整体停止)──────────────────
|
|
@@ -436,19 +442,31 @@ export function renderSceneMemory(probe, index, maxBytes) {
|
|
|
436
442
|
}
|
|
437
443
|
missed.sort((a, b) => a.seq - b.seq);
|
|
438
444
|
// ── ② 尾注自身也占字节:放不下就把已入选的块从后往前退回,直到回到预算内 ──
|
|
439
|
-
|
|
440
|
-
|
|
445
|
+
// 引导语要不要带完整性声明,取决于**最终**有没有截断 —— 所以每轮重算 body(贪心阶段不知道
|
|
446
|
+
// 结论)。两版字数差在 3 个词以内,预算按全量版预留,不会因此挤掉条目。
|
|
447
|
+
let truncatedNow = probe.truncated || missed.length > 0;
|
|
448
|
+
let body = renderBody(selected, truncatedNow);
|
|
449
|
+
let tail = renderTail(missed, maxBytes - byteLen(body), truncatedNow);
|
|
441
450
|
while (byteLen(body) + byteLen(tail) > maxBytes && selected.length > 0) {
|
|
442
451
|
missed.push(selected.pop());
|
|
443
452
|
missed.sort((a, b) => a.seq - b.seq);
|
|
444
|
-
|
|
445
|
-
|
|
453
|
+
truncatedNow = true;
|
|
454
|
+
body = renderBody(selected, truncatedNow);
|
|
455
|
+
tail = renderTail(missed, maxBytes - byteLen(body), truncatedNow);
|
|
446
456
|
}
|
|
447
457
|
let text = `${body}${tail}`.replace(/^\n+/, '');
|
|
448
|
-
// ③
|
|
449
|
-
//
|
|
450
|
-
|
|
451
|
-
|
|
458
|
+
// ③ 兜底:有记忆没注入时绝不能静默消失。
|
|
459
|
+
// - 原来(2026-09-? 初版)只兜 text === '' 的情形:body 非空、tail 放不下时,
|
|
460
|
+
// 模型看到的是一段看起来完整的记忆段 —— 既没有 `## Not injected` 也没有标记,
|
|
461
|
+
// 被截掉的那些条目就**完全无痕**了。这是 2026-10-18 审查发现的 P1:body 的字节
|
|
462
|
+
// 核算(`used`)与 `byteLen(body)` 有几字节的误差(渲染时条目间的 `\n` / `\n\n`
|
|
463
|
+
// 没全进 `used`),于是 reserve 留的余量偶尔不够,标记放不下,3 兜底又不触发。
|
|
464
|
+
// - 现在的判据:只要 missed 非空或发现阶段截断,而 text 里既没有标记也没有
|
|
465
|
+
// `## Not injected` 标题,就强制追加标记 —— 宁可超预算几字节(原 ③ 注释的原则),
|
|
466
|
+
// 也不能让截断无痕。两样有一样就不追加(信号已经在)。
|
|
467
|
+
if ((missed.length > 0 || probe.truncated) && !text.includes(TRUNCATION_MARKER) && !text.includes(DROPPED_HEADING)) {
|
|
468
|
+
text = text === '' ? `${TRUNCATION_MARKER}\n` : `${text.replace(/\n+$/, '')}\n${TRUNCATION_MARKER}\n`;
|
|
469
|
+
}
|
|
452
470
|
const scenes = [];
|
|
453
471
|
for (const c of selected)
|
|
454
472
|
if (!scenes.includes(c.scene))
|
|
@@ -456,7 +474,7 @@ export function renderSceneMemory(probe, index, maxBytes) {
|
|
|
456
474
|
return {
|
|
457
475
|
text,
|
|
458
476
|
bytes: byteLen(text),
|
|
459
|
-
truncated:
|
|
477
|
+
truncated: truncatedNow,
|
|
460
478
|
maxBytes,
|
|
461
479
|
scenes,
|
|
462
480
|
items: selected.map((c) => c.item),
|
package/lib/op-registry.js
CHANGED
|
@@ -243,6 +243,9 @@ export const OP_REGISTRY = Object.freeze({
|
|
|
243
243
|
// 客户端 boot 时必问一次决定挂不挂 `/` 菜单,那一次不能要令牌,否则没配令牌的宿主上这个功能
|
|
244
244
|
// 永远出不来。
|
|
245
245
|
'slash-settings': { write: true, writeWhen: (a) => a.set === true },
|
|
246
|
+
// 侧栏入口的开关(侧车 `sidebar-settings.json`):同 `slash-settings` —— 写侧车按写门禁、
|
|
247
|
+
// 不冻结;读侧是纯读(客户端 boot 时必问一次,那一次不能要令牌)。
|
|
248
|
+
'sidebar-settings': { write: true, writeWhen: (a) => a.set === true },
|
|
246
249
|
// 清理 patch 备份:删磁盘文件(备份里含明文凭据副本),按写操作门禁。
|
|
247
250
|
'backups-clean': { write: true },
|
|
248
251
|
// ── 场景候选源(4)与内联(1)──────────────────────────────────────────────
|