rcs-context 0.0.0-stage → 0.1.40

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.
@@ -0,0 +1,513 @@
1
+ // Copyright (c) 2026 ScreamingMaggot. This source code is licensed under the MIT License.
2
+ // toolfold.mjs — 工具结果折叠:结构化短行 [T] 升级 + 值保真链 [V](A档最小集,纯规则)
3
+ // 规格:docs/seminar/Draft of the tool result collapse rules.md (§1.1/1.2/1.3)
4
+ // 行格式(旧档前缀兼容):
5
+ // [T] tool|action|target|status|P<n>|k=v|k="verbatim"|bare...
6
+ // [V] <seq>|tool|action|target|field|<verbatim 原文(单次转义)>
7
+ // status ∈ OK|FAIL|PARTIAL|CANCEL;P3 字段强制同时写 [V];纯规则、无 LLM、正文不回流。
8
+ // 与 DSH 解耦、可离线单测:输入 (action, args, resultText, isError, {fs})。
9
+ import { createHash } from 'node:crypto'
10
+
11
+ const C = { HEAD: 200, TAIL: 200, TEE_BYTES: 2048, GREP_MATCHES: 10, GLOB_MATCHES: 20,
12
+ READ_HEAD_LINES: 24, READ_HEAD_CHARS: 800, READ_TAIL_LINES: 8, READ_TAIL_CHARS: 280, READ_OUTLINE_CHARS: 320 }
13
+
14
+ // read 预览:确定性抽取,不做摘要。目的不是替代原文,而是让模型在折叠后仍知道
15
+ // “这个文件是什么、开头/结尾长什么样、有哪些结构符号”,避免把 8KB 源码压成一行
16
+ // `chars=8365` 后被迫重读。正文仍可经 [T] 的 raw=<seq> 精确展开。
17
+ const CODE_EXT = /\.(?:py|pyw|js|mjs|cjs|ts|tsx|jsx|java|go|rs|c|h|cc|cpp|hpp|cs|rb|php|sh|ps1|tex|md|ya?ml|toml)$/i
18
+ function clipHead(s, n) {
19
+ const t = String(s ?? '')
20
+ if (t.length <= n) return { text: t, cut: false }
21
+ return { text: t.slice(0, n), cut: true }
22
+ }
23
+ // 大纲不是 AST:按语言做行级词法匹配,但补齐了正则方案最容易错的三件事——
24
+ // 多行签名拼接、`const x = (...) =>` 与普通括号表达式的区分、类/impl 上下文前缀。
25
+ const JS_EXT = new Set(['js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx'])
26
+ const CONTROL_NAMES = new Set(['if', 'for', 'while', 'switch', 'catch', 'with', 'return', 'new', 'await', 'yield', 'super', 'this', 'do', 'else', 'try', 'finally', 'foreach', 'using', 'lock', 'fixed'])
27
+ const OUTLINE_RULES = {
28
+ js: [
29
+ { kind: 'class', ctx: true, re: /^(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/ },
30
+ { kind: 'type', re: /^(?:export\s+)?(?:default\s+)?(?:interface|enum)\s+([A-Za-z_$][\w$]*)/ },
31
+ { kind: 'type', re: /^(?:export\s+)?type\s+([A-Za-z_$][\w$]*)\s*=/ },
32
+ { kind: 'function', re: /^(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)\s*\(/ },
33
+ // 箭头函数必须出现 `=>`;参数表不嵌套括号,避免把 `const x = (() => ...)` 这类 IIFE 当函数。
34
+ { kind: 'function', re: /^(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?(?:function\b|\([^()]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)/ },
35
+ { kind: 'method', method: true, re: /^(?:(?:static|async|get|set|override|public|private|protected|readonly)\s+)*([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*(?::[^{]+)?$/ },
36
+ ],
37
+ py: [
38
+ { kind: 'class', re: /^class\s+([A-Za-z_]\w*)/ },
39
+ { kind: 'def', re: /^(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(/ },
40
+ ],
41
+ go: [
42
+ { kind: 'type', re: /^type\s+([A-Za-z_]\w*)\s+(?:struct|interface)\b/ },
43
+ { kind: 'function', re: /^func\s+(?:\([^)]*\)\s*)?([A-Za-z_]\w*)\s*\(/ },
44
+ ],
45
+ rs: [
46
+ { kind: 'class', ctx: true, re: /^(?:pub(?:\([^)]*\))?\s+)?(?:struct|enum|trait)\s+([A-Za-z_]\w*)/ },
47
+ { kind: 'class', ctx: true, re: /^(?:unsafe\s+)?impl(?:<[^>]*>)?\s+(?:[\w:<>]+\s+for\s+)?([A-Za-z_]\w*)/ },
48
+ { kind: 'mod', ctx: true, re: /^(?:pub(?:\([^)]*\))?\s+)?mod\s+([A-Za-z_]\w*)/ },
49
+ { kind: 'function', re: /^(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?(?:unsafe\s+)?(?:const\s+)?fn\s+([A-Za-z_]\w*)\s*[<(]/ },
50
+ ],
51
+ jvm: [
52
+ { kind: 'class', ctx: true, re: /^(?:(?:public|protected|private|static|final|abstract|sealed|non-sealed|strictfp|partial)\s+)*(?:class|interface|enum|record)\s+([A-Za-z_$][\w$]*)/ },
53
+ { kind: 'method', method: true, re: /^(?:(?:public|protected|private|static|final|abstract|synchronized|native|default|strictfp|virtual|override|async|sealed|internal|extern|unsafe)\s+)+(?:[\w$<>\[\],.?\s]+\s+)?([A-Za-z_$][\w$]*)\s*\([^;]*\)\s*(?:throws\s+[^{]+)?$/ },
54
+ ],
55
+ c: [
56
+ { kind: 'class', ctx: true, re: /^(?:typedef\s+)?(?:struct|union|enum)\s+([A-Za-z_]\w*)\s*$/ },
57
+ { kind: 'function', re: /^(?!\s*(?:if|for|while|switch|catch|return|sizeof|else|do|case|goto)\b)(?:[A-Za-z_][\w \t*&:<>,]*?)\b([A-Za-z_]\w*)\s*\([^;]*\)$/ },
58
+ ],
59
+ rb: [
60
+ { kind: 'class', re: /^(?:class|module)\s+([A-Za-z_]\w*)/ },
61
+ { kind: 'function', re: /^def\s+(?:self\.)?([A-Za-z_]\w*[!?=]?)/ },
62
+ ],
63
+ php: [
64
+ { kind: 'class', ctx: true, re: /^(?:(?:abstract|final)\s+)?(?:class|interface|trait|enum)\s+([A-Za-z_]\w*)/ },
65
+ { kind: 'method', method: true, re: /^(?:(?:public|private|protected|static|abstract|final)\s+)*function\s+([A-Za-z_]\w*)\s*\([^)]*\)\s*(?::\s*[^{]+)?$/ },
66
+ { kind: 'function', re: /^function\s+([A-Za-z_]\w*)\s*\(/ },
67
+ ],
68
+ sh: [
69
+ { kind: 'function', re: /^(?:function\s+)?([A-Za-z_]\w*)\s*\(\s*\)\s*$/ },
70
+ { kind: 'function', re: /^function\s+([A-Za-z_]\w*)\s*$/ },
71
+ ],
72
+ ps1: [
73
+ { kind: 'function', re: /^function\s+([A-Za-z_][\w-]*)/ },
74
+ ],
75
+ tex: [
76
+ { kind: 'section', re: /^\\(?:part|chapter|section|subsection|subsubsection)\*?\{([^}]*)\}/ },
77
+ ],
78
+ md: [
79
+ { kind: 'heading', nameGroup: 2, re: /^(#{1,3})\s+(.+)$/ },
80
+ ],
81
+ }
82
+ function rulesFor(ext) {
83
+ if (JS_EXT.has(ext)) return OUTLINE_RULES.js
84
+ if (ext === 'py' || ext === 'pyw') return OUTLINE_RULES.py
85
+ if (ext === 'go') return OUTLINE_RULES.go
86
+ if (ext === 'rs') return OUTLINE_RULES.rs
87
+ if (ext === 'java' || ext === 'cs') return OUTLINE_RULES.jvm
88
+ if (ext === 'c' || ext === 'h' || ext === 'cc' || ext === 'cpp' || ext === 'hpp') return OUTLINE_RULES.c
89
+ if (ext === 'rb') return OUTLINE_RULES.rb
90
+ if (ext === 'php') return OUTLINE_RULES.php
91
+ if (ext === 'sh') return OUTLINE_RULES.sh
92
+ if (ext === 'ps1') return OUTLINE_RULES.ps1
93
+ if (ext === 'tex') return OUTLINE_RULES.tex
94
+ if (ext === 'md') return OUTLINE_RULES.md
95
+ return []
96
+ }
97
+ // 宿主 read 结果带 `N: ` 行号前缀(以及 <content> 包装),结构行必须先剥掉它。
98
+ function outlineLine(raw) {
99
+ return String(raw ?? '').replace(/^\s*\d+:\s?/, '').trim()
100
+ }
101
+ function outlineIndent(raw) {
102
+ const s = String(raw ?? '').replace(/^\s*\d+:\s?/, '')
103
+ return /^[ \t]*/.exec(s)[0].replace(/\t/g, ' ').length
104
+ }
105
+ function tidySignature(s) {
106
+ return String(s ?? '')
107
+ .replace(/\s+/g, ' ')
108
+ .replace(/\(\s+/g, '(')
109
+ .replace(/\s+\)/g, ')')
110
+ .replace(/\s*,\s*/g, ', ')
111
+ .replace(/,\s*\)/g, ')')
112
+ .replace(/\s*[:{]\s*$/, '')
113
+ .trim()
114
+ }
115
+ function needsMoreSignature(s) {
116
+ const t = String(s).replace(/\s*\{\s*$/, '')
117
+ let p = 0, b = 0, c = 0
118
+ for (const ch of t) {
119
+ if (ch === '(') p++
120
+ else if (ch === ')') p--
121
+ else if (ch === '[') b++
122
+ else if (ch === ']') b--
123
+ else if (ch === '{') c++
124
+ else if (ch === '}') c--
125
+ }
126
+ return p > 0 || b > 0 || c > 0 || /\\$/.test(t)
127
+ }
128
+ function signatureAt(lines, i) {
129
+ let text = outlineLine(lines[i])
130
+ for (let j = i + 1; j < Math.min(lines.length, i + 6) && needsMoreSignature(text); j++) {
131
+ const next = outlineLine(lines[j])
132
+ if (!next) break
133
+ text += ' ' + next
134
+ }
135
+ return tidySignature(text)
136
+ }
137
+ // 去掉函数/类的块体,但保留参数里的默认值对象(`opts = {}` 的花括号在括号内,不能切)。
138
+ function cutBody(s) {
139
+ let p = 0, b = 0, c = 0, last = -1
140
+ for (let i = 0; i < s.length; i++) {
141
+ const ch = s[i]
142
+ if (ch === '(') p++
143
+ else if (ch === ')') p--
144
+ else if (ch === '[') b++
145
+ else if (ch === ']') b--
146
+ else if (ch === '{') {
147
+ if (p === 0 && b === 0 && c === 0) last = i
148
+ c++
149
+ } else if (ch === '}') c--
150
+ }
151
+ return last > 0 ? s.slice(0, last).trimEnd() : s
152
+ }
153
+ function braceDelta(line) {
154
+ const s = String(line).replace(/`(?:\\.|[^`\\])*`|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, '""').replace(/\/\/.*$/, '')
155
+ let d = 0
156
+ for (const ch of s) {
157
+ if (ch === '{') d++
158
+ else if (ch === '}') d--
159
+ }
160
+ return d
161
+ }
162
+ function matchRule(line, rules, { inClass, indent }) {
163
+ for (const rule of rules) {
164
+ if (rule.method && (!inClass || indent <= 0)) continue
165
+ const m = rule.re.exec(line)
166
+ if (!m) continue
167
+ const name = m[rule.nameGroup ?? 1] ?? ''
168
+ if (rule.method && CONTROL_NAMES.has(name)) continue
169
+ return { kind: rule.kind, name, rule }
170
+ }
171
+ return null
172
+ }
173
+ export function codeOutline(lines, ext = '', limit = 8) {
174
+ const rules = rulesFor(ext)
175
+ if (!rules.length) return []
176
+ const found = []
177
+ const add = (decl, sig, ctx, isMethod, groupOrder = null, cat = 0) => {
178
+ let clean = tidySignature(sig)
179
+ if (!clean) return
180
+ // 单行函数体不能进大纲:箭头函数截到 `=>`,其余截到配平后的块体 `{`。
181
+ const arrow = /^(?:export\s+)?(?:const|let|var)\b/.test(clean) ? clean.indexOf('=>') : -1
182
+ if (arrow >= 0) clean = clean.slice(0, arrow + 2)
183
+ else if (decl.kind === 'type') clean = clean.replace(/\s*=.*$/, '')
184
+ else if (decl.kind !== 'section' && decl.kind !== 'heading') clean = cutBody(clean)
185
+ const at = decl.name ? clean.indexOf(decl.name) : -1
186
+ const tail = at >= 0 ? clean.slice(at) : clean
187
+ const label = clipHead(isMethod ? (ctx ? ctx + '.' : '') + tail : clean, 100).text
188
+ const order = found.length
189
+ found.push({ label, cat, group: groupOrder ?? order, order })
190
+ }
191
+ if (ext === 'py' || ext === 'pyw') {
192
+ const stack = []
193
+ for (let i = 0; i < lines.length; i++) {
194
+ const line = outlineLine(lines[i])
195
+ // 注释/包装行不是语句,不能让它们参与缩进 dedent,否则类下面的方法会丢上下文。
196
+ if (!line || line.startsWith('#') || line.startsWith('<')) continue
197
+ const indent = outlineIndent(lines[i])
198
+ while (stack.length && indent <= stack[stack.length - 1].indent) stack.pop()
199
+ const decl = matchRule(cutBody(tidySignature(line)), rules, { inClass: stack.some((x) => x.kind === 'class'), indent })
200
+ if (!decl) continue
201
+ const classStack = stack.filter((x) => x.kind === 'class')
202
+ const classes = classStack.map((x) => x.name)
203
+ const isMethod = decl.kind === 'def' && classes.length > 0
204
+ const owner = isMethod ? classStack[classStack.length - 1] : null
205
+ const cat = isMethod ? Math.max(1, owner?.cat ?? 0) : stack.length === 0 ? 0 : 2
206
+ const order = found.length
207
+ add(decl, signatureAt(lines, i), classes.join('.'), isMethod, owner?.order ?? null, cat)
208
+ stack.push({ name: decl.name, indent, kind: decl.kind === 'class' ? 'class' : 'def', order, cat })
209
+ }
210
+ } else {
211
+ const stack = []
212
+ let depth = 0
213
+ let pending = null
214
+ for (let i = 0; i < lines.length; i++) {
215
+ const line = outlineLine(lines[i])
216
+ if (!line) continue
217
+ while (stack.length && depth < stack[stack.length - 1].depth) stack.pop()
218
+ const inClass = stack.length > 0 && depth === stack[stack.length - 1].depth
219
+ const decl = matchRule(cutBody(tidySignature(line)), rules, { inClass, indent: outlineIndent(lines[i]) })
220
+ if (decl) {
221
+ const isMethod = !!decl.rule.method || (inClass && decl.kind === 'function')
222
+ const ctx = stack.map((x) => x.name).join('.')
223
+ const order = found.length
224
+ const owner = stack.length ? stack[stack.length - 1] : null
225
+ const cat = isMethod ? Math.max(1, owner?.cat ?? 0) : depth <= 0 ? 0 : 2
226
+ add(decl, signatureAt(lines, i), ctx, isMethod, isMethod && owner ? owner.order : null, cat)
227
+ if (decl.rule.ctx) {
228
+ if (line.includes('{')) stack.push({ name: decl.name || tidySignature(line), order, cat, depth: depth + 1 })
229
+ else pending = { name: decl.name || tidySignature(line), order, cat }
230
+ }
231
+ } else if (pending && line.includes('{')) {
232
+ stack.push({ name: pending.name, order: pending.order, cat: pending.cat, depth: depth + 1 })
233
+ pending = null
234
+ }
235
+ depth += braceDelta(line)
236
+ }
237
+ }
238
+ // 顶层 → 成员 → 深层嵌套;同类按文件顺序,方法跟随其所属类,类永远排在自己的方法前。
239
+ found.sort((a, b) => a.cat - b.cat || a.group - b.group || a.order - b.order)
240
+ const out = []
241
+ for (const item of found) {
242
+ if (out.includes(item.label)) continue
243
+ out.push(item.label)
244
+ if (out.length >= limit) break
245
+ }
246
+ return out
247
+ }
248
+ export function readPreview(text, path) {
249
+ const s = String(text ?? '').replace(/\r\n?/g, '\n')
250
+ if (!s) return { head: '', tail: '', outline: '', truncated: false }
251
+ const lines = s.split('\n')
252
+ const headRaw = lines.slice(0, C.READ_HEAD_LINES).join('\n')
253
+ const head = clipHead(headRaw, C.READ_HEAD_CHARS)
254
+ const coveredByHead = headRaw.length
255
+ const wantTail = lines.length > C.READ_HEAD_LINES + C.READ_TAIL_LINES || head.cut
256
+ const tailRaw = wantTail && s.length > coveredByHead ? lines.slice(-C.READ_TAIL_LINES).join('\n') : ''
257
+ const tail = tailRaw.length > C.READ_TAIL_CHARS
258
+ ? { text: tailRaw.slice(-C.READ_TAIL_CHARS), cut: true }
259
+ : { text: tailRaw, cut: false }
260
+ let outline = ''
261
+ const ext = (/\.([a-z0-9]+)$/i.exec(String(path ?? ''))?.[1] ?? '').toLowerCase()
262
+ if (CODE_EXT.test(String(path ?? '')) && lines.length > C.READ_HEAD_LINES) {
263
+ const items = codeOutline(lines, ext).filter((x) => !head.text.includes(x))
264
+ const picked = []
265
+ let used = 0
266
+ for (const item of items) {
267
+ const need = (picked.length ? 2 : 0) + item.length
268
+ if (used + need > C.READ_OUTLINE_CHARS) break
269
+ picked.push(item)
270
+ used += need
271
+ }
272
+ if (picked.length >= 1) outline = picked.join('; ')
273
+ }
274
+ return { head: head.text, tail: tail.text, outline, truncated: head.cut || tail.cut || lines.length > C.READ_HEAD_LINES }
275
+ }
276
+
277
+
278
+ // ---- 转义(只做一次;值内含裸 | \ " 换行时整体引号包裹并转义)----
279
+ function escVal(raw, forceQuote = false) {
280
+ let s = String(raw == null ? '' : raw)
281
+ // ⚠ 必须连 \r 一起转义:本模块的输出是**行式**格式(一行一条 [T]/[V]),而 Windows 工具
282
+ // 正文普遍是 \r\n。只转 \n 会留下裸 CR,写进只增日志后 CR 自身被当换行 ⇒ 一行撑成多行,
283
+ // 面板 parseTranscript 与任何按行解析的下游都会读到垃圾续行(2026-09-29 重建事故日志时暴露)。
284
+ const special = /[|\\"\n\r]/.test(s)
285
+ if (!forceQuote && !special) return s
286
+ return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\|/g, '\\|').replace(/\r/g, '\\r').replace(/\n/g, '\\n') + '"'
287
+ }
288
+ // 字段段:k=v(v 只在组装时转义一次)
289
+ const pair = (k, v, quote = false) => ({ k, v, quote })
290
+
291
+ // 【#2 修复(取自 RCS-0.1.0 返回包)】状态词表归一:结构化档用 OK|FAIL|PARTIAL|CANCEL,
292
+ // 注入器非结构化档用 OK|ERR(见 contextinjector `[T] op | path | ${isErr?'ERR':'OK'}`)。
293
+ // 不归一会导致"同一状态两套词"、下游匹配不上(反馈:"[T] 行 ok 解析不匹配")。
294
+ function normalizeStatus(s) {
295
+ const v = String(s ?? '').trim().toUpperCase()
296
+ if (v === 'ERR' || v === 'ERROR') return 'FAIL'
297
+ if (v === 'OK' || v === 'FAIL' || v === 'PARTIAL' || v === 'CANCEL') return v
298
+ return v || 'OK'
299
+ }
300
+
301
+ // 【#2 修复(取自 RCS-0.1.0 返回包)】兼容两种 [T] 行:
302
+ // ① 结构化(本模块产出):[T] tool|action|target|status|P<n>|k=v|… (≥5 字段)
303
+ // ② 非结构化(contextinjector 产出):[T] op | path | OK/ERR (3 字段)
304
+ // 旧实现要求 ≥5 字段 → 注入器的 [T] 行恒返回 null("ok 解析不匹配"根因)。
305
+ export function parseRow(line) {
306
+ const m = /^\[T\] (.*)$/.exec(String(line).trim())
307
+ if (!m) return null
308
+ const toks = tokenize(m[1])
309
+ if (toks.length >= 5) {
310
+ const [tool, action, target, status, grade] = toks.slice(0, 5)
311
+ const fields = {}
312
+ for (const t of toks.slice(5)) {
313
+ const eq = t.indexOf('=')
314
+ if (eq > 0) fields[t.slice(0, eq)] = t.slice(eq + 1)
315
+ else fields[t] = t
316
+ }
317
+ return { tool, action, target, status: normalizeStatus(status), grade, fields, format: 'structured' }
318
+ }
319
+ if (toks.length >= 3) {
320
+ // 非结构化:[T] op | path | OK/ERR(注入器用 " | " 分隔 → token 需 trim);
321
+ // 新转录还会追加 raw=/call= 指针,解析器容忍并收进 fields。
322
+ const [op, target, status] = toks.slice(0, 3).map((t) => t.trim())
323
+ const fields = {}
324
+ for (const rawTok of toks.slice(3)) {
325
+ const t = rawTok.trim()
326
+ const eq = t.indexOf('=')
327
+ if (eq > 0) fields[t.slice(0, eq)] = t.slice(eq + 1)
328
+ }
329
+ return { tool: op, action: op, target, status: normalizeStatus(status), grade: '', fields, format: 'plain' }
330
+ }
331
+ return null
332
+ }
333
+ function tokenize(str) {
334
+ const out = []; let cur = '', q = false, i = 0
335
+ while (i < str.length) {
336
+ const ch = str[i]
337
+ if (q) {
338
+ if (ch === '\\' && i + 1 < str.length) { cur += str[i + 1]; i += 2; continue }
339
+ if (ch === '"') { q = false; i++; continue }
340
+ cur += ch; i++; continue
341
+ }
342
+ if (ch === '"') { q = true; i++; continue }
343
+ if (ch === '|') { if (cur !== '') out.push(cur); cur = ''; i++; continue }
344
+ cur += ch; i++
345
+ }
346
+ if (cur !== '') out.push(cur)
347
+ return out
348
+ }
349
+
350
+ // ---- 通用 ---------------------------------------------------------------
351
+ export function statusOf(isError, extra = {}) {
352
+ if (isError) return 'FAIL'
353
+ if (extra.cancelled) return 'CANCEL'
354
+ if (extra.partial) return 'PARTIAL'
355
+ return 'OK'
356
+ }
357
+ function firstLine(text) { return String(text ?? '').split('\n')[0] ?? '' }
358
+ function headTail(text, h = C.HEAD, t = C.TAIL) {
359
+ const s = String(text == null ? '' : text)
360
+ return { head: s.slice(0, h), tail: s.slice(-t), len: s.length }
361
+ }
362
+ function targetOf(action, args = {}) {
363
+ for (const k of ['file_path', 'path', 'directory', 'pattern', 'query', 'url', 'job_id', 'goal_id', 'scope', 'cd', 'name']) {
364
+ if (typeof args[k] === 'string' && args[k]) return args[k]
365
+ }
366
+ if (action === 'ask_user_question' && typeof args.question === 'string') return args.question
367
+ return '-'
368
+ }
369
+ function fsMeta(fs, path) {
370
+ try {
371
+ if (!fs || !path || path === '-') return null
372
+ const buf = fs.readFileSync(path)
373
+ return {
374
+ size: buf.length,
375
+ sha8: createHash('sha256').update(buf).digest('hex').slice(0, 8),
376
+ lines: buf.toString('utf8').split('\n').length,
377
+ }
378
+ } catch { return null }
379
+ }
380
+
381
+ // ---- action 规则:返回 { status, grade, pairs:[{k,v,quote}], v:[{f,v}] } ----
382
+ const errPair = (pairs, v) => { pairs.push(pair('err', v, true)); return { f: 'err', v } }
383
+ function readRule({ args, text, isError, fs }) {
384
+ const pairs = []; const v = []
385
+ if (isError) { v.push(errPair(pairs, firstLine(text))); return { status: 'FAIL', grade: 'P1', pairs, v } }
386
+ const path = targetOf('read', args)
387
+ const meta = fsMeta(fs, path)
388
+ if (meta) pairs.push(pair('size', meta.size), pair('lines', meta.lines), pair('sha8', meta.sha8))
389
+ else pairs.push(pair('chars', String(text ?? '').length))
390
+ // 有界、确定性的正文预览:head/tail + 代码结构大纲;完整原文仍由 raw 指针承载。
391
+ const pv = readPreview(text, path)
392
+ if (pv.head) pairs.push(pair('head', pv.head, true))
393
+ if (pv.outline) pairs.push(pair('outline', pv.outline, true))
394
+ if (pv.tail) pairs.push(pair('tail', pv.tail, true))
395
+ if (pv.truncated) pairs.push(pair('trunc', 'mid'))
396
+ return { status: 'OK', grade: 'P1', pairs, v }
397
+ }
398
+ function writeRule({ args, text, isError, fs }) {
399
+ const pairs = []; const v = []
400
+ if (isError) { v.push(errPair(pairs, firstLine(text))); return { status: 'FAIL', grade: 'P1', pairs, v } }
401
+ const path = targetOf('write', args)
402
+ const meta = fsMeta(fs, path)
403
+ if (meta) pairs.push(pair('size', meta.size), pair('sha8', meta.sha8))
404
+ else pairs.push(pair('ok', 1))
405
+ // 精确 patch 统计:不摘写正文,只给行数增减;完整参数仍由 call=<seq> 承载。
406
+ const oc = args.old_string ?? args.old_str ?? args.oldText ?? null
407
+ const nc = args.new_string ?? args.new_str ?? args.newText ?? args.content ?? null
408
+ if (typeof oc === 'string' && oc !== '') pairs.push(pair('del', oc.split('\n').length))
409
+ if (typeof nc === 'string' && nc !== '') pairs.push(pair('ins', nc.split('\n').length))
410
+ return { status: 'OK', grade: 'P1', pairs, v }
411
+ }
412
+ function searchRule({ action, args, text, isError }) {
413
+ const pairs = []; const v = []
414
+ if (isError) { v.push(errPair(pairs, firstLine(text))); return { status: 'FAIL', grade: 'P2', pairs, v } }
415
+ const pat = args.pattern || args.query || args.scope || '-'
416
+ const lines = String(text ?? '').split('\n').filter((l) => l.trim())
417
+ const K = action === 'grep' ? C.GREP_MATCHES : C.GLOB_MATCHES
418
+ pairs.push(pair('pattern', pat), pair('count', lines.length))
419
+ if (lines.length) pairs.push(pair('matches', lines.slice(0, K).join('\\n')))
420
+ if (lines.length > K) pairs.push(pair('trunc', 'matches'))
421
+ return { status: 'OK', grade: 'P2', pairs, v }
422
+ }
423
+ function shellRule({ args, text, isError }) {
424
+ const pairs = []; const v = []
425
+ const { head, tail, len } = headTail(text)
426
+ pairs.push(pair('cmd', (args.cmd || args.command || args.arguments || '').slice(0, 80)),
427
+ pair('exit', isError ? '!=0' : '0'), pair('out_n', len))
428
+ if (len > 0) pairs.push(pair('head', head), pair('tail', tail))
429
+ if (len > C.TEE_BYTES) pairs.push(pair('tee', 'REQUIRED'))
430
+ if (isError) v.push(errPair(pairs, firstLine(text)))
431
+ return { status: isError ? 'FAIL' : 'OK', grade: 'P2', pairs, v }
432
+ }
433
+ // 宿主 working_directory:无参调用会把 cwd 设为工作区根,返回当前路径。
434
+ // 显式记录 cd 与 cwd,避免转录只看到"路径变了"却看不出是谁改的。
435
+ function workingDirRule({ args, text, isError }) {
436
+ const pairs = []; const v = []
437
+ const cd = typeof args?.cd === 'string' ? args.cd : (typeof args?.path === 'string' ? args.path : '')
438
+ if (cd) pairs.push(pair('cd', cd))
439
+ const cwd = firstLine(text).slice(0, 240)
440
+ if (isError) v.push(errPair(pairs, cwd))
441
+ else if (cwd) pairs.push(pair('cwd', cwd, true))
442
+ return { status: isError ? 'FAIL' : 'OK', grade: 'P1', pairs, v }
443
+ }
444
+ function askRule({ args, text, isError }) {
445
+ const pairs = []; const v = []
446
+ const val = String(text ?? '')
447
+ v.push({ f: 'val', v: val })
448
+ pairs.push(pair('q', args.question || '-', true))
449
+ if (isError) v.push(errPair(pairs, firstLine(text)))
450
+ return { status: isError ? 'FAIL' : 'OK', grade: 'P3', pairs, v }
451
+ }
452
+ function jobRule({ args, text, isError }) {
453
+ const pairs = []; const v = []
454
+ const id = args.job_id || firstLine(text).slice(0, 40) || '-'
455
+ const { tail, len } = headTail(text)
456
+ v.push({ f: 'job_id', v: id })
457
+ pairs.push(pair('job_id', id, true), pair('state', isError ? 'fail' : 'done'))
458
+ if (len) pairs.push(pair('out_n', len), pair('tail', tail))
459
+ if (isError) v.push(errPair(pairs, firstLine(text)))
460
+ return { status: isError ? 'FAIL' : 'OK', grade: 'P1', pairs, v }
461
+ }
462
+ function stateRule({ text, isError }) {
463
+ const pairs = []; const v = []
464
+ if (isError) v.push(errPair(pairs, firstLine(text)))
465
+ return { status: isError ? 'FAIL' : 'OK', grade: 'P0', pairs, v }
466
+ }
467
+ function imageRule({ isError }) {
468
+ const pairs = []; const v = []
469
+ if (isError) { v.push(errPair(pairs, 'decode-error')); return { status: 'FAIL', grade: 'P1', pairs, v } }
470
+ return { status: 'OK', grade: 'P1', pairs, v }
471
+ }
472
+ const RULES = {
473
+ read: readRule, write: writeRule, edit: writeRule, str_replace: writeRule,
474
+ glob: (x) => searchRule(Object.assign({}, x, { action: 'glob' })),
475
+ grep: (x) => searchRule(Object.assign({}, x, { action: 'grep' })),
476
+ pwsh: shellRule, bash: shellRule,
477
+ working_directory: workingDirRule,
478
+ ask_user_question: askRule, job_output: jobRule,
479
+ create_goal: stateRule, get_goal: stateRule, update_goal: stateRule,
480
+ job_list: stateRule, job_kill: stateRule, todo_write: stateRule, read_image: imageRule,
481
+ }
482
+ function fallbackRule({ text, isError }) {
483
+ const pairs = []; const v = []
484
+ // 未命中专用规则的工具(web_fetch/web_search/skill/present…)不能再只留 status:
485
+ // 保留有界头尾与总字节数,全文靠 [T] 的 raw 指针回取,避免"静默丢正文"。
486
+ const { head, tail, len } = headTail(text)
487
+ pairs.push(pair('out_n', len))
488
+ if (len > 0) pairs.push(pair('head', head))
489
+ if (len > C.HEAD) pairs.push(pair('tail', tail), pair('trunc', 'mid'))
490
+ if (isError) v.push(errPair(pairs, firstLine(text)))
491
+ return { status: isError ? 'FAIL' : 'OK', grade: 'P1', pairs, v }
492
+ }
493
+
494
+ let VSEQ = 0
495
+ // 组装:为一条工具结果生成 [T] 行 + 需要的 [V] 行
496
+ export function foldOne({ action, args = {}, text = '', isError = false, fs = null, tool = 'tool' }) {
497
+ const rule = RULES[action] || fallbackRule
498
+ const r = rule({ action, args, text, isError, fs })
499
+ const target = targetOf(action, args)
500
+ // 先写 [V](拿真实 seq),再在 [T] 里引用首条
501
+ let firstSeq = 0
502
+ const vs = []
503
+ for (const it of r.v || []) {
504
+ const seq = ++VSEQ
505
+ if (!firstSeq) firstSeq = seq
506
+ vs.push(`[V] ${seq}|${tool}|${action}|${target}|${it.f}|${escVal(it.v, true)}`)
507
+ }
508
+ const atom = [tool, action, target, r.status, r.grade].map((s) => escVal(s, false))
509
+ const out = r.pairs.map((p) => p.k + '=' + escVal(p.v, p.quote))
510
+ if (firstSeq) out.push('ref=V' + firstSeq)
511
+ const t = '[T] ' + atom.concat(out).join('|')
512
+ return { t, vs }
513
+ }
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ // agg-cost.mjs — 用真实调用记录标定「一次浓缩调用的计费成本」,为经济性准入闸提供常数。
3
+ // 数据源:tools/.probe/p-*.json(每条含 usage: inputTokens/cacheReadTokens/outputTokens)
4
+ // 模型:cost_billed(S) = a·S + b (S = 原文 token 数;b = 被缓存的系统提示词开销)
5
+ // benefit_per_turn(S) = (1-r)·S·D
6
+ // break-even 剩余回合 m*(S) = (a·S + b) / ((1-r)·S·D)
7
+ import { readdirSync, readFileSync } from 'node:fs'
8
+ import { join } from 'node:path'
9
+
10
+ const P = join(import.meta.dirname, '.probe')
11
+ const D = Number(process.env.BENCH_D ?? 0.25)
12
+ const rows = []
13
+ for (const f of readdirSync(P)) {
14
+ if (!f.startsWith('p-') || !f.endsWith('.json')) continue
15
+ let j; try { j = JSON.parse(readFileSync(join(P, f), 'utf8')) } catch { continue }
16
+ for (const r of j.rows ?? []) {
17
+ if (!r.ok || !r.usage) continue
18
+ rows.push({
19
+ chars: r.chars ?? null,
20
+ inTok: r.usage.inputTokens ?? 0,
21
+ cacheTok: r.usage.cacheReadTokens ?? 0,
22
+ outTok: r.usage.outputTokens ?? 0,
23
+ ratio: typeof r.ratio === 'number' ? r.ratio : null,
24
+ variant: r.variant,
25
+ maxTokens: r.maxTokens,
26
+ effort: r.effort,
27
+ })
28
+ }
29
+ }
30
+ if (!rows.length) { console.error('no probe rows with usage'); process.exit(1) }
31
+
32
+ const mean = (a) => a.reduce((x, y) => x + y, 0) / a.length
33
+ const med = (a) => { const s = [...a].sort((x, y) => x - y); const m = s.length >> 1; return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2 }
34
+
35
+ console.log(`=== 真实调用成本(n=${rows.length} 条带 usage 的探针记录,D=${D})===`)
36
+ console.log('inTok(未缓存)中位'.padStart(18) + 'cacheTok中位'.padStart(13) + 'outTok中位'.padStart(11) + 'cache占比中位'.padStart(14) + 'ratio中位'.padStart(10) + 'costBilled中位'.padStart(15))
37
+ const inMed = med(rows.map((r) => r.inTok))
38
+ const cacheMed = med(rows.map((r) => r.cacheTok))
39
+ const outMed = med(rows.map((r) => r.outTok))
40
+ const ratioMed = med(rows.map((r) => r.ratio).filter((x) => x != null))
41
+ const costMed = med(rows.map((r) => r.inTok + r.cacheTok * D))
42
+ console.log(String(Math.round(inMed)).padStart(18) + String(Math.round(cacheMed)).padStart(13) + String(Math.round(outMed)).padStart(11) +
43
+ (cacheMed / (inMed + cacheMed)).toFixed(3).padStart(14) + ratioMed.toFixed(3).padStart(10) + String(Math.round(costMed)).padStart(15))
44
+
45
+ // 按原文规模分桶,标定 a(与 S 成正比的部分)与 b(常数:被缓存的系统提示词)
46
+ console.log(`\n=== 按原文规模分桶(chars → token 近似 /4)===`)
47
+ console.log('chars'.padStart(7) + 'n'.padStart(4) + 'inTok'.padStart(8) + 'cacheTok'.padStart(10) + 'outTok'.padStart(8) + 'costBilled'.padStart(12) + ' 收益/回合'.padStart(11) + ' m*'.padStart(7))
48
+ const buckets = [...new Set(rows.map((r) => r.chars).filter((x) => x != null))].sort((a, b) => a - b)
49
+ const fit = []
50
+ for (const c of buckets) {
51
+ const g = rows.filter((r) => r.chars === c)
52
+ const sTok = c / 4
53
+ const i = med(g.map((r) => r.inTok)), cc = med(g.map((r) => r.cacheTok)), o = med(g.map((r) => r.outTok))
54
+ const cost = i + cc * D
55
+ const rr = med(g.map((r) => r.ratio).filter((x) => x != null))
56
+ const ben = (sTok - o) * D
57
+ console.log(String(c).padStart(7) + String(g.length).padStart(4) + String(Math.round(i)).padStart(8) + String(Math.round(cc)).padStart(10) + String(Math.round(o)).padStart(8) + String(Math.round(cost)).padStart(12) + ben.toFixed(1).padStart(11) + (ben > 0 ? (cost / ben).toFixed(1) : 'n/a').padStart(7))
58
+ if (ben > 0) fit.push({ sTok, cost, i, cc })
59
+ }
60
+
61
+ // 最小二乘拟合 cost = a·S + b
62
+ if (fit.length >= 2) {
63
+ const n = fit.length
64
+ const sx = fit.reduce((a, r) => a + r.sTok, 0), sy = fit.reduce((a, r) => a + r.cost, 0)
65
+ const sxx = fit.reduce((a, r) => a + r.sTok * r.sTok, 0), sxy = fit.reduce((a, r) => a + r.sTok * r.cost, 0)
66
+ const a = (n * sxy - sx * sy) / (n * sxx - sx * sx), b = (sy - a * sx) / n
67
+ console.log(`\n=== 拟合:cost_billed ≈ ${a.toFixed(3)}·S + ${b.toFixed(1)} (S = 原文 token)===`)
68
+ const rr = ratioMed
69
+ const D2 = D
70
+ const benefit = (S) => (1 - rr) * S * D2
71
+ console.log(`渐进临界回合 = 1/((1-r)·D) = ${(1 / ((1 - rr) * D2)).toFixed(2)} (r=${rr.toFixed(3)})`)
72
+ console.log(`\n不同「剩余回合 m」下的最小可折原文规模(字节,chars = 4·S):`)
73
+ for (const m of [4, 6, 8, 10, 12, 16, 20]) {
74
+ const S = b / (benefit(1) * m - a)
75
+ if (!(S > 0) || !Number.isFinite(S)) { console.log(` m=${String(m).padStart(2)} → 无解(该 m 下永远不划算)`); continue }
76
+ console.log(` m=${String(m).padStart(2)} → S ≥ ${Math.round(S).toString().padStart(6)} tok ≈ ${Math.round(S * 4).toString().padStart(6)} 字节`)
77
+ }
78
+ }