mocode-ai 0.6.5 → 0.6.7
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/dist/agent/core.js +54 -31
- package/dist/context/age-aware.js +136 -0
- package/dist/context/budget.js +56 -86
- package/dist/context/encoders/code.js +186 -60
- package/dist/context/encoders/command.js +201 -0
- package/dist/context/encoders/graph.js +69 -19
- package/dist/context/encoders/index.js +2 -2
- package/dist/context/encoders/log.js +2 -52
- package/dist/context/encoders/search.js +116 -50
- package/dist/context/index.js +1 -1
- package/dist/context/pipeline.js +5 -1
- package/dist/context/relevance.js +166 -121
- package/dist/context/token-calibration.js +104 -0
- package/dist/llm/index.js +42 -99
- package/dist/llm/think-filter.js +90 -0
- package/dist/repl/index.js +0 -2
- package/dist/session/compact.js +20 -23
- package/dist/session/index.js +3 -3
- package/dist/session/scheduler.js +18 -38
- package/dist/tools/builtins/run-command.js +29 -9
- package/dist/tools/constants.js +0 -17
- package/package.json +1 -1
|
@@ -1,68 +1,134 @@
|
|
|
1
|
-
const
|
|
1
|
+
const LEGACY_GREP_RE = /^(.*?):(\d+):(.*)$/;
|
|
2
|
+
const STRUCTURED_HEADER_RE = /^(.*): (\d+) 处匹配,行号 \[([0-9,\s]+)\]$/;
|
|
3
|
+
const STRUCTURED_BODY_RE = /^\s{2}L\d+:/;
|
|
4
|
+
const STRUCTURED_FOLDED_RE = /^\s{2}\(body 已折叠/;
|
|
5
|
+
function isAgedCold(input) {
|
|
6
|
+
return input.phase === 'sweep' && input.isCold === true && (input.age ?? 0) >= 2;
|
|
7
|
+
}
|
|
8
|
+
/** Current grep output already has lossless file headers; Cold drops body previews only. */
|
|
9
|
+
function collapseStructuredGrep(output) {
|
|
10
|
+
const lines = output.split('\n');
|
|
11
|
+
const out = [];
|
|
12
|
+
let files = 0;
|
|
13
|
+
let inFile = false;
|
|
14
|
+
for (const line of lines) {
|
|
15
|
+
if (STRUCTURED_HEADER_RE.test(line)) {
|
|
16
|
+
files++;
|
|
17
|
+
inFile = true;
|
|
18
|
+
out.push(line);
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (inFile && (STRUCTURED_BODY_RE.test(line) || STRUCTURED_FOLDED_RE.test(line))) {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
inFile = false;
|
|
25
|
+
out.push(line);
|
|
26
|
+
}
|
|
27
|
+
return files > 0 ? { text: out.join('\n'), files } : null;
|
|
28
|
+
}
|
|
29
|
+
function encodeLegacyGrep(output) {
|
|
30
|
+
const lines = output.split('\n');
|
|
31
|
+
const matches = [];
|
|
32
|
+
const tail = [];
|
|
33
|
+
let inTail = false;
|
|
34
|
+
for (const line of lines) {
|
|
35
|
+
if (!line)
|
|
36
|
+
continue;
|
|
37
|
+
if (inTail) {
|
|
38
|
+
tail.push(line);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const match = LEGACY_GREP_RE.exec(line);
|
|
42
|
+
if (match) {
|
|
43
|
+
matches.push({ file: match[1], line: match[2], content: match[3] });
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
inTail = true;
|
|
47
|
+
tail.push(line);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (matches.length === 0)
|
|
51
|
+
return null;
|
|
52
|
+
const groups = new Map();
|
|
53
|
+
const order = [];
|
|
54
|
+
for (const match of matches) {
|
|
55
|
+
if (!groups.has(match.file)) {
|
|
56
|
+
groups.set(match.file, []);
|
|
57
|
+
order.push(match.file);
|
|
58
|
+
}
|
|
59
|
+
groups.get(match.file).push({ line: match.line, content: match.content });
|
|
60
|
+
}
|
|
61
|
+
const out = [`# ${matches.length} matches · ${order.length} files · search-encoded`];
|
|
62
|
+
for (const file of order) {
|
|
63
|
+
out.push(`${file}:`);
|
|
64
|
+
for (const item of groups.get(file))
|
|
65
|
+
out.push(` ${item.line}:${item.content}`);
|
|
66
|
+
}
|
|
67
|
+
if (tail.length)
|
|
68
|
+
out.push(...tail);
|
|
69
|
+
return { text: out.join('\n'), matches: matches.length, files: order.length };
|
|
70
|
+
}
|
|
71
|
+
function collapseLegacyBodies(text) {
|
|
72
|
+
if (!text.startsWith('# ') || !text.includes('search-encoded'))
|
|
73
|
+
return text;
|
|
74
|
+
return text
|
|
75
|
+
.split('\n')
|
|
76
|
+
.filter((line) => !/^\s{2}\d+:/.test(line))
|
|
77
|
+
.join('\n');
|
|
78
|
+
}
|
|
2
79
|
export const searchEncoder = {
|
|
3
80
|
kind: 'search',
|
|
4
|
-
encode(
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
let inTail = false;
|
|
9
|
-
for (const l of lines) {
|
|
10
|
-
if (!l)
|
|
11
|
-
continue;
|
|
12
|
-
if (inTail) {
|
|
13
|
-
tail.push(l);
|
|
14
|
-
continue;
|
|
15
|
-
}
|
|
16
|
-
const m = GREP_RE.exec(l);
|
|
17
|
-
if (m) {
|
|
18
|
-
matches.push({ file: m[1], line: m[2], content: m[3] });
|
|
19
|
-
}
|
|
20
|
-
else {
|
|
21
|
-
// 首个非 grep 行起视作 tail(grep 上限标记 / 无匹配串 / web_search 非 grep 结构)
|
|
22
|
-
inTail = true;
|
|
23
|
-
tail.push(l);
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
if (matches.length === 0) {
|
|
27
|
-
// 非 grep 格式(web_search 等)→ 不动其已格式化结构
|
|
81
|
+
encode(input) {
|
|
82
|
+
const structured = collapseStructuredGrep(input.output);
|
|
83
|
+
if (structured) {
|
|
84
|
+
const text = isAgedCold(input) ? structured.text : input.output;
|
|
28
85
|
return {
|
|
29
|
-
text
|
|
86
|
+
text,
|
|
30
87
|
meta: {
|
|
31
88
|
kind: 'search',
|
|
32
|
-
originalLen: output.length,
|
|
33
|
-
encodedLen:
|
|
34
|
-
note:
|
|
89
|
+
originalLen: input.output.length,
|
|
90
|
+
encodedLen: text.length,
|
|
91
|
+
note: isAgedCold(input)
|
|
92
|
+
? `${structured.files} files · Cold body previews removed`
|
|
93
|
+
: `${structured.files} structured grep files · passthrough`,
|
|
35
94
|
},
|
|
36
95
|
};
|
|
37
96
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
97
|
+
// A previous pass may already have transformed legacy file:line output.
|
|
98
|
+
if (input.output.startsWith('# ') && input.output.includes('search-encoded')) {
|
|
99
|
+
const text = isAgedCold(input) ? collapseLegacyBodies(input.output) : input.output;
|
|
100
|
+
return {
|
|
101
|
+
text,
|
|
102
|
+
meta: {
|
|
103
|
+
kind: 'search',
|
|
104
|
+
originalLen: input.output.length,
|
|
105
|
+
encodedLen: text.length,
|
|
106
|
+
note: isAgedCold(input) ? 'Cold legacy bodies removed' : 'already encoded',
|
|
107
|
+
},
|
|
108
|
+
};
|
|
46
109
|
}
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
110
|
+
const legacy = encodeLegacyGrep(input.output);
|
|
111
|
+
if (!legacy) {
|
|
112
|
+
return {
|
|
113
|
+
text: input.output,
|
|
114
|
+
meta: {
|
|
115
|
+
kind: 'search',
|
|
116
|
+
originalLen: input.output.length,
|
|
117
|
+
encodedLen: input.output.length,
|
|
118
|
+
note: 'no recognized grep structure → passthrough',
|
|
119
|
+
},
|
|
120
|
+
};
|
|
55
121
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
122
|
+
const text = isAgedCold(input)
|
|
123
|
+
? collapseLegacyBodies(legacy.text)
|
|
124
|
+
: legacy.text;
|
|
59
125
|
return {
|
|
60
126
|
text,
|
|
61
127
|
meta: {
|
|
62
128
|
kind: 'search',
|
|
63
|
-
originalLen: output.length,
|
|
129
|
+
originalLen: input.output.length,
|
|
64
130
|
encodedLen: text.length,
|
|
65
|
-
note: `${matches
|
|
131
|
+
note: `${legacy.matches} matches / ${legacy.files} files${isAgedCold(input) ? ' · Cold bodies removed' : ''}`,
|
|
66
132
|
},
|
|
67
133
|
};
|
|
68
134
|
},
|
package/dist/context/index.js
CHANGED
|
@@ -8,4 +8,4 @@ export { optimizeToolResult } from './pipeline.js';
|
|
|
8
8
|
export { classify, knownToolKinds } from './classifier.js';
|
|
9
9
|
export { registerEncoder, registerAll, getEncoder, registeredKinds, } from './registry.js';
|
|
10
10
|
// ── Context Budget Scheduler ───────────────────────────────────────────────
|
|
11
|
-
export { evaluateBudget, scheduleActions, formatReport, quickEstimate, userTurnBoundary, BUDGET_LAYERS, BUDGET_RATIO, HOT_TURN_WINDOW, TOOL_OLD_AGE, } from './budget.js';
|
|
11
|
+
export { evaluateBudget, scheduleActions, formatReport, quickEstimate, userTurnBoundary, BUDGET_LAYERS, DEFAULT_BUDGET_POLICY, BUDGET_RATIO, HOT_TURN_WINDOW, TOOL_OLD_AGE, } from './budget.js';
|
package/dist/context/pipeline.js
CHANGED
|
@@ -58,7 +58,7 @@ function budgetFor(name) {
|
|
|
58
58
|
* @param argsRaw 工具 arguments 原始 JSON 字符串(tc.arguments,可空;未传则 args=null)
|
|
59
59
|
* @returns 进 history 的 content 字符串(永不抛错)
|
|
60
60
|
*/
|
|
61
|
-
export function optimizeToolResult(name, output, argsRaw) {
|
|
61
|
+
export function optimizeToolResult(name, output, argsRaw, context = {}) {
|
|
62
62
|
boot();
|
|
63
63
|
// 总开关关闭:完全走老路径,零行为变化(Phase 1 默认 true,但保留紧急回退开关)。
|
|
64
64
|
if (!config.contextOptimize) {
|
|
@@ -73,6 +73,10 @@ export function optimizeToolResult(name, output, argsRaw) {
|
|
|
73
73
|
output,
|
|
74
74
|
args,
|
|
75
75
|
budget: budgetFor(name),
|
|
76
|
+
age: context.age ?? 0,
|
|
77
|
+
isCold: context.isCold ?? false,
|
|
78
|
+
isFirstRead: context.isFirstRead,
|
|
79
|
+
phase: context.phase ?? 'push',
|
|
76
80
|
});
|
|
77
81
|
// 末尾长度裁剪兜底(同改造前):encoder 已更短则 no-op;use_skill/memory_search 的放宽 cap 由此保留。
|
|
78
82
|
return capToolResultForHistory(name, text);
|
|
@@ -1,93 +1,129 @@
|
|
|
1
|
-
// Relevance Pruner:
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
1
|
+
// Relevance Pruner: statically removes tool results that a newer observation supersedes.
|
|
2
|
+
// It never deletes messages or changes tool_call_id pairing; only tool content is stubbed.
|
|
3
|
+
import { canonicalizePath, extractPath, isToolResultSuccess, lastUserIndex, toText, } from './utils.js';
|
|
4
|
+
/** Shared prefix lets /context count read and observation supersession together. */
|
|
5
|
+
const STUB_PREFIX = '⌦[已过时:';
|
|
6
|
+
const READ_STUB_REASON = '同 path 已有新 read / 已被 mutation 覆写';
|
|
7
|
+
function parseArgs(raw) {
|
|
8
|
+
try {
|
|
9
|
+
const parsed = raw.trim() ? JSON.parse(raw) : {};
|
|
10
|
+
return parsed && typeof parsed === 'object'
|
|
11
|
+
? parsed
|
|
12
|
+
: null;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function normalizedInteger(value, fallback) {
|
|
19
|
+
const n = Number(value);
|
|
20
|
+
return Number.isFinite(n) ? Math.trunc(n) : fallback;
|
|
21
|
+
}
|
|
22
|
+
/** Only complete semantic-query equality is safe for whole-message replacement. */
|
|
23
|
+
function observationKey(call) {
|
|
24
|
+
const args = call.args;
|
|
25
|
+
if (!args)
|
|
26
|
+
return null;
|
|
27
|
+
if (call.name === 'grep') {
|
|
28
|
+
if (typeof args.pattern !== 'string')
|
|
29
|
+
return null;
|
|
30
|
+
const rawMax = normalizedInteger(args.max_per_file, 15);
|
|
31
|
+
return JSON.stringify({
|
|
32
|
+
tool: 'grep',
|
|
33
|
+
pattern: args.pattern,
|
|
34
|
+
glob: typeof args.glob === 'string' ? args.glob : '**/*',
|
|
35
|
+
maxPerFile: Math.min(Math.max(rawMax, 1), 50),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
if (call.name === 'codegraph') {
|
|
39
|
+
if (typeof args.action !== 'string' || typeof args.query !== 'string')
|
|
40
|
+
return null;
|
|
41
|
+
const query = args.action === 'explore'
|
|
42
|
+
? args.query.trim().replace(/\s+/g, ' ')
|
|
43
|
+
: args.query.trim();
|
|
44
|
+
return JSON.stringify({
|
|
45
|
+
tool: 'codegraph',
|
|
46
|
+
action: args.action,
|
|
47
|
+
query,
|
|
48
|
+
file: typeof args.file === 'string' ? args.file.trim().replace(/\\/g, '/') : '',
|
|
49
|
+
offset: args.offset === undefined ? null : normalizedInteger(args.offset, 0),
|
|
50
|
+
limit: args.limit === undefined ? null : normalizedInteger(args.limit, 0),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
function observationLabel(call) {
|
|
56
|
+
const args = call.args ?? {};
|
|
57
|
+
if (call.name === 'grep') {
|
|
58
|
+
const pattern = JSON.stringify(String(args.pattern ?? '')).slice(0, 80);
|
|
59
|
+
const glob = JSON.stringify(String(args.glob ?? '**/*')).slice(0, 80);
|
|
60
|
+
return `grep(pattern=${pattern}, glob=${glob})`;
|
|
61
|
+
}
|
|
62
|
+
const action = String(args.action ?? '');
|
|
63
|
+
const query = JSON.stringify(String(args.query ?? '')).slice(0, 100);
|
|
64
|
+
return `codegraph(action=${action}, query=${query})`;
|
|
65
|
+
}
|
|
29
66
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* 设计:每个 agent 会话(每个 runAgentCore 实例)持有一个 pruner。会话结束/换 plan 时
|
|
35
|
-
* 可新建;不持久化(history 重建时索引自然过期)。
|
|
36
|
-
*
|
|
37
|
-
* 零依赖:仅依赖 ChatMessage 形状;不 import llm / tools / agent。
|
|
67
|
+
* Cross-message relevance pruning:
|
|
68
|
+
* - read_file: a newer successful read of the same canonical path supersedes old reads.
|
|
69
|
+
* - grep/codegraph: a newer successful call with the exact same semantic arguments
|
|
70
|
+
* supersedes old results. Partial file overlap is intentionally not enough.
|
|
38
71
|
*/
|
|
39
72
|
export class RelevancePruner {
|
|
40
|
-
/** path → [history index, ...] 按插入序;最新在末尾。 */
|
|
41
73
|
readByPath = new Map();
|
|
42
|
-
|
|
43
|
-
* - 只处理成功的 read_file tool 消息;失败读取不能淘汰旧的有效结果。
|
|
44
|
-
* - 登记当前 canonical path,并反向 stub 同 path 旧 read。
|
|
45
|
-
*/
|
|
74
|
+
observationByKey = new Map();
|
|
46
75
|
observePush(history, msg, succeeded = true) {
|
|
47
76
|
try {
|
|
48
77
|
if (!succeeded || msg.role !== 'tool')
|
|
49
78
|
return;
|
|
50
|
-
const m = msg;
|
|
51
79
|
const idx = history.length - 1;
|
|
52
80
|
if (idx < 1 || history[idx] !== msg)
|
|
53
|
-
return; // 防御:必须刚 push 到末尾
|
|
54
|
-
if (toolNameOf(history, idx) !== 'read_file')
|
|
55
81
|
return;
|
|
56
82
|
const content = toText(msg.content);
|
|
57
83
|
if (content.startsWith(STUB_PREFIX))
|
|
58
|
-
return;
|
|
59
|
-
const
|
|
60
|
-
if (!
|
|
61
|
-
return;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
84
|
+
return;
|
|
85
|
+
const call = this.callAt(history, idx);
|
|
86
|
+
if (!call)
|
|
87
|
+
return;
|
|
88
|
+
if (call.name === 'read_file') {
|
|
89
|
+
const path = canonicalizePath(extractPath(call.argsRaw));
|
|
90
|
+
if (!path)
|
|
91
|
+
return;
|
|
92
|
+
this.stubPriorReads(history, path, idx);
|
|
93
|
+
const list = this.readByPath.get(path) ?? [];
|
|
66
94
|
list.push(idx);
|
|
67
|
-
|
|
68
|
-
|
|
95
|
+
this.readByPath.set(path, list);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const key = observationKey(call);
|
|
99
|
+
if (!key)
|
|
100
|
+
return;
|
|
101
|
+
this.stubPriorObservations(history, call.name, key, idx);
|
|
102
|
+
const list = this.observationByKey.get(key) ?? [];
|
|
103
|
+
list.push(idx);
|
|
104
|
+
this.observationByKey.set(key, list);
|
|
69
105
|
}
|
|
70
106
|
catch {
|
|
71
|
-
|
|
107
|
+
// Relevance pruning must never block tool-result insertion.
|
|
72
108
|
}
|
|
73
109
|
}
|
|
74
|
-
|
|
75
|
-
pathAt(history, idx) {
|
|
110
|
+
callAt(history, idx) {
|
|
76
111
|
const tcId = history[idx]?.tool_call_id;
|
|
77
112
|
if (!tcId)
|
|
78
113
|
return null;
|
|
79
114
|
for (let j = idx - 1; j >= 1; j--) {
|
|
80
|
-
const
|
|
81
|
-
if (
|
|
115
|
+
const message = history[j];
|
|
116
|
+
if (message.role !== 'assistant')
|
|
117
|
+
continue;
|
|
118
|
+
const calls = message.tool_calls;
|
|
119
|
+
const hit = calls?.find((tc) => tc?.id === tcId);
|
|
120
|
+
if (!hit?.function?.name)
|
|
82
121
|
continue;
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
if (hit)
|
|
86
|
-
return canonicalizePath(extractPath(hit.function?.arguments));
|
|
122
|
+
const argsRaw = hit.function.arguments ?? '';
|
|
123
|
+
return { name: hit.function.name, argsRaw, args: parseArgs(argsRaw) };
|
|
87
124
|
}
|
|
88
125
|
return null;
|
|
89
126
|
}
|
|
90
|
-
/** 成功 mutation 后,把该 canonical path 在 mutation 之前的 read 全部 stub。 */
|
|
91
127
|
observeMutation(history, path) {
|
|
92
128
|
try {
|
|
93
129
|
const canonicalPath = canonicalizePath(path);
|
|
@@ -100,93 +136,102 @@ export class RelevancePruner {
|
|
|
100
136
|
this.readByPath.delete(canonicalPath);
|
|
101
137
|
}
|
|
102
138
|
catch {
|
|
103
|
-
|
|
139
|
+
// Never throw from mutation cleanup.
|
|
104
140
|
}
|
|
105
141
|
}
|
|
106
|
-
/**
|
|
107
|
-
* 把 history 里 "canonical path 同 + index < beforeIdx + 不在当前轮保护区" 的 read_file
|
|
108
|
-
* tool 消息替换为存根。索引是快路径,全表扫描用于恢复 resume 历史;两条路径都重新校验 path。
|
|
109
|
-
*/
|
|
110
142
|
stubPriorReads(history, path, beforeIdx) {
|
|
111
143
|
const targetPath = canonicalizePath(path);
|
|
112
144
|
if (!targetPath)
|
|
113
145
|
return;
|
|
114
|
-
const
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
if (i >= beforeIdx)
|
|
146
|
+
const protectedFrom = Math.max(0, lastUserIndex(history));
|
|
147
|
+
const stubOne = (idx) => {
|
|
148
|
+
if (idx >= beforeIdx || (protectedFrom > 0 && idx >= protectedFrom))
|
|
118
149
|
return;
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const m = history[i];
|
|
122
|
-
if (!m || m.role !== 'tool')
|
|
150
|
+
const message = history[idx];
|
|
151
|
+
if (!message || message.role !== 'tool')
|
|
123
152
|
return;
|
|
124
|
-
const content = toText(
|
|
153
|
+
const content = toText(message.content);
|
|
125
154
|
if (content.startsWith(STUB_PREFIX))
|
|
126
|
-
return; // 幂等
|
|
127
|
-
if (toolNameOf(history, i) !== 'read_file')
|
|
128
155
|
return;
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (
|
|
156
|
+
const call = this.callAt(history, idx);
|
|
157
|
+
if (call?.name !== 'read_file')
|
|
158
|
+
return;
|
|
159
|
+
if (canonicalizePath(extractPath(call.argsRaw)) !== targetPath)
|
|
160
|
+
return;
|
|
161
|
+
if (!message.tool_call_id)
|
|
133
162
|
return;
|
|
134
|
-
|
|
135
|
-
|
|
163
|
+
message.content =
|
|
164
|
+
`${STUB_PREFIX}${READ_STUB_REASON}] read_file(${targetPath}) ${content.length} 字符 ` +
|
|
165
|
+
`→ 已被新 read / mutation 替代 · id …${message.tool_call_id.slice(-6)}⌫`;
|
|
136
166
|
};
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
for (const i of indexed)
|
|
140
|
-
stubOne(i);
|
|
141
|
-
}
|
|
167
|
+
for (const idx of this.readByPath.get(targetPath) ?? [])
|
|
168
|
+
stubOne(idx);
|
|
142
169
|
const scanEnd = Math.min(beforeIdx, protectedFrom > 0 ? protectedFrom : beforeIdx);
|
|
143
|
-
for (let
|
|
144
|
-
stubOne(
|
|
170
|
+
for (let idx = 1; idx < scanEnd; idx++)
|
|
171
|
+
stubOne(idx);
|
|
172
|
+
}
|
|
173
|
+
stubPriorObservations(history, toolName, key, beforeIdx) {
|
|
174
|
+
const protectedFrom = Math.max(0, lastUserIndex(history));
|
|
175
|
+
const stubOne = (idx) => {
|
|
176
|
+
if (idx >= beforeIdx || (protectedFrom > 0 && idx >= protectedFrom))
|
|
177
|
+
return;
|
|
178
|
+
const message = history[idx];
|
|
179
|
+
if (!message || message.role !== 'tool')
|
|
180
|
+
return;
|
|
181
|
+
const content = toText(message.content);
|
|
182
|
+
if (content.startsWith(STUB_PREFIX) || !isToolResultSuccess(content))
|
|
183
|
+
return;
|
|
184
|
+
const call = this.callAt(history, idx);
|
|
185
|
+
if (!call || call.name !== toolName || observationKey(call) !== key)
|
|
186
|
+
return;
|
|
187
|
+
if (!message.tool_call_id)
|
|
188
|
+
return;
|
|
189
|
+
const reason = toolName === 'grep'
|
|
190
|
+
? '相同 grep 查询已有更新结果'
|
|
191
|
+
: '相同 codegraph 查询已有更新结果';
|
|
192
|
+
message.content =
|
|
193
|
+
`${STUB_PREFIX}${reason}] ${observationLabel(call)} ${content.length} 字符 ` +
|
|
194
|
+
`→ 已被更新查询替代 · id …${message.tool_call_id.slice(-6)}⌫`;
|
|
195
|
+
};
|
|
196
|
+
for (const idx of this.observationByKey.get(key) ?? [])
|
|
197
|
+
stubOne(idx);
|
|
198
|
+
// The fallback scan restores correctness after resume/compact when this instance
|
|
199
|
+
// has no index for older messages.
|
|
200
|
+
const scanEnd = Math.min(beforeIdx, protectedFrom > 0 ? protectedFrom : beforeIdx);
|
|
201
|
+
for (let idx = 1; idx < scanEnd; idx++)
|
|
202
|
+
stubOne(idx);
|
|
145
203
|
}
|
|
146
204
|
}
|
|
147
|
-
/** 默认单例:每个 agent 循环一个。runAgentCore 入口 new 一个,后续 observe 共享。 */
|
|
148
205
|
export function createRelevancePruner() {
|
|
149
206
|
return new RelevancePruner();
|
|
150
207
|
}
|
|
151
|
-
/**
|
|
208
|
+
/** Parse the original content length recorded by any relevance stub. */
|
|
152
209
|
function parseStubOriginalLen(stub) {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
if (!m)
|
|
210
|
+
const match = /\) (\d+) 字符 →/.exec(stub);
|
|
211
|
+
if (!match)
|
|
156
212
|
return null;
|
|
157
|
-
const
|
|
158
|
-
return Number.isFinite(
|
|
213
|
+
const value = Number(match[1]);
|
|
214
|
+
return Number.isFinite(value) && value >= 0 ? value : null;
|
|
159
215
|
}
|
|
160
|
-
/**
|
|
161
|
-
* 扫 history,统计被相关性裁剪 stub 的 read_file tool 消息(条数 + 原字节数)。
|
|
162
|
-
* 供 /context 渲染统计行用(让用户直观看到「prune 帮了多少」)。
|
|
163
|
-
* 永不抛错(对齐本模块契约);history 为空 / 无 stub 时返零值。
|
|
164
|
-
*
|
|
165
|
-
* 注意:stub 后只剩 stub 字符串(原 content 已丢失),故只能从 stub 字符串里 parse
|
|
166
|
-
* 原字节数,误差 = stub 时记录的 content.length(精确);token 估算走 estimateTokens。
|
|
167
|
-
*/
|
|
216
|
+
/** Aggregate relevance/lifecycle compression for the /context panel. */
|
|
168
217
|
export function computePruneStats(history) {
|
|
169
218
|
let stubbed = 0;
|
|
170
219
|
let originalChars = 0;
|
|
171
220
|
let stubChars = 0;
|
|
172
|
-
for (const
|
|
173
|
-
if (
|
|
221
|
+
for (const message of history) {
|
|
222
|
+
if (message.role !== 'tool')
|
|
174
223
|
continue;
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
const isDigest = c.startsWith('⌦[摘要:');
|
|
224
|
+
const content = toText(message.content);
|
|
225
|
+
const isPruneStub = content.startsWith(STUB_PREFIX);
|
|
226
|
+
const isDigest = content.startsWith('⌦[摘要:');
|
|
179
227
|
if (!isPruneStub && !isDigest)
|
|
180
228
|
continue;
|
|
181
229
|
stubbed++;
|
|
182
|
-
stubChars +=
|
|
183
|
-
const
|
|
184
|
-
if (
|
|
185
|
-
originalChars +=
|
|
230
|
+
stubChars += content.length;
|
|
231
|
+
const original = parseStubOriginalLen(content);
|
|
232
|
+
if (original != null)
|
|
233
|
+
originalChars += original;
|
|
186
234
|
}
|
|
187
|
-
// token 估算:用 estimateTokens(懒导入,避免循环依赖 llm)
|
|
188
|
-
// 这里偷懒:走粗略 chars/4(中文混合下会过估,安全侧)
|
|
189
|
-
// 准确应调 estimateTokens,但 /context 已经是粗算,误差可接受
|
|
190
235
|
const originalTokens = Math.ceil(originalChars / 4);
|
|
191
236
|
const stubTokens = Math.ceil(stubChars / 4);
|
|
192
237
|
return {
|