thincoder 0.12.54 → 0.12.59
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 +98 -0
- package/README.md +1 -1
- package/bin/thincoder.mjs +25 -3
- package/package.json +3 -7
- package/src/acp/bridge.mjs +132 -26
- package/src/advisor/messages.mjs +38 -3
- package/src/advisor/run.mjs +91 -53
- package/src/advisor.mjs +15 -7
- package/src/agent/dispatch.mjs +156 -39
- package/src/agent/helpers.mjs +46 -4
- package/src/agent/setup.mjs +102 -19
- package/src/agent/spawn-child.mjs +28 -1
- package/src/agent-tools/advisor.mjs +43 -11
- package/src/agent-tools/consult.mjs +37 -6
- package/src/agent-tools/eng.mjs +4 -1
- package/src/agent-tools/goal.mjs +11 -1
- package/src/agent-tools/read-history.mjs +160 -0
- package/src/agent-tools/settings.mjs +162 -0
- package/src/agent-tools/skill.mjs +2 -1
- package/src/agent-tools/subagent-actions.mjs +432 -0
- package/src/agent-tools/subagent-async.mjs +427 -0
- package/src/agent-tools/subagent-scheduler.mjs +319 -0
- package/src/agent-tools/subagent.mjs +565 -128
- package/src/agent-tools/task.mjs +4 -3
- package/src/agent-tools/timer.mjs +9 -4
- package/src/agent-tools/verify.mjs +161 -49
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +182 -81
- package/src/auto-think.mjs +14 -0
- package/src/cli/make-agent.mjs +27 -1
- package/src/cli/memory-command.mjs +28 -7
- package/src/cli/permission.mjs +8 -1
- package/src/config.mjs +125 -8
- package/src/context.mjs +115 -34
- package/src/distill.mjs +19 -1
- package/src/escape.mjs +82 -27
- package/src/log.mjs +195 -0
- package/src/mcp/transport-http.mjs +13 -1
- package/src/mcp.mjs +52 -7
- package/src/memory/code-sync.mjs +1 -1
- package/src/memory/core.mjs +204 -10
- package/src/memory/docs.mjs +197 -62
- package/src/memory.mjs +1 -1
- package/src/model-specs.mjs +38 -1
- package/src/prompts/advisor-design.md +46 -0
- package/src/prompts/advisor-round1.md +49 -2
- package/src/prompts/advisor-round2.md +47 -0
- package/src/prompts/advisor-round3.md +47 -0
- package/src/prompts/coder.md +22 -0
- package/src/prompts/consult-base.md +13 -0
- package/src/prompts/discipline.md +25 -6
- package/src/prompts/eng-coder.md +2 -2
- package/src/prompts/engineering-sub.md +23 -1
- package/src/prompts/engineering.md +157 -50
- package/src/prompts/explore.md +1 -2
- package/src/prompts/main.md +11 -5
- package/src/prompts/methodology-template.md +14 -0
- package/src/prompts/system.md +5 -2
- package/src/provider/anthropic.mjs +7 -5
- package/src/provider/core.mjs +104 -28
- package/src/provider/google.mjs +57 -24
- package/src/provider/normalize.mjs +1 -1
- package/src/provider/rate.mjs +0 -2
- package/src/provider/responses.mjs +8 -13
- package/src/provider/sse.mjs +20 -0
- package/src/session.mjs +15 -0
- package/src/tools/apply_patch.md +5 -1
- package/src/tools/bash.md +3 -3
- package/src/tools/delete.md +1 -0
- package/src/tools/edit-batch.mjs +92 -0
- package/src/tools/edit-diff.mjs +265 -0
- package/src/tools/edit.md +11 -6
- package/src/tools/execute.md +8 -8
- package/src/tools/execute.mjs +31 -35
- package/src/tools/file.mjs +26 -114
- package/src/tools/file_ops.md +3 -2
- package/src/tools/get_current_time.md +3 -1
- package/src/tools/git.md +1 -1
- package/src/tools/git.mjs +8 -16
- package/src/tools/hashline_edit.md +2 -0
- package/src/tools/index.mjs +3 -2
- package/src/tools/insert_after.md +2 -1
- package/src/tools/lint.md +3 -1
- package/src/tools/linter.mjs +9 -37
- package/src/tools/lsp.md +4 -1
- package/src/tools/patch.mjs +84 -13
- package/src/tools/pdf-parse-text.mjs +497 -0
- package/src/tools/pdf-parse-xref.mjs +499 -0
- package/src/tools/pdf.mjs +155 -0
- package/src/tools/question.md +2 -1
- package/src/tools/read.md +1 -0
- package/src/tools/read_pdf.md +21 -0
- package/src/tools/repomap.mjs +1 -1
- package/src/tools/shared.mjs +11 -32
- package/src/tools/system.mjs +6 -21
- package/src/tools/tree.md +2 -1
- package/src/tools/web.mjs +5 -3
- package/src/tools/websearch.md +2 -1
- package/src/tools/write.md +2 -0
- package/src/traces/trace-store.mjs +224 -0
- package/src/tui/agent-turn.mjs +387 -24
- package/src/tui/clipboard.mjs +17 -6
- package/src/tui/cmd-config.mjs +29 -9
- package/src/tui/cmd-eng.mjs +1 -0
- package/src/tui/cmd-extract.mjs +1 -1
- package/src/tui/cmd-mcp-form.mjs +197 -0
- package/src/tui/cmd-mcp.mjs +264 -114
- package/src/tui/cmd-think.mjs +1 -1
- package/src/tui/index.mjs +49 -95
- package/src/tui/interaction.mjs +41 -3
- package/src/tui/key-handler.mjs +105 -143
- package/src/tui/key-modes.mjs +215 -0
- package/src/tui/layout.mjs +22 -1
- package/src/tui/mouse.mjs +41 -1
- package/src/tui/pickers.mjs +73 -7
- package/src/tui/render-conversation.mjs +13 -161
- package/src/tui/render-frame.mjs +45 -20
- package/src/tui/render-loop.mjs +4 -1
- package/src/tui/render-segments.mjs +165 -0
- package/src/tui/render.mjs +4 -4
- package/src/tui/startup.mjs +40 -2
- package/src/tui/subagent-blocks.mjs +404 -111
- package/src/tui/subagent-panel.mjs +88 -13
- package/src/tui/tool-args.mjs +10 -2
- package/src/tui/tool-events.mjs +172 -95
- package/src/tui/update-notice.mjs +72 -0
- package/src/tui/wizard.mjs +36 -6
- package/src/agent-tools/escalate.mjs +0 -179
- package/src/tools/exec-prelude.mjs +0 -84
package/src/config.mjs
CHANGED
|
@@ -73,13 +73,17 @@ export const DEFAULTS = {
|
|
|
73
73
|
provider: "tavily", // structured search API; empty apiKey → fall back to Bing HTML scraping
|
|
74
74
|
apiKey: "", // Tavily key (tvly-...) — optional
|
|
75
75
|
},
|
|
76
|
+
traces: {
|
|
77
|
+
enabled: false, // §18.6 D-TR6 修订(2026-09-05 用户裁定——发布隐私:"不希望用户那边也采集"):轨迹存档默认 OFF——新用户零采集;本地调试分析可显式开(~/.thincoder/config.json traces.enabled:true)
|
|
78
|
+
retentionHours: 24, // D-TR10:轨迹文件保留小时数——CLI 启动时删除超过该时长的文件(默认 24h)
|
|
79
|
+
},
|
|
76
80
|
}
|
|
77
81
|
|
|
78
82
|
// Model capability table + spec lookup live in model-specs.mjs (2026-08-31
|
|
79
83
|
// extract — config.mjs had grown past the 300-line advisory). Re-exported here
|
|
80
84
|
// so the 23 existing importers keep their import paths.
|
|
81
|
-
import { specForModel } from "./model-specs.mjs"
|
|
82
|
-
export { specForModel }
|
|
85
|
+
import { specForModel, providerSpec } from "./model-specs.mjs"
|
|
86
|
+
export { specForModel, providerSpec }
|
|
83
87
|
|
|
84
88
|
|
|
85
89
|
// Window utilization threshold: compacts at 60% context, reserving 40% headroom
|
|
@@ -87,10 +91,14 @@ export { specForModel }
|
|
|
87
91
|
// memory/doc search results) which can consume 30-50K tokens each turn.
|
|
88
92
|
const COMPACT_RATIO = 0.6
|
|
89
93
|
|
|
90
|
-
/** Derive compaction threshold; explicit is the value explicitly set in config file (takes priority), otherwise auto-computed from model
|
|
91
|
-
|
|
94
|
+
/** Derive compaction threshold; explicit is the value explicitly set in config file (takes priority), otherwise auto-computed from model.
|
|
95
|
+
* Second param accepts EITHER a model name string (pure spec lookup — legacy caller:
|
|
96
|
+
* first-run wizard) OR a provider object (providerSpec — the providers[].context
|
|
97
|
+
* override in K units is honored, PROVIDER.md §15 T-C2). */
|
|
98
|
+
export function resolveCompactThreshold(explicit, modelOrProvider) {
|
|
92
99
|
if (explicit != null) return { value: explicit, auto: false }
|
|
93
|
-
const
|
|
100
|
+
const provider = typeof modelOrProvider === "string" ? { model: modelOrProvider } : (modelOrProvider ?? {})
|
|
101
|
+
const spec = providerSpec(provider)
|
|
94
102
|
const value = Math.floor(spec.context * COMPACT_RATIO)
|
|
95
103
|
return { value, auto: true }
|
|
96
104
|
}
|
|
@@ -125,6 +133,9 @@ export function resolveEnableThinking(provider, spec) {
|
|
|
125
133
|
return undefined
|
|
126
134
|
}
|
|
127
135
|
|
|
136
|
+
/** Module-level one-time warn dedupe for invalid providers[].context (PROVIDER.md §15 D-C1). */
|
|
137
|
+
const warnedContextProviders = new Set()
|
|
138
|
+
|
|
128
139
|
/**
|
|
129
140
|
* Find provider by name in providers[].
|
|
130
141
|
* Throws if name is non-empty but not found — a typo in activeProvider silently falling to the first provider would use the wrong key on the wrong endpoint.
|
|
@@ -188,6 +199,20 @@ export function loadConfig() {
|
|
|
188
199
|
agent: { ...DEFAULTS.agent, ...config.agent },
|
|
189
200
|
memory: { ...DEFAULTS.memory, ...config.memory },
|
|
190
201
|
embedding: { ...DEFAULTS.embedding, ...config.embedding },
|
|
202
|
+
traces: { ...DEFAULTS.traces, ...config.traces },
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// providers[].context (K units, PROVIDER.md §15 D-C1): positive integer only — invalid
|
|
206
|
+
// values (0/negative/non-numeric) are IGNORED (spec value applies) with a ONE-TIME warn
|
|
207
|
+
// per provider name (module-level dedupe, same precedent as warnedModels in model-specs.mjs).
|
|
208
|
+
for (const p of merged.providers) {
|
|
209
|
+
if (p.context === undefined) continue
|
|
210
|
+
if (Number.isInteger(Number(p.context)) && Number(p.context) > 0) { p.context = Number(p.context); continue } // 数字字符串("128")归一为数字——两端语义统一(code review #1)
|
|
211
|
+
if (!warnedContextProviders.has(p.name ?? "(unnamed)")) {
|
|
212
|
+
warnedContextProviders.add(p.name ?? "(unnamed)")
|
|
213
|
+
console.warn(`[config] provider "${p.name}" context must be a positive integer in K units (e.g. 128 = 128K) — got ${JSON.stringify(p.context)} — ignored, using the model spec value`)
|
|
214
|
+
}
|
|
215
|
+
delete p.context
|
|
191
216
|
}
|
|
192
217
|
|
|
193
218
|
// Consult/escalate pool validation (CLI parity with the plugin): up to 5 candidates.
|
|
@@ -227,7 +252,16 @@ export function loadConfig() {
|
|
|
227
252
|
merged.proxy = normalizeProxy(merged.proxy)
|
|
228
253
|
|
|
229
254
|
// Get the currently active provider
|
|
230
|
-
|
|
255
|
+
// 2026-09-02 Q1(SESSION.md §8):activeProvider 指向不存在的 provider 不再抛错——runtimeProvider
|
|
256
|
+
// 置空对象,由 make-agent.mjs assembleAgent 后的校验打 `_providerInvalid` 标记 → TUI 引导重选 /
|
|
257
|
+
// headless 报可读错误(原 findProvider throw 直接击穿 loadConfig → uncaughtException 退出)。
|
|
258
|
+
// findProvider 的 throw 契约保留(advisor/run.mjs 等直接调用方仍依赖)。
|
|
259
|
+
let active
|
|
260
|
+
try {
|
|
261
|
+
active = findProvider(merged.providers, merged.activeProvider)
|
|
262
|
+
} catch {
|
|
263
|
+
active = {}
|
|
264
|
+
}
|
|
231
265
|
|
|
232
266
|
// Build runtime provider object (for agent.provider usage)
|
|
233
267
|
const runtimeProvider = { ...active }
|
|
@@ -236,20 +270,103 @@ export function loadConfig() {
|
|
|
236
270
|
if (merged.activeModel) runtimeProvider.model = merged.activeModel
|
|
237
271
|
merged.activeModel = merged.activeModel || null // normalize for agent.activeModel
|
|
238
272
|
|
|
239
|
-
// Compaction threshold follows the model
|
|
273
|
+
// Compaction threshold follows the model (provider-level context override honored — providerSpec)
|
|
240
274
|
const explicitThreshold = config.agent?.compactThreshold
|
|
241
|
-
const { value, auto } = resolveCompactThreshold(explicitThreshold, runtimeProvider
|
|
275
|
+
const { value, auto } = resolveCompactThreshold(explicitThreshold, runtimeProvider)
|
|
242
276
|
merged.agent.compactThreshold = value
|
|
243
277
|
merged.agent.compactThresholdAuto = auto
|
|
244
278
|
|
|
245
279
|
// Write back to merged for convenient access by upper layers
|
|
246
280
|
merged.provider = runtimeProvider
|
|
281
|
+
// fetch 超时可配置(2026-09-01:agent.fetchTimeoutMs——provider/core.mjs effectiveFetchTimeoutMs 消费)
|
|
282
|
+
runtimeProvider.fetchTimeoutMs = Number.isFinite(merged.agent?.fetchTimeoutMs) && merged.agent.fetchTimeoutMs > 0
|
|
283
|
+
? merged.agent.fetchTimeoutMs : undefined
|
|
247
284
|
merged.providersList = merged.providers
|
|
248
285
|
merged.advisor = { ...merged.agent.advisor } // promote for consistent access (decoupled copy)
|
|
249
286
|
|
|
250
287
|
return merged
|
|
251
288
|
}
|
|
252
289
|
|
|
290
|
+
/**
|
|
291
|
+
* MCP.md §5 D-3 (2026-09-01): re-read config.json and replace ONLY the agent's mcp section
|
|
292
|
+
* — the agent 代配 closed loop (agent edits config.json with its edit tool, /mcp picks it
|
|
293
|
+
* up). Never touches other config sections (providers/activeProvider stay as loaded).
|
|
294
|
+
*
|
|
295
|
+
* Malformed disk config → memory state kept, { ok:false, error } returned (the /mcp menu
|
|
296
|
+
* shows "⚠ disk config unreadable"). Never throws.
|
|
297
|
+
*
|
|
298
|
+
* 对账 (reconciliation, MCP.md §5 D-3 / T23): returns which disk servers CHANGED
|
|
299
|
+
* (fingerprint differs) or are DELETED from disk while still connected — fingerprint =
|
|
300
|
+
* endpoint + token + headers/env key order. Existing connections are NOT torn down (an
|
|
301
|
+
* in-use server must not be dropped): a deleted-but-connected server KEEPS its memory
|
|
302
|
+
* entry (appended after the disk list) so the /mcp list can still show the row with the
|
|
303
|
+
* "⚠ disk changed" mark. A server that is merely NEW on disk is not drift. persistRaw
|
|
304
|
+
* write + reload is idempotent (fingerprints equal → no drift mark).
|
|
305
|
+
*
|
|
306
|
+
* @param path optional config path override (tests inject a tmp file; default configPath)
|
|
307
|
+
*/
|
|
308
|
+
export function reloadMcpFromDisk(agent, path) {
|
|
309
|
+
const memoryServers = Array.isArray(agent.config?.mcp?.servers) ? agent.config.mcp.servers : []
|
|
310
|
+
const fileExists = existsSync(path ?? configPath)
|
|
311
|
+
const diskMcp = readMcpSection(path)
|
|
312
|
+
if (!diskMcp.ok) return { ok: false, error: diskMcp.error, changedNames: [] }
|
|
313
|
+
// Missing/deleted config file → keep whichever mcp servers the session had (never
|
|
314
|
+
// silently drop user servers because the file vanished — same memory-keeps policy
|
|
315
|
+
// as the malformed-disk fallback).
|
|
316
|
+
let diskServers = diskMcp.servers
|
|
317
|
+
if (diskServers.length === 0 && !fileExists) diskServers = memoryServers
|
|
318
|
+
// Drift vs the RAW disk list: fingerprint-changed or deleted-from-disk (T23 ⚠ 标记依据)
|
|
319
|
+
const diskNames = new Set(diskServers.filter((s) => s?.name).map((s) => s.name))
|
|
320
|
+
const changedNames = diffMcpServers(memoryServers, diskServers)
|
|
321
|
+
// Connected servers deleted from disk stay in the list (memory copy) — T23: the row
|
|
322
|
+
// must remain visible (marked ⚠) and its live connection untouched. They are already
|
|
323
|
+
// in changedNames (absent from disk), and stay flagged on every reload until the user
|
|
324
|
+
// reconnects (re-persists them) or removes them — real drift, honestly reported.
|
|
325
|
+
const connectedNames = new Set((agent.tools ?? []).filter((t) => t?._mcpName).map((t) => t._mcpName))
|
|
326
|
+
const keptConnected = memoryServers.filter((s) => s?.name && connectedNames.has(s.name) && !diskNames.has(s.name))
|
|
327
|
+
const finalServers = [...diskServers, ...keptConnected]
|
|
328
|
+
agent.config ??= {}
|
|
329
|
+
agent.config.mcp = { ...agent.config.mcp, servers: finalServers }
|
|
330
|
+
return { ok: true, servers: finalServers, changedNames }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Disk read behind reloadMcpFromDisk — bounded, never throws. */
|
|
334
|
+
function readMcpSection(path = configPath) {
|
|
335
|
+
try {
|
|
336
|
+
if (!existsSync(path)) return { ok: true, servers: [] }
|
|
337
|
+
const raw = JSON.parse(readFileSync(path, "utf8"))
|
|
338
|
+
const servers = raw?.mcp?.servers
|
|
339
|
+
if (servers !== undefined && !Array.isArray(servers)) return { ok: true, servers: [] }
|
|
340
|
+
return { ok: true, servers: Array.isArray(servers) ? servers : [] }
|
|
341
|
+
} catch (error) {
|
|
342
|
+
return { ok: false, error: error?.message ?? String(error) }
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Fingerprint = endpoint + token + headers/env entries in key order (JSON.stringify
|
|
347
|
+
* of a normalized subset — key order included, matching connectMcpServer's
|
|
348
|
+
* configFingerprint semantics: any change the connect layer would see counts).
|
|
349
|
+
* Drift = CHANGED (fingerprint differs) or DELETED (missing from disk) — a server
|
|
350
|
+
* that is new on disk is not drift (no live connection to protect). */
|
|
351
|
+
function diffMcpServers(memoryServers, diskServers) {
|
|
352
|
+
const memFp = new Map(memoryServers.filter((s) => s?.name).map((s) => [s.name, mcpFingerprint(s)]))
|
|
353
|
+
const diskFp = new Map(diskServers.filter((s) => s?.name).map((s) => [s.name, mcpFingerprint(s)]))
|
|
354
|
+
const changed = []
|
|
355
|
+
for (const [name, fp] of diskFp) if (memFp.has(name) && memFp.get(name) !== fp) changed.push(name)
|
|
356
|
+
for (const name of memFp.keys()) if (!diskFp.has(name)) changed.push(name)
|
|
357
|
+
return changed
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function mcpFingerprint(s) {
|
|
361
|
+
return JSON.stringify([
|
|
362
|
+
s.wsUrl ?? s.url ?? s.command ?? null,
|
|
363
|
+
s.args ?? null,
|
|
364
|
+
s.token ?? null,
|
|
365
|
+
s.headers ?? null,
|
|
366
|
+
s.env ?? null,
|
|
367
|
+
])
|
|
368
|
+
}
|
|
369
|
+
|
|
253
370
|
/**
|
|
254
371
|
* Save configuration. Preserves providers list structure and activeProvider pointer.
|
|
255
372
|
* providers[i].apiKey is only written when explicitly passed in (does not overwrite env-var-fallback keys).
|
package/src/context.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { chat } from "./provider/index.mjs"
|
|
13
13
|
import { estimateText } from "./provider/rate.mjs"
|
|
14
|
-
import {
|
|
14
|
+
import { providerSpec } from "./config.mjs"
|
|
15
15
|
|
|
16
16
|
const IMAGE_TOKEN_ESTIMATE = 2000 // rough estimate for image content tokens (CLI legacy 256 underestimated real image costs, delaying compaction)
|
|
17
17
|
|
|
@@ -37,16 +37,25 @@ export function estimateTokens(messages) {
|
|
|
37
37
|
const KEEP_HEAD = 0 // No dedicated head: earliest messages may be a COMPLETED earlier task in multi-task
|
|
38
38
|
// sessions — keeping them verbatim anchored attention on stale work. Everything before the tail is
|
|
39
39
|
// summarized (the summary itself distinguishes completed vs in-progress work; see SUMMARIZE_PROMPT).
|
|
40
|
-
// Tail
|
|
41
|
-
//
|
|
42
|
-
//
|
|
40
|
+
// Tail count formula (D4): window-adaptive (~30 msgs per 100K — old fixed 10 too thin on 1M), capped
|
|
41
|
+
// at 40% of history; §9 D-T1/D-T2 make the count only a CANDIDATE — a token budget (TAIL_BUDGET_FRACTION
|
|
42
|
+
// × window − SUMMARY_TOKEN_ESTIMATE ≈1K, §8) tightens it over pair-safe boundaries when compaction runs,
|
|
43
|
+
// never below TAIL_FLOOR_MESSAGES; ordinary sessions never reach it (D-T4: trigger 0.6 untouched).
|
|
44
|
+
const TAIL_BUDGET_FRACTION = 0.15
|
|
45
|
+
const SUMMARY_TOKEN_ESTIMATE = 1000 // §8: summary output target ~1K tokens — reserved from the 15%
|
|
46
|
+
const TAIL_FLOOR_MESSAGES = 10 // §9 D-T2: the tail keeps ≥10 verbatim messages — floor beats budget
|
|
43
47
|
function keepTailSize(provider, historyLen) {
|
|
44
|
-
// provider is guaranteed at every call site (runAgent always builds one);
|
|
45
|
-
// degrades to DEFAULT_SPEC (128K) only if provider
|
|
46
|
-
// because the 40% history cap still bounds the tail.
|
|
47
|
-
|
|
48
|
+
// provider is guaranteed at every call site (runAgent always builds one); providerSpec
|
|
49
|
+
// degrades to DEFAULT_SPEC (128K) only if provider is somehow absent — acceptable
|
|
50
|
+
// because the 40% history cap still bounds the tail. providers[].context override
|
|
51
|
+
// (K units) is honored here (PROVIDER.md §15 T-C2: tail formula follows the window).
|
|
52
|
+
const ctxWindow = providerSpec(provider).context
|
|
48
53
|
return Math.min(Math.max(10, Math.floor((ctxWindow / 100_000) * 30)), Math.floor(historyLen * 0.4))
|
|
49
54
|
}
|
|
55
|
+
// §9 D-T1 tail token budget: window×15% − summary ~1K — the compressed history segment (summary + placeholder + tail) lands ≈ 15% (B 口径 §9.5).
|
|
56
|
+
function tailBudgetTokens(provider) {
|
|
57
|
+
return Math.max(0, Math.floor(providerSpec(provider).context * TAIL_BUDGET_FRACTION) - SUMMARY_TOKEN_ESTIMATE)
|
|
58
|
+
}
|
|
50
59
|
|
|
51
60
|
export const SUMMARIZE_PROMPT = `You are a conversation compressor. Summarize the following agent work log into a compact summary for use as context in the ongoing conversation.
|
|
52
61
|
Requirements:
|
|
@@ -58,7 +67,7 @@ Requirements:
|
|
|
58
67
|
- Explicitly list UNRESOLVED ISSUES / TODOs: anything still open plus the next steps — so post-compaction recovery knows where to resume
|
|
59
68
|
- Drop: pleasantries, repetition, fine-grained tool output details
|
|
60
69
|
- Honestly mark uncertain items: anything not actually verified must say "unverified"; do not present guesses as facts
|
|
61
|
-
- Use bullet-point output
|
|
70
|
+
- Use bullet-point output. Stay under ~1K tokens (≈1000 Chinese chars / 4000 ASCII chars) — a hard target. An oversized summary wastes window and dilutes the tail; the old unbounded-length guidance is deprecated. When over budget, trim in this order: completed recaps to one line; FILES CHANGED why-notes to bare paths; in-progress prose tightened. NEVER cut design anchors or UNRESOLVED ISSUES/TODOs — recovery depends on them.
|
|
62
71
|
|
|
63
72
|
Work log:
|
|
64
73
|
`
|
|
@@ -67,7 +76,7 @@ Work log:
|
|
|
67
76
|
const COMPACTION_PREFIX =
|
|
68
77
|
"[Context was automatically compacted. Below is a summary of earlier work. " +
|
|
69
78
|
"Treat it as notes, not proof — trust its conclusions (don't redo what it reports as done) " +
|
|
70
|
-
"but re-verify transient state with tools. Check
|
|
79
|
+
"but re-verify transient state with tools. Check memory search for any missing decisions.]\n\n"
|
|
71
80
|
|
|
72
81
|
/** After this many consecutive compaction summary failures, degrade to deterministic truncation (losing info is better than task-killing 400 errors) */
|
|
73
82
|
export const COMPRESS_FAILURE_LIMIT = 3
|
|
@@ -83,12 +92,13 @@ const FALLBACK_NOTE =
|
|
|
83
92
|
|
|
84
93
|
/**
|
|
85
94
|
* Split history into head / middle (to be summarized) / tail; return null if no middle to compress.
|
|
86
|
-
* head is normally empty (KEEP_HEAD = 0 — earliest messages go into the summary); the
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
* the
|
|
95
|
+
* head is normally empty (KEEP_HEAD = 0 — earliest messages go into the summary); the tool_calls-extension logic below is defensive for future KEEP_HEAD > 0.
|
|
96
|
+
* The tail boundary must include any assistant whose tool results are in the tail — if the assistant is in the middle, the summary swallows it, leaving orphan tool results → protocol 400.
|
|
97
|
+
* `budgetTokens` (optional, §9 D-T1): when the candidate's estimate exceeds it, the boundary moves
|
|
98
|
+
* forward until the tail fits — never below the D-T2 floor (10 msgs, or the candidate itself when
|
|
99
|
+
* the 40% cap made it < 10 — short history).
|
|
90
100
|
*/
|
|
91
|
-
function splitHistory(history, keepTail) {
|
|
101
|
+
function splitHistory(history, keepTail, budgetTokens = null) {
|
|
92
102
|
if (history.length <= KEEP_HEAD + keepTail + 1) return null
|
|
93
103
|
let headEnd = KEEP_HEAD
|
|
94
104
|
// head must not end with dangling tool_calls: when assistant declares tool_calls, all its tool results must stay in head.
|
|
@@ -96,10 +106,24 @@ function splitHistory(history, keepTail) {
|
|
|
96
106
|
if (history[headEnd - 1]?.role === "assistant" && history[headEnd - 1].tool_calls?.length) {
|
|
97
107
|
while (headEnd < history.length && history[headEnd].role === "tool") headEnd++
|
|
98
108
|
}
|
|
99
|
-
|
|
109
|
+
const candidate = repairedTailStart(history, headEnd, history.length - keepTail)
|
|
110
|
+
if (candidate <= headEnd) return null
|
|
111
|
+
let tailStart = candidate
|
|
112
|
+
// §9 D-T1: tighten only above the floor — a candidate ≤ 10 IS the floor (short history under the 40% cap must not tighten further, review #5); the floor is D5-repaired too.
|
|
113
|
+
if (budgetTokens > 0 && keepTail > TAIL_FLOOR_MESSAGES) {
|
|
114
|
+
const floor = repairedTailStart(history, headEnd, history.length - TAIL_FLOOR_MESSAGES)
|
|
115
|
+
if (floor > candidate) tailStart = tightenTailByBudget(history, candidate, floor, budgetTokens)
|
|
116
|
+
}
|
|
117
|
+
return { headEnd, tailStart }
|
|
118
|
+
}
|
|
100
119
|
|
|
101
|
-
|
|
102
|
-
|
|
120
|
+
/**
|
|
121
|
+
* D5 tail-side pairing repair for a raw cut at history.length − tailCount: pull into the tail any
|
|
122
|
+
* assistant whose tool results are in the tail (the summary swallowing the owner leaves orphan tool
|
|
123
|
+
* results → protocol 400), then skip orphan tool messages at the new boundary. Single-assistant
|
|
124
|
+
* assumption (nearest owner only — a tail spans at most one assistant→tools cycle); bounds-guarded.
|
|
125
|
+
*/
|
|
126
|
+
function repairedTailStart(history, headEnd, tailStart) {
|
|
103
127
|
const tailToolIds = new Set()
|
|
104
128
|
for (let i = tailStart; i < history.length; i++) {
|
|
105
129
|
if (history[i].role === "tool") tailToolIds.add(history[i].tool_call_id)
|
|
@@ -111,16 +135,28 @@ function splitHistory(history, keepTail) {
|
|
|
111
135
|
break
|
|
112
136
|
}
|
|
113
137
|
}
|
|
114
|
-
|
|
115
|
-
// skip orphan tool messages at the new tail boundary (tool whose assistant was pulled in above)
|
|
116
|
-
// NOTE: single-assistant assumption — the backwards scan pulls the nearest owner only; in
|
|
117
|
-
// practice a tail spans at most one assistant→tools cycle (parallel calls share one assistant).
|
|
118
|
-
// Bounds-guarded so an all-tool tail cannot push tailStart past history.length.
|
|
119
138
|
while (tailStart < history.length && tailStart > headEnd && history[tailStart].role === "tool") {
|
|
120
139
|
tailStart++
|
|
121
140
|
}
|
|
122
|
-
|
|
123
|
-
|
|
141
|
+
return tailStart
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* §9 D-T1 budget tightening (pair-safe, review #2): walk the boundary FORWARD (fewer tail messages —
|
|
146
|
+
* the rest joins the summary) while the tail's estimated tokens exceed the budget. Only pair-safe
|
|
147
|
+
* positions may stop the walk: a boundary ON a tool message would orphan its owner assistant into the
|
|
148
|
+
* middle (D5); pairing is contiguous in the machine line (§6 note) — every non-tool boundary is safe.
|
|
149
|
+
* No fit before the floor → keep the floor, accept the overrun.
|
|
150
|
+
*/
|
|
151
|
+
function tightenTailByBudget(history, start, floorStart, budgetTokens) {
|
|
152
|
+
const suffixTokens = new Array(history.length + 1)
|
|
153
|
+
suffixTokens[history.length] = 0
|
|
154
|
+
for (let i = history.length - 1; i >= 0; i--) suffixTokens[i] = suffixTokens[i + 1] + estimateTokens([history[i]])
|
|
155
|
+
if (suffixTokens[start] <= budgetTokens) return start // already fits — ordinary sessions stay untouched (D-T2)
|
|
156
|
+
for (let p = start + 1; p <= floorStart; p++) { // first fit keeps the most recent verbatim context
|
|
157
|
+
if (history[p].role !== "tool" && suffixTokens[p] <= budgetTokens) return p
|
|
158
|
+
}
|
|
159
|
+
return floorStart
|
|
124
160
|
}
|
|
125
161
|
|
|
126
162
|
/**
|
|
@@ -131,9 +167,14 @@ function splitHistory(history, keepTail) {
|
|
|
131
167
|
* Machine-only messages ([System reminder:...], compaction notes, task/plan/checkpoint re-injections)
|
|
132
168
|
* are pushed directly to agent.history WITHOUT going through here, so they never enter _fullHistory.
|
|
133
169
|
* The two lines are written independently at the source — no after-the-fact delta sync.
|
|
170
|
+
* Message timestamps (SESSION.md §9 D-S1): stamped HERE once at push time (epoch ms) — a single
|
|
171
|
+
* point covers every real message. Pre-existing ts (e.g. from another end writing the shared slot)
|
|
172
|
+
* is preserved; restored old messages keep no ts rather than getting a misleading backdate (D-S3).
|
|
173
|
+
* ts is a LOCAL-ONLY field — the send layer strips it before any provider request (T-S3).
|
|
134
174
|
*/
|
|
135
175
|
export function pushReal(agent, msg) {
|
|
136
176
|
if (!Array.isArray(agent._fullHistory)) agent._fullHistory = []
|
|
177
|
+
if (msg && msg.ts === undefined) msg.ts = Date.now()
|
|
137
178
|
agent._fullHistory.push(msg)
|
|
138
179
|
agent.history.push(msg)
|
|
139
180
|
}
|
|
@@ -147,10 +188,14 @@ function applyCompression(agent, headEnd, tailStart, note) {
|
|
|
147
188
|
// possibly-completed earlier requests.
|
|
148
189
|
const head = agent.history.slice(0, headEnd)
|
|
149
190
|
const tail = agent.history.slice(tailStart)
|
|
191
|
+
// SESSION.md §9 D-S1: compaction-injected messages (note + "Understood") carry a ts —
|
|
192
|
+
// Date.now() at the compaction moment. They are machine-only (never in _fullHistory),
|
|
193
|
+
// but the machine-line timeline stays consistent for any audit use.
|
|
194
|
+
const now = Date.now()
|
|
150
195
|
agent.history = [
|
|
151
196
|
...head,
|
|
152
|
-
{ role: "user", content: note },
|
|
153
|
-
{ role: "assistant", content: "Understood. I'll continue from these notes, re-verifying anything transient." },
|
|
197
|
+
{ role: "user", content: note, ts: now },
|
|
198
|
+
{ role: "assistant", content: "Understood. I'll continue from these notes, re-verifying anything transient.", ts: now },
|
|
154
199
|
...tail,
|
|
155
200
|
]
|
|
156
201
|
// Compaction REBUILDS the machine line (head + note + "Understood" + tail), so the pre-compaction
|
|
@@ -195,8 +240,10 @@ function applyCompression(agent, headEnd, tailStart, note) {
|
|
|
195
240
|
* Automatically re-injects task list state after compaction.
|
|
196
241
|
* @param {object} agent
|
|
197
242
|
* @param {number} threshold - compaction threshold in tokens
|
|
198
|
-
* @param {object} callbacks - { onToken, onReasoning, onCompress } — summary
|
|
199
|
-
* (never forwards onToken/onReasoning: the compaction process is an
|
|
243
|
+
* @param {object} callbacks - { onToken, onReasoning, onCompress, onCompressStart } — summary
|
|
244
|
+
* generation is SILENT (never forwards onToken/onReasoning: the compaction process is an
|
|
245
|
+
* internal mechanism, not a model reply); onCompressStart fires right before the summary call
|
|
246
|
+
* (§7 D-C1, compression lifecycle visibility — panel start state)
|
|
200
247
|
* @param {object} extras - { systemPrompt?, tools? } — estimated overhead for the pure-estimation
|
|
201
248
|
* path (no measured baseline); the measured path already includes system+tools in prompt_tokens.
|
|
202
249
|
*/
|
|
@@ -214,7 +261,7 @@ export async function compressIfNeeded(agent, threshold, callbacks, extras = {},
|
|
|
214
261
|
if (tokens <= threshold) return false
|
|
215
262
|
|
|
216
263
|
const keepTail = keepTailSize(agent.provider, history.length)
|
|
217
|
-
const split = splitHistory(history, keepTail)
|
|
264
|
+
const split = splitHistory(history, keepTail, tailBudgetTokens(agent.provider))
|
|
218
265
|
if (!split) {
|
|
219
266
|
// History is too short (≤KEEP_HEAD+keepTail+1 messages) to find a middle section, but tokens exceed threshold — typically a single giant message
|
|
220
267
|
// (large paste / huge injection). When summarization has no room, degrade to deterministic shrinking to ensure context always reduces
|
|
@@ -239,13 +286,35 @@ export async function compressIfNeeded(agent, threshold, callbacks, extras = {},
|
|
|
239
286
|
// The summary is a plain-text task, no reasoning needed — passing thinking to the compaction provider wastes tokens.
|
|
240
287
|
// Silent by design (D11): no onToken/onReasoning — the compaction process must not stream to the frontend.
|
|
241
288
|
// signal propagates user cancellation (Ctrl+C) to the in-flight summary call.
|
|
289
|
+
// Compression visibility (CONTEXT-COMPACTION.md §7 D-C1/D-C2): the frontend learns the compression
|
|
290
|
+
// STARTED right before the LLM call ("Compressing context… / summarizing N messages" panel) — only
|
|
291
|
+
// the lifecycle is surfaced, never the summary body. N = the number of history messages being summarized.
|
|
292
|
+
callbacks?.onCompressStart?.({ messages: middle.length })
|
|
293
|
+
const startedAt = performance.now()
|
|
242
294
|
const summary = await chat({ ...agent.provider, thinking: null, reasoningEffort: null }, {
|
|
243
295
|
messages: [{ role: "user", content: SUMMARIZE_PROMPT + serialized }],
|
|
244
296
|
signal,
|
|
297
|
+
// §18.6 D-TR4:轨迹元数据增补——kind=compress(上下文构建面——agent 元数据透出;
|
|
298
|
+
// depth 经 extras.traceDepth——agent.mjs 主作用域传入——compress 调用点补齐)
|
|
299
|
+
logCtx: {
|
|
300
|
+
stage: "compress", child: agent._logId, kind: "compress",
|
|
301
|
+
role: agent._role ?? null, depth: extras?.traceDepth ?? null,
|
|
302
|
+
session: agent._sessionStart ?? null, cwd: agent.cwd,
|
|
303
|
+
traces: agent.config?.traces?.enabled !== false,
|
|
304
|
+
},
|
|
245
305
|
})
|
|
246
306
|
|
|
247
307
|
applyCompression(agent, split.headEnd, split.tailStart, COMPACTION_PREFIX + summary.content)
|
|
248
308
|
|
|
309
|
+
// Completion info for the compression panel (D-C2): tokens freed = the pre-compression prompt
|
|
310
|
+
// estimate (`tokens` — the value that tripped the threshold, incl. system/tools overhead on the
|
|
311
|
+
// pure-estimation path) minus the post-compression estimate on the same basis. Elapsed = the
|
|
312
|
+
// summary call + splice duration. agent.mjs forwards this to onCompress unchanged.
|
|
313
|
+
agent._lastCompressInfo = {
|
|
314
|
+
mode: "summary",
|
|
315
|
+
tokensFreed: Math.max(0, Math.round(tokens - (estimateTokens(agent.history) + overhead))),
|
|
316
|
+
elapsedMs: performance.now() - startedAt,
|
|
317
|
+
}
|
|
249
318
|
return true
|
|
250
319
|
}
|
|
251
320
|
|
|
@@ -255,9 +324,13 @@ export async function compressIfNeeded(agent, threshold, callbacks, extras = {},
|
|
|
255
324
|
*/
|
|
256
325
|
export function compressFallback(agent) {
|
|
257
326
|
const keepTail = keepTailSize(agent.provider, agent.history.length)
|
|
258
|
-
const split = splitHistory(agent.history, keepTail)
|
|
327
|
+
const split = splitHistory(agent.history, keepTail, tailBudgetTokens(agent.provider))
|
|
259
328
|
if (!split) return false
|
|
329
|
+
const tailMessages = agent.history.length - split.tailStart
|
|
260
330
|
applyCompression(agent, split.headEnd, split.tailStart, FALLBACK_NOTE)
|
|
331
|
+
// Fallback completion info (D-C2): mode marks the deterministic-truncation path — the panel
|
|
332
|
+
// shows the degradation note ("truncated to N messages") ONLY after 3 consecutive failures.
|
|
333
|
+
agent._lastCompressInfo = { mode: "fallback", tailMessages }
|
|
261
334
|
return true
|
|
262
335
|
}
|
|
263
336
|
|
|
@@ -383,7 +456,7 @@ function serializeExplorationMessages(messages) {
|
|
|
383
456
|
* or null when there is nothing to shrink (<3 exploration results / LLM failure). Pairing-safe:
|
|
384
457
|
* whole assistant→tool blocks are removed, so no orphan tool_calls/tool can survive.
|
|
385
458
|
*/
|
|
386
|
-
async function distillExplorations(history, start, provider, signal) {
|
|
459
|
+
async function distillExplorations(history, start, provider, signal, agent, depth) {
|
|
387
460
|
if (!Array.isArray(history) || history.length - start < 2) return null
|
|
388
461
|
const blocks = findExplorationBlocks(history, start)
|
|
389
462
|
const resultCount = blocks.reduce((n, b) => n + b.toolCount, 0)
|
|
@@ -398,6 +471,14 @@ async function distillExplorations(history, start, provider, signal) {
|
|
|
398
471
|
const resp = await chat({ ...provider, thinking: null, reasoningEffort: null }, {
|
|
399
472
|
messages: [{ role: "user", content: EXPLORE_SUMMARY_PROMPT + serialized }],
|
|
400
473
|
signal,
|
|
474
|
+
// §18.6 D-TR4:轨迹元数据增补——kind=distill(探索蒸馏面——agent 元数据透出;
|
|
475
|
+
// depth 经 summarizeRunExplorations 参数透传——agent.mjs 主作用域传入)
|
|
476
|
+
logCtx: {
|
|
477
|
+
stage: "distill", child: agent?._logId ?? null, kind: "distill",
|
|
478
|
+
role: agent?._role ?? null, depth: depth ?? null,
|
|
479
|
+
session: agent?._sessionStart ?? null, cwd: agent?.cwd ?? process.cwd(),
|
|
480
|
+
traces: agent?.config?.traces?.enabled !== false,
|
|
481
|
+
},
|
|
401
482
|
})
|
|
402
483
|
summary = resp?.content
|
|
403
484
|
} catch {
|
|
@@ -428,8 +509,8 @@ async function distillExplorations(history, start, provider, signal) {
|
|
|
428
509
|
* ONLY after the replacement actually lands (never on no-op/failure) — callers persist the
|
|
429
510
|
* compressed session (SEND-STALL-DISTILL §2.3).
|
|
430
511
|
*/
|
|
431
|
-
export async function summarizeRunExplorations(agent, callbacks, signal) {
|
|
432
|
-
const next = await distillExplorations(agent.history, agent._runStartHistoryLen ?? 0, agent.provider, signal)
|
|
512
|
+
export async function summarizeRunExplorations(agent, callbacks, signal, depth = 0) {
|
|
513
|
+
const next = await distillExplorations(agent.history, agent._runStartHistoryLen ?? 0, agent.provider, signal, agent, depth)
|
|
433
514
|
if (!next) return
|
|
434
515
|
agent.history = next
|
|
435
516
|
// The machine line changed shape — the measured token baseline was for the pre-shrink context.
|
package/src/distill.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { chat } from "./provider/index.mjs"
|
|
8
|
+
import { loadConfig } from "./config.mjs"
|
|
8
9
|
import { put, putMarkdown } from "./memory.mjs"
|
|
9
10
|
import { commitAndPush } from "./git/gitmem.mjs"
|
|
10
11
|
|
|
@@ -39,13 +40,30 @@ If the session is long, prioritize conclusions that appeared last and are still
|
|
|
39
40
|
Session log:
|
|
40
41
|
`
|
|
41
42
|
|
|
43
|
+
/** §18.6 D-TR6(2026-09-04 fix round1):distill 调用点无 agent 作用域——traces 开关
|
|
44
|
+
* 缺省回退磁盘配置(loadConfig——与 agent.config 同源:traces.enabled 缺省 on);
|
|
45
|
+
* 配置不可读时按缺省 on(注:CLI 启动早已 loadConfig——此处仅是兜底防御)。 */
|
|
46
|
+
function tracesEnabledFromConfig() {
|
|
47
|
+
try {
|
|
48
|
+
return loadConfig().traces?.enabled !== false
|
|
49
|
+
} catch {
|
|
50
|
+
return true
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
42
54
|
/**
|
|
43
55
|
* Extract candidates from a session transcript. transcript: plain-text session record.
|
|
44
56
|
* Returns [{ type, title, content, tags, scope }], or [] on parse failure.
|
|
57
|
+
* opts.traces(可选):§18.6 D-TR6 开关显式透传(测试隔离/未来调用方)——缺省回退
|
|
58
|
+
* 磁盘配置(tracesEnabledFromConfig)——关 = chat() 出口不落盘。
|
|
45
59
|
*/
|
|
46
|
-
export async function extractCandidates(provider, transcript) {
|
|
60
|
+
export async function extractCandidates(provider, transcript, opts = {}) {
|
|
61
|
+
const traces = opts.traces ?? tracesEnabledFromConfig()
|
|
47
62
|
const res = await chat(provider, {
|
|
48
63
|
messages: [{ role: "user", content: DISTILL_PROMPT + transcript }],
|
|
64
|
+
// §18.6 D-TR4/D-TR6(fix round1):distill 调用经 chat() 唯一采集点——补轨迹
|
|
65
|
+
// 元数据 + traces 开关透传(关=不落盘必须全覆盖——不再静默越过开关)
|
|
66
|
+
logCtx: { stage: "distill", kind: "distill", traces },
|
|
49
67
|
})
|
|
50
68
|
// Balanced-bracket extraction: find the first '[' and track depth through nested
|
|
51
69
|
// brackets (tags arrays, nested objects, etc.) until the matching ']'.
|