mocode-ai 1.1.6 → 1.1.8
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/README.md +23 -37
- package/README.zh-CN.md +46 -38
- package/dist/agent/core.js +106 -395
- package/dist/agent/index.js +2 -21
- package/dist/agent/spawn.js +3 -5
- package/dist/agent/work-discipline.js +28 -82
- package/dist/config/index.js +7 -7
- package/dist/context/age-aware.js +18 -48
- package/dist/context/artifacts.js +19 -17
- package/dist/context/budget.js +27 -28
- package/dist/context/classifier.js +0 -1
- package/dist/context/encoders/index.js +4 -11
- package/dist/context/index.js +4 -7
- package/dist/context/lifecycle.js +115 -483
- package/dist/context/pipeline.js +8 -15
- package/dist/context/relevance.js +77 -55
- package/dist/host/stdio.js +0 -6
- package/dist/i18n/index.js +0 -6
- package/dist/index.js +11 -1
- package/dist/llm/index.js +77 -5
- package/dist/mcp/index.js +0 -1
- package/dist/repl/index.js +21 -15
- package/dist/runtime/browser-manager.js +299 -0
- package/dist/runtime/dev-server-manager.js +354 -0
- package/dist/runtime/shutdown.js +26 -0
- package/dist/session/compact.js +86 -102
- package/dist/session/index.js +0 -1
- package/dist/session/scheduler.js +88 -92
- package/dist/session/trace-metrics.js +5 -92
- package/dist/session/trace.js +1 -10
- package/dist/tools/builtins/browser.js +199 -0
- package/dist/tools/builtins/dev-server.js +99 -0
- package/dist/tools/builtins/index.js +28 -19
- package/dist/tools/builtins/screenshot.js +173 -0
- package/dist/tools/builtins/view-image.js +49 -0
- package/dist/tools/constants.js +3 -0
- package/dist/tools/registry.js +5 -29
- package/dist/ui/layout.js +28 -5
- package/dist/ui/render.js +11 -0
- package/package.json +2 -2
- package/dist/agent/middleware/checklist.js +0 -59
- package/dist/session/drop.d.ts +0 -19
- package/dist/session/drop.js +0 -93
- package/dist/tools/builtins/drop-context.d.ts +0 -18
- package/dist/tools/builtins/drop-context.js +0 -68
- package/dist/verification/diagnostics.js +0 -108
- package/dist/verification/fingerprint.js +0 -54
- package/dist/verification/index.js +0 -333
- package/dist/verification/postconditions.js +0 -98
- package/dist/verification/targeted-tests.js +0 -96
- package/dist/verification/types.js +0 -1
|
@@ -1,563 +1,195 @@
|
|
|
1
|
-
// Observation Lifecycle Engine:
|
|
1
|
+
// Observation Lifecycle Engine: provenance metadata only.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
// - REFERENCED:被某个下游 read_file/edit_file/write_file 引用过(基于 path 字符串匹配)。
|
|
9
|
-
// - OBSOLETE:无任何消费者引用,且距离当前 push 已老化 N 步(默认 2)。
|
|
10
|
-
// - STUB:已被替换为存根(物理上 content 变成「⌦[无消费者:...]」)。
|
|
11
|
-
//
|
|
12
|
-
// 观察类工具两阶段衰减(避免误伤):
|
|
13
|
-
// - grep/glob/web_search/web_fetch 等「观察/检索类」工具两阶段衰减:
|
|
14
|
-
// Phase 1(10 步):LIVE → REFERENCED,保留完整内容(返回多个候选,剩余候选可能后续被消费)。
|
|
15
|
-
// Phase 2(+5 步):REFERENCED → DIGEST,替换为摘要存根(保留文件列表+命中数+参数,丢弃详情),
|
|
16
|
-
// 释放 ~90% token。states 仍为 REFERENCED,不引入新状态。
|
|
17
|
-
// - 当前轮保护区(最后一个 user 之后的工具结果)完全不动。
|
|
18
|
-
// - 已 STUB(含「⌦[已过时:...]」或「⌦[已剔除:...]」或本层的「⌦[无消费者:...]」)不重复处理。
|
|
19
|
-
// - 永不抛错(对齐上下文管道的「调度器永不抛错」契约)。
|
|
20
|
-
// - 只改 .content,不动 tool_call_id / 不删消息 / 不动 tool_calls 数组。
|
|
21
|
-
//
|
|
22
|
-
// 与 Relevance Pruner 的分工(不重复):
|
|
23
|
-
// - Relevance Pruner:管 read_file 同 path 旧 read + mutation 覆写 → 直接 STUB。
|
|
24
|
-
// - 本层:管「观察类工具老化后 → DIGEST、普通孤立工具老化后 → STUB」+
|
|
25
|
-
// 「被消费的工具 → REFERENCED 标记(可视)」。
|
|
26
|
-
//
|
|
27
|
-
// 触发点(agent/core.ts):
|
|
28
|
-
// - pushToolResult 出口,新 message idx = history.length - 1。
|
|
29
|
-
// - mutation 分支额外调 pushMutation 通知(也走 observeMutation 同语义)。
|
|
30
|
-
//
|
|
31
|
-
// 开关:`config.contextLifecycle`(默认 true;MOCODE_LIFECYCLE=false 回退)。
|
|
32
|
-
import { canonicalizePath, extractPath, isToolResultSuccess, lastUserIndex, toText, toolNameOf, } from './utils.js';
|
|
33
|
-
/** 观察类工具(LIVE → REFERENCED → DIGEST 两阶段衰减,永不自动 STUB)。 */
|
|
34
|
-
const OBSERVER_TOOLS = new Set([
|
|
35
|
-
'grep',
|
|
36
|
-
'glob',
|
|
37
|
-
'web_search',
|
|
38
|
-
'web_fetch',
|
|
39
|
-
]);
|
|
40
|
-
/** 消费者工具(这些工具的 push 会触发「上游被消费」标记)。
|
|
41
|
-
* 只列能基于 path 静态判定消费的;run_command/memory_* 等不参与(避免误伤)。 */
|
|
3
|
+
// Tool-call count is not context pressure, so this module never ages, digests,
|
|
4
|
+
// stubs, or otherwise rewrites history. It only records producer/consumer
|
|
5
|
+
// relationships for diagnostics and future pressure-aware decisions.
|
|
6
|
+
import { canonicalizePath, extractPath, isToolResultSuccess, toText, toolNameOf, } from './utils.js';
|
|
7
|
+
const OBSERVER_TOOLS = new Set(['grep', 'glob', 'web_search', 'web_fetch']);
|
|
42
8
|
const CONSUMER_TOOLS = new Set(['read_file', 'edit_file', 'write_file']);
|
|
43
|
-
/** mutation 工具:pushTool 跳过 autoStubOrphans(让 pushMutation 标完 read REFERENCED 再统一老化)。 */
|
|
44
|
-
const MUTATION_TOOLS = new Set(['edit_file', 'write_file']);
|
|
45
|
-
/** 存根前缀(区分 Relevance Pruner 与 drop_context)。 */
|
|
46
|
-
const STUB_PREFIX_NO_CONSUMER = '⌦[无消费者:观察结果已无引用价值]';
|
|
47
|
-
/** 观察类工具摘要前缀(DIGEST 状态标记)。 */
|
|
48
9
|
const DIGEST_PREFIX = '⌦[摘要:';
|
|
49
|
-
/** 老化阈值:某条工具消息自 push 以来经历的「消费者 push」次数。
|
|
50
|
-
* ≥ 这个值且仍为 LIVE 且非观察类 → 视为 OBSOLETE → STUB。
|
|
51
|
-
* 默认 2:等价于「跨过两个消费者 push 仍无人引用」= 跨过整轮最末尾的工具调用。 */
|
|
52
|
-
const DEFAULT_AGE_THRESHOLD = 2;
|
|
53
|
-
/** 观察类工具 Phase 1:LIVE → REFERENCED 的老化阈值(普通工具用 DEFAULT_AGE_THRESHOLD=2)。
|
|
54
|
-
* 10 步后才降为 REFERENCED,保留完整内容;理由:返回多个候选(grep 10 文件但只读 1 个),
|
|
55
|
-
* 剩余候选可能后续被消费,给足够时间窗口。 */
|
|
56
|
-
const OBSERVER_REFERENCED_AGE = 10;
|
|
57
|
-
/** 观察类工具 Phase 2:REFERENCED → DIGEST 的老化阈值(从 REFERENCED 起再累积)。
|
|
58
|
-
* +5 步后替换为摘要存根,保留文件列表+命中数+参数,丢弃详情;释放 ~90% token。 */
|
|
59
|
-
const OBSERVER_DIGEST_AGE = 5;
|
|
60
|
-
/** 从 tool 结果的 content 中提取「生产者命中过的 path 列表」。
|
|
61
|
-
* - read_file:没有 path 列表(本身就是单 path 消费者,无需再追生产者)。
|
|
62
|
-
* - grep:content 是 `file:line: ...` 行,提取每行的 file 段(只保留绝对路径形态或与 pattern 匹配的)。
|
|
63
|
-
* 简化:把所有看起来像「相对路径 + 文件名」的 token 抽出,留 narrow。
|
|
64
|
-
* - glob:content 是路径列表,按行 / 空格拆。
|
|
65
|
-
*
|
|
66
|
-
* 返回值:命中过的 path 字符串集合(已 dedup)。失败返空集。 */
|
|
67
10
|
function extractProducerPaths(toolName, content) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (canonical)
|
|
74
|
-
out.add(canonical);
|
|
11
|
+
const paths = new Set();
|
|
12
|
+
const add = (raw) => {
|
|
13
|
+
const normalized = canonicalizePath(raw);
|
|
14
|
+
if (normalized)
|
|
15
|
+
paths.add(normalized);
|
|
75
16
|
};
|
|
76
17
|
try {
|
|
77
18
|
if (toolName === 'grep') {
|
|
78
|
-
// grep 内置工具的结构化输出是 `path: N 处匹配,行号 [...]`;旧格式也可能是
|
|
79
|
-
// `path:line: content`。两者都必须建立 producer 索引,供后续 read_file 关联。
|
|
80
19
|
const rawLine = /^(.+?\.[A-Za-z0-9]+):\d+:/gm;
|
|
81
|
-
const
|
|
82
|
-
let
|
|
83
|
-
while ((
|
|
84
|
-
|
|
85
|
-
while ((
|
|
86
|
-
|
|
20
|
+
const summary = /^(.+?\.[A-Za-z0-9]+):\s*\d+\s*(?:处匹配|matches?)[,,]/gmi;
|
|
21
|
+
let match;
|
|
22
|
+
while ((match = rawLine.exec(content)))
|
|
23
|
+
add(match[1]);
|
|
24
|
+
while ((match = summary.exec(content)))
|
|
25
|
+
add(match[1]);
|
|
87
26
|
}
|
|
88
27
|
else if (toolName === 'glob') {
|
|
89
|
-
// glob 输出一般是「paths:」+ 换行 + 多路径;每行一个绝对或相对路径。
|
|
90
|
-
// 简化:按行切,跳过含空格的(避免命中 prose),取看起来像路径的行。
|
|
91
28
|
for (const line of content.split(/\r?\n/)) {
|
|
92
|
-
const
|
|
93
|
-
if (!
|
|
94
|
-
|
|
95
|
-
// 含扩展名或含路径分隔符
|
|
96
|
-
if (/\.[A-Za-z0-9]+$/.test(t) || t.includes('/') || t.includes('\\'))
|
|
97
|
-
addPath(t);
|
|
29
|
+
const value = line.trim();
|
|
30
|
+
if (value && !value.includes(' ') && (/\.[A-Za-z0-9]+$/.test(value) || value.includes('/') || value.includes('\\')))
|
|
31
|
+
add(value);
|
|
98
32
|
}
|
|
99
33
|
}
|
|
100
|
-
else if (toolName === 'web_search' || toolName === 'web_fetch') {
|
|
101
|
-
// 网络结果不在文件系统路径范畴;不参与 producer 路径索引(避免误匹配)。
|
|
102
|
-
}
|
|
103
34
|
}
|
|
104
35
|
catch {
|
|
105
|
-
//
|
|
36
|
+
// Metadata extraction is best-effort and must never block the agent.
|
|
106
37
|
}
|
|
107
|
-
return [...
|
|
38
|
+
return [...paths];
|
|
108
39
|
}
|
|
109
|
-
/**
|
|
110
|
-
* Observation Lifecycle Engine。
|
|
111
|
-
* 每个 runAgentCore 实例持一个;pushToolResult 出口调 pushTool、mutation 分支调 pushMutation。
|
|
112
|
-
* 内部 try/catch 兜底,对外永不抛错。
|
|
113
|
-
*/
|
|
114
40
|
export class LifecycleEngine {
|
|
115
|
-
ageThreshold;
|
|
116
|
-
/** 工具消息 idx → 状态。 */
|
|
117
41
|
states = new Map();
|
|
118
|
-
/** 工具消息 idx → 被消费的次数(同一上游被多次消费也只算 REFERENCED,不计并发)。 */
|
|
119
42
|
consumerCount = new Map();
|
|
120
|
-
/** 工具消息 idx → 自 push 以来的「消费者 push」次数(用于老化判定)。
|
|
121
|
-
* 每次 pushTool 触发,所有 LIVE 工具消息 age++。 */
|
|
122
|
-
age = new Map();
|
|
123
|
-
/** producer 路径 → 生产者工具消息 idx 列表(逆查用:某个 path 被消费时,反查上游 producer)。
|
|
124
|
-
* 注意:不存 read_file,因为 read 自身就是消费者不充当 producer。 */
|
|
125
43
|
producersByPath = new Map();
|
|
126
|
-
/** 已被 DIGEST 的 tool 消息 idx 集合(避免重复检查;states 保持 REFERENCED)。 */
|
|
127
44
|
digestedIdxs = new Set();
|
|
128
|
-
|
|
129
|
-
digestRetainedIdxs = new Set();
|
|
130
|
-
/** 当前步序号(用于 age 老化:每次 pushTool 自增,对比 age 阈值)。 */
|
|
131
|
-
step = 0;
|
|
132
|
-
/** 最后 user 索引缓存(pushTool 时重算;pushMutation 时也重算,因为 mutation 可能跟 user 同行)。 */
|
|
133
|
-
lastUser = -1;
|
|
134
|
-
constructor(ageThreshold = DEFAULT_AGE_THRESHOLD, history) {
|
|
135
|
-
this.ageThreshold = ageThreshold;
|
|
45
|
+
constructor(history) {
|
|
136
46
|
if (history)
|
|
137
47
|
this.rehydrate(history);
|
|
138
48
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
49
|
+
callArgs(history, idx) {
|
|
50
|
+
try {
|
|
51
|
+
const toolCallId = history[idx]?.tool_call_id;
|
|
52
|
+
if (!toolCallId)
|
|
53
|
+
return '';
|
|
54
|
+
for (let cursor = idx - 1; cursor >= 1; cursor--) {
|
|
55
|
+
const message = history[cursor];
|
|
56
|
+
if (message.role !== 'assistant')
|
|
57
|
+
continue;
|
|
58
|
+
const calls = message.tool_calls;
|
|
59
|
+
const hit = calls?.find((call) => call.id === toolCallId);
|
|
60
|
+
if (hit)
|
|
61
|
+
return hit.function?.arguments ?? '';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Ignore malformed resumed history.
|
|
66
|
+
}
|
|
67
|
+
return '';
|
|
68
|
+
}
|
|
69
|
+
registerProducer(history, idx, toolName, content) {
|
|
70
|
+
if (!OBSERVER_TOOLS.has(toolName))
|
|
71
|
+
return;
|
|
72
|
+
for (const path of extractProducerPaths(toolName, content)) {
|
|
73
|
+
const producers = this.producersByPath.get(path) ?? [];
|
|
74
|
+
if (!producers.includes(idx))
|
|
75
|
+
producers.push(idx);
|
|
76
|
+
this.producersByPath.set(path, producers);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
markProducerConsumers(history, idx, toolName) {
|
|
80
|
+
if (!CONSUMER_TOOLS.has(toolName))
|
|
81
|
+
return;
|
|
82
|
+
const path = canonicalizePath(extractPath(this.callArgs(history, idx)));
|
|
83
|
+
if (!path)
|
|
84
|
+
return;
|
|
85
|
+
for (const producerIdx of this.producersByPath.get(path) ?? []) {
|
|
86
|
+
if (producerIdx >= idx)
|
|
87
|
+
continue;
|
|
88
|
+
if (this.states.get(producerIdx) === 'LIVE')
|
|
89
|
+
this.states.set(producerIdx, 'REFERENCED');
|
|
90
|
+
this.consumerCount.set(producerIdx, (this.consumerCount.get(producerIdx) ?? 0) + 1);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
145
93
|
rehydrate(history) {
|
|
146
94
|
try {
|
|
147
|
-
const currentTurnStart = lastUserIndex(history);
|
|
148
|
-
let replayLastUser = -1;
|
|
149
|
-
const pendingDigests = new Set();
|
|
150
95
|
for (let idx = 1; idx < history.length; idx++) {
|
|
151
|
-
const
|
|
152
|
-
if (
|
|
153
|
-
replayLastUser = idx;
|
|
154
|
-
continue;
|
|
155
|
-
}
|
|
156
|
-
if (m.role !== 'tool')
|
|
96
|
+
const message = history[idx];
|
|
97
|
+
if (message.role !== 'tool')
|
|
157
98
|
continue;
|
|
158
99
|
const toolName = toolNameOf(history, idx);
|
|
159
100
|
if (!toolName)
|
|
160
101
|
continue;
|
|
161
|
-
const content = toText(
|
|
162
|
-
|
|
163
|
-
const isDigest = content.startsWith(DIGEST_PREFIX);
|
|
164
|
-
const isCurrentTurnTool = currentTurnStart >= 0 && idx > currentTurnStart;
|
|
165
|
-
const shouldRestoreLive = isToolResultSuccess(content) &&
|
|
166
|
-
!content.startsWith('⌦[') &&
|
|
167
|
-
(isObserver || isCurrentTurnTool);
|
|
168
|
-
if (shouldRestoreLive) {
|
|
169
|
-
this.states.set(idx, 'LIVE');
|
|
170
|
-
this.consumerCount.set(idx, 0);
|
|
171
|
-
this.age.set(idx, 0);
|
|
172
|
-
// 只有 observer 是 producer;当前轮普通工具只恢复自身状态。
|
|
173
|
-
if (isObserver) {
|
|
174
|
-
for (const path of extractProducerPaths(toolName, content)) {
|
|
175
|
-
const producers = this.producersByPath.get(path) ?? [];
|
|
176
|
-
producers.push(idx);
|
|
177
|
-
this.producersByPath.set(path, producers);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
else if (isObserver && isDigest) {
|
|
102
|
+
const content = toText(message.content);
|
|
103
|
+
if (content.startsWith(DIGEST_PREFIX)) {
|
|
182
104
|
this.states.set(idx, 'REFERENCED');
|
|
183
105
|
this.consumerCount.set(idx, 0);
|
|
184
|
-
this.age.set(idx, 0);
|
|
185
106
|
this.digestedIdxs.add(idx);
|
|
107
|
+
continue;
|
|
186
108
|
}
|
|
187
|
-
if (
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if (this.states.get(producerIdx) === 'LIVE') {
|
|
192
|
-
this.states.set(producerIdx, 'REFERENCED');
|
|
193
|
-
}
|
|
194
|
-
this.consumerCount.set(producerIdx, (this.consumerCount.get(producerIdx) ?? 0) + 1);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
for (const key of this.states.keys())
|
|
199
|
-
this.age.set(key, (this.age.get(key) ?? 0) + 1);
|
|
200
|
-
this.step++;
|
|
201
|
-
// 和 pushTool 一致:本轮 user 及之后的结果不处理;旧轮结果可以推进。
|
|
202
|
-
for (const [observerIdx, state] of this.states) {
|
|
203
|
-
if (observerIdx >= replayLastUser || this.isDigestFinalized(observerIdx))
|
|
204
|
-
continue;
|
|
205
|
-
const observerName = toolNameOf(history, observerIdx);
|
|
206
|
-
if (!observerName || !OBSERVER_TOOLS.has(observerName))
|
|
207
|
-
continue;
|
|
208
|
-
const age = this.age.get(observerIdx) ?? 0;
|
|
209
|
-
if (state === 'LIVE' && age >= OBSERVER_REFERENCED_AGE) {
|
|
210
|
-
this.states.set(observerIdx, 'REFERENCED');
|
|
211
|
-
}
|
|
212
|
-
else if (state === 'REFERENCED'
|
|
213
|
-
&& age >= OBSERVER_REFERENCED_AGE + OBSERVER_DIGEST_AGE) {
|
|
214
|
-
pendingDigests.add(observerIdx);
|
|
215
|
-
}
|
|
109
|
+
if (content.startsWith('⌦[')) {
|
|
110
|
+
this.states.set(idx, 'STUB');
|
|
111
|
+
this.consumerCount.set(idx, 0);
|
|
112
|
+
continue;
|
|
216
113
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
this.digestOne(history, idx, toolName);
|
|
114
|
+
if (!isToolResultSuccess(content))
|
|
115
|
+
continue;
|
|
116
|
+
this.states.set(idx, 'LIVE');
|
|
117
|
+
this.consumerCount.set(idx, 0);
|
|
118
|
+
this.registerProducer(history, idx, toolName, content);
|
|
119
|
+
this.markProducerConsumers(history, idx, toolName);
|
|
224
120
|
}
|
|
225
121
|
}
|
|
226
122
|
catch {
|
|
227
|
-
//
|
|
123
|
+
// A partial graph is safer than altering execution.
|
|
228
124
|
}
|
|
229
125
|
}
|
|
230
|
-
/**
|
|
231
|
-
* mutation 工具(edit_file/write_file)的 push 跳过本轮的 autoStubOrphans(由调用方在
|
|
232
|
-
* pushMutation 标完 read REFERENCED 之后再触发),避免刚被 mutation 消费的 read 被提前 STUB。 */
|
|
126
|
+
/** Register a result without changing message content. */
|
|
233
127
|
pushTool(history, idx, succeeded = true) {
|
|
234
128
|
try {
|
|
235
|
-
const
|
|
236
|
-
if (!
|
|
129
|
+
const message = history[idx];
|
|
130
|
+
if (!succeeded || message?.role !== 'tool')
|
|
237
131
|
return;
|
|
238
132
|
const toolName = toolNameOf(history, idx);
|
|
239
|
-
|
|
133
|
+
const content = toText(message.content);
|
|
134
|
+
if (!toolName || content.startsWith('⌦['))
|
|
240
135
|
return;
|
|
241
|
-
// 失败工具结果只保留给模型诊断,不登记为 observation、不会推动老化或污染统计。
|
|
242
|
-
if (!succeeded)
|
|
243
|
-
return;
|
|
244
|
-
// 已 stub 的不重复登记(幂等)。
|
|
245
|
-
const c = toText(m.content);
|
|
246
|
-
if (c.startsWith('⌦['))
|
|
247
|
-
return;
|
|
248
|
-
// 登记为 LIVE。
|
|
249
136
|
this.states.set(idx, 'LIVE');
|
|
250
137
|
this.consumerCount.set(idx, 0);
|
|
251
|
-
this.
|
|
252
|
-
|
|
253
|
-
if (succeeded && OBSERVER_TOOLS.has(toolName)) {
|
|
254
|
-
const paths = extractProducerPaths(toolName, c);
|
|
255
|
-
for (const p of paths) {
|
|
256
|
-
const arr = this.producersByPath.get(p) ?? [];
|
|
257
|
-
if (!arr.includes(idx))
|
|
258
|
-
arr.push(idx);
|
|
259
|
-
this.producersByPath.set(p, arr);
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
// 只有成功 consumer 才能消费上游;失败 read/edit/write 不改变旧数据状态。
|
|
263
|
-
if (succeeded && CONSUMER_TOOLS.has(toolName)) {
|
|
264
|
-
const argsRaw = (() => {
|
|
265
|
-
// tool 消息本身没有 args;args 在前导 assistant.tool_calls 里;直接走同 idx 前的 assistant。
|
|
266
|
-
const tcId = m.tool_call_id;
|
|
267
|
-
for (let j = idx - 1; j >= 1; j--) {
|
|
268
|
-
const mm = history[j];
|
|
269
|
-
if (mm.role !== 'assistant')
|
|
270
|
-
continue;
|
|
271
|
-
const tcs = mm.tool_calls;
|
|
272
|
-
const hit = tcs?.find((tc) => tc?.id === tcId);
|
|
273
|
-
if (hit)
|
|
274
|
-
return hit.function?.arguments ?? '';
|
|
275
|
-
}
|
|
276
|
-
return '';
|
|
277
|
-
})();
|
|
278
|
-
const path = canonicalizePath(extractPath(argsRaw));
|
|
279
|
-
if (path) {
|
|
280
|
-
// 1) 找该 path 的所有上游 producer(grep/glob/web_search/web_fetch)→ 标 REFERENCED。
|
|
281
|
-
const producers = this.producersByPath.get(path);
|
|
282
|
-
if (producers) {
|
|
283
|
-
for (const pidx of producers) {
|
|
284
|
-
if (this.states.get(pidx) === 'LIVE') {
|
|
285
|
-
this.states.set(pidx, 'REFERENCED');
|
|
286
|
-
}
|
|
287
|
-
this.consumerCount.set(pidx, (this.consumerCount.get(pidx) ?? 0) + 1);
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
// 2) 同 path 的旧 read_file(被本 read「替代」)→ 也标 REFERENCED(由 Relevance Pruner 已 stub)。
|
|
291
|
-
// 这里不重复操作,Relevance Pruner 那边管「内容已被新 read 替代」的语义。
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
// 所有 LIVE 工具消息 age++(本次 push 算一步)。stale 的 READ 消息也涨 age,直到 ≥ 阈值才可能 STUB。
|
|
295
|
-
for (const k of this.states.keys()) {
|
|
296
|
-
this.age.set(k, (this.age.get(k) ?? 0) + 1);
|
|
297
|
-
}
|
|
298
|
-
this.step++;
|
|
299
|
-
this.lastUser = lastUserIndex(history);
|
|
300
|
-
// 成功 mutation 跳过本轮 autoStubOrphans;调用方会在 pushMutation 标完旧 read 后统一检查。
|
|
301
|
-
if (MUTATION_TOOLS.has(toolName))
|
|
302
|
-
return;
|
|
303
|
-
// 老化检查:本次 push 完,扫描 LIVE(且非观察类)的工具消息,age ≥ 阈值 → 标 OBSOLETE → STUB。
|
|
304
|
-
this.autoStubOrphans(history);
|
|
138
|
+
this.registerProducer(history, idx, toolName, content);
|
|
139
|
+
this.markProducerConsumers(history, idx, toolName);
|
|
305
140
|
}
|
|
306
141
|
catch {
|
|
307
|
-
//
|
|
142
|
+
// Lifecycle metadata must never block tool-result insertion.
|
|
308
143
|
}
|
|
309
144
|
}
|
|
310
|
-
/** mutation
|
|
311
|
-
* 注意:本层不直接 stub read(那是 Relevance Pruner 的职责);本层只更新状态图。
|
|
312
|
-
* 注意:puhToolResult 出口已经登记过 mutation 本身,这里不再调 pushTool(避免 age 翻倍)。 */
|
|
145
|
+
/** Record exact-path mutation consumption without stubbing old reads. */
|
|
313
146
|
pushMutation(history, mutationIdx, path) {
|
|
314
147
|
try {
|
|
315
148
|
const mutationPath = canonicalizePath(path);
|
|
316
|
-
if (!mutationPath)
|
|
317
|
-
this.autoStubOrphans(history);
|
|
149
|
+
if (!mutationPath)
|
|
318
150
|
return;
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
// 标 REFERENCED。
|
|
323
|
-
const protectedFrom = Math.max(0, this.lastUser);
|
|
324
|
-
for (let i = 1; i < mutationIdx; i++) {
|
|
325
|
-
if (i >= protectedFrom)
|
|
151
|
+
for (let idx = 1; idx < mutationIdx; idx++) {
|
|
152
|
+
const message = history[idx];
|
|
153
|
+
if (message?.role !== 'tool' || toolNameOf(history, idx) !== 'read_file')
|
|
326
154
|
continue;
|
|
327
|
-
|
|
328
|
-
if (m?.role !== 'tool')
|
|
155
|
+
if (canonicalizePath(extractPath(this.callArgs(history, idx))) !== mutationPath)
|
|
329
156
|
continue;
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
const c = toText(m.content);
|
|
334
|
-
if (c.startsWith('⌦['))
|
|
335
|
-
continue;
|
|
336
|
-
const argsRaw = this.findToolArgs(history, i);
|
|
337
|
-
if (canonicalizePath(extractPath(argsRaw)) === mutationPath) {
|
|
338
|
-
if (this.states.get(i) === 'LIVE')
|
|
339
|
-
this.states.set(i, 'REFERENCED');
|
|
340
|
-
this.consumerCount.set(i, (this.consumerCount.get(i) ?? 0) + 1);
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
// 标完 read REFERENCED 后,统一跑老化检查(本次 mutation push 之前 pushTool 已跳过)。
|
|
344
|
-
this.autoStubOrphans(history);
|
|
345
|
-
}
|
|
346
|
-
catch {
|
|
347
|
-
// 永不抛错。
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
/** 老化自动处理:
|
|
351
|
-
* - 非观察类 LIVE 且 age ≥ 阈值 → OBSOLETE → STUB(完全丢弃)。
|
|
352
|
-
* - 观察类工具两阶段衰减:
|
|
353
|
-
* Phase 1: LIVE → REFERENCED(age ≥ OBSERVER_REFERENCED_AGE,保留完整内容)。
|
|
354
|
-
* Phase 2: REFERENCED → DIGEST(增量 age ≥ OBSERVER_DIGEST_AGE,替换为摘要存根)。
|
|
355
|
-
* - 当前轮保护区(最后一个 user 之后)完全不动。
|
|
356
|
-
*/
|
|
357
|
-
autoStubOrphans(history) {
|
|
358
|
-
try {
|
|
359
|
-
const protectedFrom = Math.max(0, this.lastUser);
|
|
360
|
-
for (const [idx, state] of this.states) {
|
|
361
|
-
if (idx >= protectedFrom)
|
|
362
|
-
continue; // 当前轮保护区
|
|
363
|
-
const age = this.age.get(idx) ?? 0;
|
|
364
|
-
const tn = toolNameOf(history, idx);
|
|
365
|
-
if (!tn)
|
|
366
|
-
continue;
|
|
367
|
-
if (OBSERVER_TOOLS.has(tn)) {
|
|
368
|
-
// Phase 1: LIVE → REFERENCED(保留完整内容)。
|
|
369
|
-
if (state === 'LIVE' && age >= OBSERVER_REFERENCED_AGE) {
|
|
370
|
-
this.states.set(idx, 'REFERENCED');
|
|
371
|
-
continue;
|
|
372
|
-
}
|
|
373
|
-
// Phase 2: REFERENCED → DIGEST(替换为摘要存根,状态不变)。
|
|
374
|
-
if (state === 'REFERENCED' && !this.isDigestFinalized(idx)) {
|
|
375
|
-
// read_file 可能在很早时就把 producer 标成 REFERENCED;不能因此跳过
|
|
376
|
-
// Phase 1 的保留窗口。摘要始终以 observer 自身的总 age 为准。
|
|
377
|
-
if (age >= OBSERVER_REFERENCED_AGE + OBSERVER_DIGEST_AGE) {
|
|
378
|
-
this.digestOne(history, idx, tn);
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
continue;
|
|
382
|
-
}
|
|
383
|
-
// 非观察类:只在 LIVE 状态下老化 STUB。
|
|
384
|
-
if (state !== 'LIVE')
|
|
385
|
-
continue;
|
|
386
|
-
if (age < this.ageThreshold)
|
|
387
|
-
continue;
|
|
388
|
-
this.stubOne(history, idx, tn);
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
catch {
|
|
392
|
-
// 永不抛错。
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
/** 实际替换 content 为存根。 */
|
|
396
|
-
stubOne(history, idx, toolName) {
|
|
397
|
-
try {
|
|
398
|
-
const m = history[idx];
|
|
399
|
-
if (!m)
|
|
400
|
-
return;
|
|
401
|
-
const c = toText(m.content);
|
|
402
|
-
if (c.startsWith('⌦['))
|
|
403
|
-
return; // 幂等
|
|
404
|
-
const origLen = c.length;
|
|
405
|
-
const stub = `${STUB_PREFIX_NO_CONSUMER} ${toolName} ${origLen} 字符 → 老化无消费者,自动归档`;
|
|
406
|
-
m.content = stub;
|
|
407
|
-
this.states.set(idx, 'STUB');
|
|
408
|
-
}
|
|
409
|
-
catch {
|
|
410
|
-
// 永不抛错。
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
/** 观察类工具 Phase 2:替换为摘要存根,保留文件列表+命中数+参数,丢弃详情。
|
|
414
|
-
* 幂等:已是 ⌦[ 前缀的跳过。states 保持 REFERENCED,只替换 content。 */
|
|
415
|
-
digestOne(history, idx, toolName) {
|
|
416
|
-
try {
|
|
417
|
-
const m = history[idx];
|
|
418
|
-
if (!m)
|
|
419
|
-
return;
|
|
420
|
-
const c = toText(m.content);
|
|
421
|
-
if (c.startsWith('⌦['))
|
|
422
|
-
return; // 幂等(已 STUB/DIGEST)
|
|
423
|
-
const origLen = c.length;
|
|
424
|
-
const argsRaw = this.findToolArgs(history, idx);
|
|
425
|
-
const summary = this.buildDigestSummary(toolName, c, argsRaw, origLen, extractProducerPaths(toolName, c));
|
|
426
|
-
// 摘要的唯一目标是降低上下文成本。短结果(如“未命中”)不能被固定文案放大。
|
|
427
|
-
if (summary.length >= origLen) {
|
|
428
|
-
this.digestRetainedIdxs.add(idx);
|
|
429
|
-
return;
|
|
430
|
-
}
|
|
431
|
-
m.content = summary;
|
|
432
|
-
// states 保持 REFERENCED;digestedIdxs 由 stats() 扣除显示。
|
|
433
|
-
this.digestedIdxs.add(idx);
|
|
434
|
-
}
|
|
435
|
-
catch {
|
|
436
|
-
// 永不抛错。
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
/** 为各观察类工具生成摘要字符串。只保留统计 + 参数,不保留详情(文件列表/URL/正文),
|
|
440
|
-
* 明确标注"这是历史摘要,需要最新信息请重新调用"。 */
|
|
441
|
-
buildDigestSummary(toolName, content, argsRaw, origLen, paths) {
|
|
442
|
-
const files = this.formatDigestPaths(paths);
|
|
443
|
-
switch (toolName) {
|
|
444
|
-
case 'grep': {
|
|
445
|
-
// 命中行数:content 里形如 `file:line:` 的行数
|
|
446
|
-
let lineCount = 0;
|
|
447
|
-
const re = /^[^\s:][^:]*?\.[A-Za-z0-9]+:\d+:/gm;
|
|
448
|
-
while (re.exec(content))
|
|
449
|
-
lineCount++;
|
|
450
|
-
const args = (() => { try {
|
|
451
|
-
const a = JSON.parse(argsRaw);
|
|
452
|
-
let s = `"${a.pattern ?? ''}"`;
|
|
453
|
-
if (a.glob)
|
|
454
|
-
s += `, glob="${a.glob}"`;
|
|
455
|
-
return s;
|
|
456
|
-
}
|
|
457
|
-
catch {
|
|
458
|
-
return '...';
|
|
459
|
-
} })();
|
|
460
|
-
return `${DIGEST_PREFIX}grep(${args}) — 历史结果 ${lineCount} 行命中,文件 ${files},${origLen}→摘要]\n如需完整详情或最新信息请重新调用 grep`;
|
|
461
|
-
}
|
|
462
|
-
case 'glob': {
|
|
463
|
-
const args = (() => { try {
|
|
464
|
-
return `"${JSON.parse(argsRaw).pattern ?? ''}"`;
|
|
465
|
-
}
|
|
466
|
-
catch {
|
|
467
|
-
return '...';
|
|
468
|
-
} })();
|
|
469
|
-
return `${DIGEST_PREFIX}glob(${args}) — 历史结果文件 ${files},${origLen}→摘要]\n如需完整列表或最新信息请重新调用 glob`;
|
|
470
|
-
}
|
|
471
|
-
case 'web_search': {
|
|
472
|
-
// 统计结果数
|
|
473
|
-
let resultCount = 0;
|
|
474
|
-
const lines = content.split(/\r?\n/);
|
|
475
|
-
for (const line of lines) {
|
|
476
|
-
if (/^\[\d+\]\s*.+/.test(line))
|
|
477
|
-
resultCount++;
|
|
478
|
-
}
|
|
479
|
-
const query = (() => { try {
|
|
480
|
-
return JSON.parse(argsRaw).query ?? '';
|
|
481
|
-
}
|
|
482
|
-
catch {
|
|
483
|
-
return '';
|
|
484
|
-
} })();
|
|
485
|
-
return `${DIGEST_PREFIX}web_search("${query}") — 历史结果 ${resultCount} 条, ${origLen}→摘要]\n如需最新信息请重新调用 web_search`;
|
|
486
|
-
}
|
|
487
|
-
case 'web_fetch': {
|
|
488
|
-
const url = (() => { try {
|
|
489
|
-
return JSON.parse(argsRaw).url ?? '';
|
|
490
|
-
}
|
|
491
|
-
catch {
|
|
492
|
-
return '';
|
|
493
|
-
} })();
|
|
494
|
-
return `${DIGEST_PREFIX}web_fetch(${url}) — 历史结果已归档, ${origLen}→摘要]\n如需最新信息请重新调用 web_fetch`;
|
|
495
|
-
}
|
|
496
|
-
default:
|
|
497
|
-
return `${DIGEST_PREFIX}${toolName} — 历史结果已归档, ${origLen}→摘要]\n如需最新信息请重新调用 ${toolName}`;
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
isDigestFinalized(idx) {
|
|
501
|
-
return this.digestedIdxs.has(idx) || this.digestRetainedIdxs.has(idx);
|
|
502
|
-
}
|
|
503
|
-
/** 文件候选是摘要的关键可用信息;限量且去重,避免摘要本身重新膨胀。 */
|
|
504
|
-
formatDigestPaths(paths) {
|
|
505
|
-
const unique = [...new Set(paths)];
|
|
506
|
-
if (unique.length === 0)
|
|
507
|
-
return '0 个(未能从输出解析路径)';
|
|
508
|
-
const limit = 12;
|
|
509
|
-
const shown = unique.slice(0, limit).join(', ');
|
|
510
|
-
return unique.length > limit ? `${unique.length} 个 [${shown}, …]` : `${unique.length} 个 [${shown}]`;
|
|
511
|
-
}
|
|
512
|
-
/** 找某条 tool 消息对应的 assistant.tool_calls.arguments。 */
|
|
513
|
-
findToolArgs(history, idx) {
|
|
514
|
-
try {
|
|
515
|
-
const tcId = history[idx].tool_call_id;
|
|
516
|
-
if (!tcId)
|
|
517
|
-
return '';
|
|
518
|
-
for (let j = idx - 1; j >= 1; j--) {
|
|
519
|
-
const mm = history[j];
|
|
520
|
-
if (mm.role !== 'assistant')
|
|
521
|
-
continue;
|
|
522
|
-
const tcs = mm.tool_calls;
|
|
523
|
-
const hit = tcs?.find((tc) => tc?.id === tcId);
|
|
524
|
-
if (hit)
|
|
525
|
-
return hit.function?.arguments ?? '';
|
|
157
|
+
if (this.states.get(idx) === 'LIVE')
|
|
158
|
+
this.states.set(idx, 'REFERENCED');
|
|
159
|
+
this.consumerCount.set(idx, (this.consumerCount.get(idx) ?? 0) + 1);
|
|
526
160
|
}
|
|
527
161
|
}
|
|
528
162
|
catch {
|
|
529
|
-
//
|
|
163
|
+
// Provenance tracking is best-effort.
|
|
530
164
|
}
|
|
531
|
-
return '';
|
|
532
165
|
}
|
|
533
|
-
// ── 观测 API(供 /context 面板、调试脚本用) ─────────────────────────────
|
|
534
|
-
/** 拿某 idx 的当前状态;不在图里返 null。 */
|
|
535
166
|
getState(idx) {
|
|
536
167
|
return this.states.get(idx) ?? null;
|
|
537
168
|
}
|
|
538
|
-
/** 拿当前各状态计数。供 /context 显示「live=N, referenced=M, digest=K, obsolete=J, stubbed=S」。
|
|
539
|
-
* digested:已被摘要的观察类工具(states 仍为 REFERENCED,但 content 已替换为摘要)。 */
|
|
540
169
|
stats() {
|
|
541
170
|
let live = 0;
|
|
542
171
|
let referenced = 0;
|
|
543
172
|
let obsolete = 0;
|
|
544
173
|
let stubbed = 0;
|
|
545
|
-
for (const
|
|
546
|
-
if (
|
|
174
|
+
for (const state of this.states.values()) {
|
|
175
|
+
if (state === 'LIVE')
|
|
547
176
|
live++;
|
|
548
|
-
else if (
|
|
177
|
+
else if (state === 'REFERENCED')
|
|
549
178
|
referenced++;
|
|
550
|
-
else if (
|
|
179
|
+
else if (state === 'OBSOLETE')
|
|
551
180
|
obsolete++;
|
|
552
|
-
else if (
|
|
181
|
+
else if (state === 'STUB')
|
|
553
182
|
stubbed++;
|
|
554
183
|
}
|
|
555
|
-
|
|
556
|
-
|
|
184
|
+
return {
|
|
185
|
+
live,
|
|
186
|
+
referenced: Math.max(0, referenced - this.digestedIdxs.size),
|
|
187
|
+
digested: this.digestedIdxs.size,
|
|
188
|
+
obsolete,
|
|
189
|
+
stubbed,
|
|
190
|
+
};
|
|
557
191
|
}
|
|
558
192
|
}
|
|
559
|
-
export function createLifecycleEngine(
|
|
560
|
-
|
|
561
|
-
const threshold = typeof historyOrThreshold === 'number' ? historyOrThreshold : ageThreshold;
|
|
562
|
-
return new LifecycleEngine(threshold, history);
|
|
193
|
+
export function createLifecycleEngine(history) {
|
|
194
|
+
return new LifecycleEngine(history);
|
|
563
195
|
}
|