@thincoder/core 0.9.5 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (215) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +1 -1
  3. package/abort-provenance.mjs +10 -10
  4. package/advisor/citations.mjs +5 -5
  5. package/advisor/compaction.mjs +16 -15
  6. package/advisor/loop.mjs +12 -12
  7. package/advisor/messages.mjs +6 -6
  8. package/advisor/project-context.mjs +5 -5
  9. package/advisor/repos.mjs +3 -3
  10. package/advisor/run.mjs +22 -12
  11. package/advisor.mjs +4 -4
  12. package/agent/assemble.mjs +111 -0
  13. package/agent/chat-call.mjs +68 -0
  14. package/agent/child-marks.mjs +2 -2
  15. package/agent/completion.mjs +9 -2
  16. package/agent/continue-decision.mjs +25 -0
  17. package/agent/dispatch-gates.mjs +140 -0
  18. package/agent/dispatch-run.mjs +167 -0
  19. package/agent/dispatch.mjs +54 -296
  20. package/agent/family-tools.mjs +5 -5
  21. package/agent/helpers.mjs +53 -3
  22. package/agent/live-beat.mjs +49 -0
  23. package/agent/post-turn.mjs +4 -8
  24. package/agent/record-results.mjs +23 -20
  25. package/agent/relay-prefix.mjs +2 -2
  26. package/agent/run-stages.mjs +15 -11
  27. package/agent/run-start.mjs +111 -0
  28. package/agent/setup-reminders.mjs +9 -9
  29. package/agent/setup.mjs +32 -4
  30. package/agent/spawn-child.mjs +9 -9
  31. package/agent/suspension.mjs +89 -29
  32. package/agent/timers.mjs +113 -0
  33. package/agent/turn-loop.mjs +244 -0
  34. package/agent/write-gate.mjs +103 -6
  35. package/agent-tools/advisor-async.mjs +9 -9
  36. package/agent-tools/advisor-settle.mjs +12 -12
  37. package/agent-tools/advisor.mjs +36 -41
  38. package/agent-tools/async-discard.mjs +44 -1
  39. package/agent-tools/async-settle.mjs +21 -18
  40. package/agent-tools/audit-block.mjs +7 -7
  41. package/agent-tools/bash-async.mjs +281 -0
  42. package/agent-tools/batch-lifecycle.mjs +57 -18
  43. package/agent-tools/batch-paths.mjs +52 -8
  44. package/agent-tools/batch-skeleton.mjs +19 -1
  45. package/agent-tools/batch.mjs +12 -65
  46. package/agent-tools/checkpoint.mjs +121 -0
  47. package/agent-tools/child-permission.mjs +5 -3
  48. package/agent-tools/consult.mjs +56 -56
  49. package/agent-tools/context.mjs +4 -20
  50. package/agent-tools/design-token.mjs +3 -3
  51. package/agent-tools/digest-budget.mjs +1 -1
  52. package/agent-tools/eng.mjs +5 -5
  53. package/agent-tools/escalate-async.mjs +16 -11
  54. package/agent-tools/goal.mjs +3 -10
  55. package/agent-tools/panel-blocks.mjs +2 -2
  56. package/agent-tools/parent-channel.mjs +3 -13
  57. package/agent-tools/plan.mjs +3 -4
  58. package/agent-tools/read-history.mjs +12 -28
  59. package/agent-tools/recent-changes.mjs +4 -5
  60. package/agent-tools/review-facts.mjs +1 -1
  61. package/agent-tools/settings.mjs +47 -20
  62. package/agent-tools/skill.mjs +3 -5
  63. package/agent-tools/spawn-gates.mjs +3 -3
  64. package/agent-tools/subagent-actions-query.mjs +270 -0
  65. package/agent-tools/subagent-actions.mjs +21 -265
  66. package/agent-tools/subagent-async.mjs +25 -20
  67. package/agent-tools/subagent-panel.mjs +33 -4
  68. package/agent-tools/subagent-run.mjs +36 -21
  69. package/agent-tools/subagent-scheduler.mjs +28 -27
  70. package/agent-tools/subagent-spawn.mjs +45 -14
  71. package/agent-tools/subagent.mjs +33 -53
  72. package/agent-tools/task.mjs +3 -9
  73. package/agent-tools/timer.mjs +12 -12
  74. package/agent-tools/verify.mjs +14 -13
  75. package/agent-tools.mjs +0 -1
  76. package/agent.mjs +18 -355
  77. package/attachments.mjs +125 -0
  78. package/auto-think.mjs +1 -1
  79. package/config-presets.mjs +11 -6
  80. package/config-watch.mjs +83 -0
  81. package/config.mjs +17 -4
  82. package/context-degrade.mjs +81 -0
  83. package/context-echo.mjs +88 -0
  84. package/context-push.mjs +51 -0
  85. package/context.mjs +8 -187
  86. package/conventions.mjs +110 -161
  87. package/declaration.mjs +194 -0
  88. package/embedding.mjs +3 -1
  89. package/env-ref.mjs +81 -0
  90. package/explore-distill.mjs +2 -3
  91. package/file-links.mjs +53 -0
  92. package/file-refs.mjs +103 -0
  93. package/generate-title.mjs +28 -4
  94. package/history-window.mjs +17 -2
  95. package/i18n.mjs +2 -0
  96. package/index-discover.mjs +7 -7
  97. package/ledger-cmd.mjs +34 -126
  98. package/ledger-db.mjs +10 -2
  99. package/ledger-tools.mjs +188 -0
  100. package/ledger.mjs +9 -4
  101. package/log.mjs +12 -7
  102. package/manifest-discovery.mjs +131 -0
  103. package/manifest-schema.mjs +166 -0
  104. package/manifest.mjs +18 -217
  105. package/mcp.mjs +4 -1
  106. package/memory/code-search.mjs +149 -0
  107. package/memory/code-sync.mjs +89 -230
  108. package/memory/core.mjs +47 -32
  109. package/memory/docs.mjs +63 -262
  110. package/memory/file-list.mjs +89 -0
  111. package/memory/file-walk.mjs +7 -4
  112. package/memory/memory-tool.mjs +229 -0
  113. package/memory/schema.mjs +18 -3
  114. package/memory/sweep.mjs +289 -0
  115. package/memory/sync-tail.mjs +81 -0
  116. package/memory-status.mjs +69 -0
  117. package/memory.mjs +3 -0
  118. package/model-specs.mjs +16 -7
  119. package/notify-policy.mjs +51 -0
  120. package/package.json +1 -1
  121. package/peer-claims.mjs +2 -2
  122. package/peer-domains.mjs +7 -11
  123. package/peer-instances.mjs +9 -10
  124. package/process-probe-exec.mjs +187 -0
  125. package/process-probe.mjs +27 -175
  126. package/prompt-overlays.mjs +11 -10
  127. package/prompts/advisor-design.md +2 -2
  128. package/prompts/advisor-round2.md +1 -1
  129. package/prompts/advisor-round3.md +1 -1
  130. package/prompts/common.md +16 -8
  131. package/prompts/discipline-engineering.md +57 -16
  132. package/prompts/discipline-normal.md +6 -3
  133. package/prompts/persona-coder.md +0 -1
  134. package/prompts/persona-eng-coder.md +1 -5
  135. package/prompts/persona-eng-designer.md +2 -3
  136. package/prompts/persona-engineering.md +32 -11
  137. package/prompts/persona-explore.md +0 -1
  138. package/prompts/persona-normal.md +1 -1
  139. package/prompts/persona-plan.md +3 -4
  140. package/prompts/subagent-base.md +14 -0
  141. package/provider/core.mjs +11 -48
  142. package/provider/list-models.mjs +3 -0
  143. package/provider/responses-request.mjs +237 -0
  144. package/provider/responses.mjs +6 -228
  145. package/provider-flows.mjs +248 -0
  146. package/queued.mjs +90 -0
  147. package/rules.mjs +209 -2
  148. package/session-guard.mjs +27 -7
  149. package/session-lifecycle.mjs +90 -18
  150. package/session-rename.mjs +5 -1
  151. package/session-slot-claims.mjs +230 -0
  152. package/session-slot-scan.mjs +299 -0
  153. package/session-slot-verify.mjs +304 -0
  154. package/session-slot-write.mjs +74 -8
  155. package/session-slots-manifest.mjs +163 -241
  156. package/session-slots.mjs +99 -71
  157. package/session.mjs +30 -10
  158. package/shell-candidates.mjs +78 -0
  159. package/text-budget.mjs +2 -2
  160. package/think-off.mjs +49 -0
  161. package/token-ttl.mjs +15 -4
  162. package/tool-args.mjs +73 -0
  163. package/tool-docs/advisor.md +9 -0
  164. package/tool-docs/batch.md +1 -0
  165. package/tool-docs/code_search.md +1 -0
  166. package/tool-docs/consult_start.md +5 -0
  167. package/tool-docs/consult_stop.md +3 -0
  168. package/tool-docs/context.md +7 -0
  169. package/tool-docs/doc_search.md +1 -0
  170. package/tool-docs/edit.md +2 -13
  171. package/tool-docs/eng.md +1 -0
  172. package/tool-docs/execute.md +1 -9
  173. package/tool-docs/git.md +25 -51
  174. package/tool-docs/goal.md +6 -0
  175. package/tool-docs/ledger_add.md +1 -0
  176. package/tool-docs/ledger_close.md +1 -0
  177. package/tool-docs/ledger_count.md +1 -0
  178. package/tool-docs/ledger_query.md +1 -0
  179. package/tool-docs/ledger_update.md +1 -0
  180. package/tool-docs/main_history.md +1 -0
  181. package/tool-docs/memory.md +10 -0
  182. package/tool-docs/notify_parent.md +9 -0
  183. package/tool-docs/peer_instances.md +1 -0
  184. package/tool-docs/plan.md +5 -0
  185. package/tool-docs/process.md +7 -3
  186. package/tool-docs/read_history.md +7 -0
  187. package/tool-docs/recent_changes.md +1 -0
  188. package/tool-docs/repo_outline.md +1 -0
  189. package/tool-docs/settings.md +6 -0
  190. package/tool-docs/skill.md +5 -0
  191. package/tool-docs/subagent.md +31 -0
  192. package/tool-docs/task.md +7 -0
  193. package/tool-docs/timer.md +6 -0
  194. package/tool-docs/verify.md +1 -0
  195. package/tool-docs/wait_for.md +2 -1
  196. package/tools/bash.mjs +28 -4
  197. package/tools/edit-batch.mjs +4 -4
  198. package/tools/edit-diff.mjs +4 -23
  199. package/tools/exec-run.mjs +102 -4
  200. package/tools/execute.mjs +5 -2
  201. package/tools/file.mjs +4 -9
  202. package/tools/git-run.mjs +1 -1
  203. package/tools/git.mjs +0 -24
  204. package/tools/index.mjs +2 -1
  205. package/tools/linter.mjs +2 -1
  206. package/tools/node-child.mjs +21 -0
  207. package/tools/ops.mjs +50 -13
  208. package/tools/patch.mjs +4 -2
  209. package/tools/repomap.mjs +20 -9
  210. package/tools/shared.mjs +8 -6
  211. package/tools/web.mjs +5 -2
  212. package/tools/write-path.mjs +1 -1
  213. package/traces/trace-store.mjs +17 -14
  214. package/vision-reader.mjs +76 -0
  215. package/agent-tools/batch-segment.mjs +0 -18
@@ -0,0 +1,83 @@
1
+ /**
2
+ * config-watch.mjs — config.json 外部写盘感知·**纯逻辑面**(R8 · 桌面功能对位批「上提」KD-T2——
3
+ * 自 `thincoder-vscode/src/extension/config-watch.mjs:35-77` 逐字随迁:去抖 300ms ∕ stat 元组
4
+ * (`mtimeMs:size`)比对 ∕ 自写抑制;原批 = 第 21 批 B5 · `docs/design/SETTINGS.md` §2.6)。
5
+ *
6
+ * `~/.thincoder/config.json` 为多端共享单文件:CLI(`/advisor`、`settings set`)或手工编辑写盘后,
7
+ * 常开面板原本零感知(只在打开面板/保存时拉快照)。机制(零语义改):
8
+ * 宿主 fs 事件 →(端落子)`onEvent` → 去抖 → 元组比对 → 变化才 `onChange`(事件驱动零空转)。
9
+ * 自写抑制:本端自身写盘同样改元组 ⇒ 裸推送会在用户编辑中被快照重建;写盘成功后核 `config-io`
10
+ * `saveRaw` 同步回调 `onConfigSelfWrite`(`config-io.mjs:107`)⇒ `noteSelfWrite()` 把基线刷到当前
11
+ * 元组 ⇒ 事件到达时元组已等于基线 ⇒ 零推送;外部写无回调 ⇒ 元组异于基线 ⇒ 推送。
12
+ *
13
+ * 平台面两注入(核内零宿主绑定):① `attach`(fs 事件源工厂——端落子:VSC = `createFileSystemWatcher`、
14
+ * 桌面 = `node:fs.watch`)② `setTimer` ∕ `clearTimer`(定时器;缺省 = 全局)。`tupleOf`(stat 探针)缺省 =
15
+ * 本核 `statSync` 实现(两端宿主皆 Node);`subscribeSelfWrite` 缺省 = 核自写订阅。
16
+ * 降级(无 path ∕ `attach` 缺失 ∕ 构造抛错 ∕ 返回空)⇒ no-op 面(不阻断端激活——面板打开拉新的既有路径兜底)。
17
+ * 装配序微差(对源件):事件订阅随 `attach` 一次落(源件 = 构造 → 基线 → 订阅三事件)——订阅更早,
18
+ * 不丢事件;基线仍于 `attach` 后取(同步段内零窗口)。零语义差。
19
+ */
20
+ import { statSync } from "node:fs"
21
+ import { _configPath, onConfigSelfWrite } from "./config-io.mjs"
22
+
23
+ /** stat 元组(mtimeMs + size——同 tick 快写 mtime 可同,size 兜底);缺失 ∕ 不可读 ⇒ null。 */
24
+ export function configTupleOf(path) {
25
+ try {
26
+ const s = statSync(path)
27
+ return `${s.mtimeMs}:${s.size}`
28
+ } catch { return null }
29
+ }
30
+
31
+ /**
32
+ * 建配置监视面(纯逻辑)。
33
+ * @param {{ configPath?: string, onChange?: () => void, debounceMs?: number,
34
+ * attach?: (arg: { path: string, onEvent: () => void }) => { dispose?: () => void } | null,
35
+ * tupleOf?: (path: string) => string | null,
36
+ * setTimer?: (fn: () => void, ms: number) => unknown, clearTimer?: (handle: unknown) => void,
37
+ * subscribeSelfWrite?: (fn: () => void) => (() => void) | void }} [opts]
38
+ * `configPath` 缺省 = 核 `_configPath()` 当前值(测试缝同源);`attach` = 平台落子(fs 事件源)。
39
+ * @returns {{ dispose: () => void, noteSelfWrite: () => void }} 降级时返回 no-op(不阻断激活)。
40
+ */
41
+ export function createConfigWatch({
42
+ configPath, onChange, debounceMs = 300, attach, tupleOf = configTupleOf,
43
+ setTimer = setTimeout, clearTimer = clearTimeout, subscribeSelfWrite = onConfigSelfWrite,
44
+ } = {}) {
45
+ const noop = { dispose() {}, noteSelfWrite() {} }
46
+ const path = configPath || _configPath()
47
+ if (!path) return noop
48
+ if (typeof attach !== "function") return noop
49
+
50
+ let baseline = null
51
+ let timer = null
52
+ /** 元组比对(去抖落点):未变 → 零推送(隐藏 N5 判据保持);变 ⇒ 基线刷新 + `onChange`(抛不炸宿主)。 */
53
+ const check = () => {
54
+ timer = null
55
+ const now = tupleOf(path)
56
+ if (now === baseline) return
57
+ baseline = now // 比对后基线更新为当前元组
58
+ try { onChange?.() } catch (e) { console.warn(`[config-watch] onChange failed: ${e.message}`) }
59
+ }
60
+ /** 去抖(同拍多事件合并——窗口内重排)。 */
61
+ const schedule = () => {
62
+ if (timer) clearTimer(timer)
63
+ timer = setTimer(check, debounceMs)
64
+ }
65
+
66
+ let listener
67
+ try { listener = attach({ path, onEvent: schedule }) } catch { return noop }
68
+ if (!listener) return noop
69
+ baseline = tupleOf(path)
70
+ const noteSelfWrite = () => { baseline = tupleOf(path) }
71
+
72
+ let unsubSelfWrite = null
73
+ try { unsubSelfWrite = subscribeSelfWrite(noteSelfWrite) } catch { /* 订阅失败仅弱化自写抑制 */ }
74
+
75
+ return {
76
+ noteSelfWrite,
77
+ dispose() {
78
+ try { listener.dispose?.() } catch { /* already disposed */ }
79
+ if (timer) { clearTimer(timer); timer = null }
80
+ try { unsubSelfWrite?.() } catch { /* ignore */ }
81
+ },
82
+ }
83
+ }
package/config.mjs CHANGED
@@ -48,7 +48,7 @@ export const DEFAULTS = {
48
48
  streamRules: [], // time-traveling stream rules: [{ pattern: "regex", message: "reminder", action: "abort"|"warn", repeat: "always"|"once" }]
49
49
  advisor: { guard: false }, // code review is always available; guard: true pushes completion back until reviewed (opt-in). Also accepts provider/model/thinking/reasoningEffort/timeoutMs overrides. Deprecated: enabled (2026-08-21)
50
50
  autoThink: false, // auto-classify task difficulty and set reasoning effort per-turn
51
- engineering: false, // strict methodology enforcement — design-before-code (design review + user approval before code)
51
+ engineering: false, // initial default only — the runtime value is the session authority (MANIFEST.md §2.2 / ENGINEERING-MODE-V2.md §2.3 E5.1). Strict methodology enforcement — design-before-code (design review + user approval before code)
52
52
  // Async pool limits (AGENT-LOOP-ASYNC-POOL.md §6.10 D-24a/R14 + R13 — POOL-CONFIG-
53
53
  // UNIFIED 2026-09-09): { engCoder, other, advisor } — eng-coder pool / other-role
54
54
  // pool / advisor-review pool, defaults 4/4/4 (user ruling "eng-coder 四路,其他
@@ -61,6 +61,9 @@ export const DEFAULTS = {
61
61
  // ADVISOR_POOL_LIMIT 逐键同值(运行时回退常量)——耦合锚 T-24a4 断言锁住——
62
62
  // 勿单侧改默认。
63
63
  poolLimits: { engCoder: 4, other: 4, advisor: 4 },
64
+ // §6.30.3 门三件③(D-TW5):timer 空闲自唤醒开关——默认开(工具描述面已承诺到点提醒);
65
+ // false ⇒ 端侧不武装闩(回既有回合边界投递——零调度器面)。键面随 DEFAULTS 派生进 settings 类型表。
66
+ timerWake: true,
64
67
  },
65
68
  memory: {
66
69
  dbPath: join(configDir, "memory.db"),
@@ -83,6 +86,10 @@ export const DEFAULTS = {
83
86
  enabled: false, // §18.6 D-TR6 修订(2026-09-05 用户裁定——发布隐私:"不希望用户那边也采集"):轨迹存档默认 OFF——新用户零采集;本地调试分析可显式开(~/.thincoder/config.json traces.enabled:true)
84
87
  retentionHours: 24, // D-TR10:轨迹文件保留小时数——CLI 启动时删除超过该时长的文件(默认 24h)
85
88
  },
89
+ // 诊断开关(用户面 ① 配置键——CONFIG.md §6.2 名录):`heapWatch` 默认开(设 false 关闭)——自愈臂
90
+ // 遥测;`heapSnapshot` 默认关——采集 opt-in(堆转储敏感度 ≥ traces ⇒ 对齐发布隐私先例 D-TR6;
91
+ // 2026-09-30 采集收网批;桌面端运行期热读 ∥ CLI 本进程下次启动)
92
+ diagnostics: { heapWatch: true, heapSnapshot: false },
86
93
  }
87
94
 
88
95
  // Model capability table + spec lookup live in model-specs.mjs (2026-08-31
@@ -127,11 +134,12 @@ export function isBailianHost(baseURL) {
127
134
  * provider.reasoningEffort → true (effort tier implies thinking on; rides with reasoning_effort)
128
135
  * otherwise → undefined (field omitted — server default stays, no behavior change)
129
136
  * NOTE: spec carries no model field today — the name comes from provider.model (spec?.model is
130
- * a forward-compatible fallback). Keep the body byte-aligned with thincoder-vscode config.mjs
131
- * (cross-repo parity test compares them).
137
+ * a forward-compatible fallback). 非串 model(脏载 / 手工档)归一等价空串(未登记 ⇒ undefined)——
138
+ * 本函数恒 total、不因类型脏载抛(#638 查表族同归一)。
132
139
  */
133
140
  export function resolveEnableThinking(provider, spec) {
134
- const model = (provider?.model ?? spec?.model ?? "").toLowerCase()
141
+ const raw = provider?.model ?? spec?.model ?? ""
142
+ const model = (typeof raw === "string" ? raw : "").toLowerCase()
135
143
  if (!model.startsWith("qwen") || model.startsWith("qwen3-coder")) return undefined
136
144
  if (!isBailianHost(provider?.baseURL)) return undefined
137
145
  if (provider.thinking === null) return false
@@ -257,6 +265,7 @@ export function loadConfig() {
257
265
  memory: { ...DEFAULTS.memory, ...config.memory },
258
266
  embedding: { ...DEFAULTS.embedding, ...config.embedding },
259
267
  traces: { ...DEFAULTS.traces, ...config.traces },
268
+ diagnostics: { ...DEFAULTS.diagnostics },
260
269
  }
261
270
 
262
271
  // 家目录展开(第 29 批):config 路径字段单一规范化点——只读归一(磁盘原文保留)
@@ -268,6 +277,10 @@ export function loadConfig() {
268
277
  }
269
278
  merged.shell = expandHome(merged.shell)
270
279
 
280
+ // diagnostics(① 配置键,CONFIG.md §6.2 名录):两键只认布尔值——非法 / 缺失回退默认(不 throw)
281
+ const _diagRaw = config.diagnostics && typeof config.diagnostics === "object" && !Array.isArray(config.diagnostics) ? config.diagnostics : {}
282
+ for (const key of ["heapWatch", "heapSnapshot"]) if (typeof _diagRaw[key] === "boolean") merged.diagnostics[key] = _diagRaw[key]
283
+
271
284
  // providers[].model 内存归一(v2 M3:非空字符串保留;非字符串/空串归一删除)——渠道默认模型
272
285
  // 单值;无默认模型合法(模型选择经 /models 拉取候选——准入判据见 M8/M9)。
273
286
  for (const p of merged.providers) {
@@ -0,0 +1,81 @@
1
+ /**
2
+ * context-degrade.mjs — 确定性降级面(无 LLM 的有损收缩 · 自 `context.mjs` 迁出 · 2026-10-01 core 拆分批 #755 ∥ #786)。
3
+ * 内容 = `pruneStub` ∥ `pruneStaleToolOutputs`(F-CC3 陈旧工具输出清理)∥ `OVERSIZE_CONTENT_LIMIT` ∥
4
+ * `shrinkOversized`(splitHistory 无中间段时的单条超限收缩)——迁出块逐字。
5
+ * 原档 `context.mjs` 经 `export { … } from` 转口保名(消费面 / 批内件 import 面零改);
6
+ * 跨档被引的原私有面(`shrinkOversized`)补 `export` 关键字。
7
+ */
8
+ import { collectStaleToolOutputs } from "./token-window.mjs"
9
+
10
+ /** prune stub 逐字(§6.16.3):只给「已清理 + 原长度 + 重跑路径」——prune 是**删**不是摘要,不声称可复原。 */
11
+ const pruneStub = (chars) => `[pruned: stale tool output dropped (${chars} chars) — re-run the tool if you need it again.]`
12
+
13
+ /**
14
+ * 陈旧工具输出清理(F-CC3 · §6.16.3):合格集 = `token-window.mjs` 单源(保护尾外 ∧ `role:"tool"` ∧ ≥ 门槛);
15
+ * 命中项**原位换「内容」**(`history[i] = { ...m, content: stub }`——数组引用 / 长度 / 索引 / `tool_call_id` 全不变
16
+ * ⇒ 配对**结构上不可能被拆**)。记录面零改(copy-on-write:消息对象与人读线共享);基线失效同 `shrinkOversized` 先例。
17
+ * @returns {{pruned:number, freed:number, candidates:number, tailKept:number, belowMin:number}}
18
+ */
19
+ export function pruneStaleToolOutputs(agent) {
20
+ const history = agent.history
21
+ const stale = collectStaleToolOutputs(history, agent.provider)
22
+ const counts = {
23
+ pruned: stale.indexes.length,
24
+ freed: stale.tokens,
25
+ candidates: stale.candidates,
26
+ tailKept: stale.tailKept,
27
+ belowMin: stale.belowMin,
28
+ }
29
+ if (counts.pruned === 0) return counts
30
+ for (const i of stale.indexes) {
31
+ const m = history[i]
32
+ // 多模态 tool 结果(content 数组)整体替换为 stub ⇒ 图像 part 丢弃(不可再取——prune 是删;回执只给重跑路径)
33
+ const chars = typeof m.content === "string" ? m.content.length : JSON.stringify(m.content ?? "").length
34
+ history[i] = { ...m, content: pruneStub(chars) }
35
+ }
36
+ agent._lastPromptTokens = null
37
+ agent._usageAtLen = null
38
+ return counts
39
+ }
40
+
41
+ /** Hard truncation limit for a single message body: when exceeded and the splitter can't find a middle section, truncate to a stub (prevents one giant message from blocking compaction) */
42
+ const OVERSIZE_CONTENT_LIMIT = 8_000
43
+
44
+ /**
45
+ * Deterministic shrinking: last resort when splitHistory can't find a middle section (history too short) but threshold is exceeded. No LLM call.
46
+ * Truncates user/tool message bodies exceeding OVERSIZE_CONTENT_LIMIT to a stub (keeps head + tail);
47
+ * does not touch reasoning_content (DeepSeek/Kimi echo protocol) or tool_calls pairing structure — no protocol 400
48
+ * risk from this path (this module's echo-safety face is `applyCompression`: the compaction placeholder is never
49
+ * committed as a separate reasoning-less assistant next to another assistant — D-CC18, 2026-09-16 batch).
50
+ * Only called after compressIfNeeded determines threshold is exceeded. Returns whether any message was truncated.
51
+ */
52
+ export function shrinkOversized(agent, limit = OVERSIZE_CONTENT_LIMIT) {
53
+ let shrunk = false
54
+ // Copy-on-write: build a NEW array and replace only truncated entries. pushReal stores the SAME
55
+ // message object in both `agent.history` (machine line) and `agent._fullHistory` (human/persistence
56
+ // line), so in-place `m.content = ...` would ALSO truncate the never-compacted human line and lose
57
+ // the original pasted content on session persist (session.mjs persists _fullHistory). VS Code port
58
+ // already copies (`history.map(m => ({ ...m }))`); this brings CLI to parity.
59
+ const next = agent.history.map((m) => {
60
+ if ((m.role !== "user" && m.role !== "tool") || typeof m.content !== "string") return m
61
+ if (m.content.length <= limit) return m
62
+ // Truncate keeping head + tail, insert stub in between; keepHead/keepTail proportional but not exceeding 50%/25% of limit
63
+ const keepHead = Math.min(Math.floor(limit * 0.5), 4000)
64
+ const keepTail = Math.min(Math.floor(limit * 0.25), 2000)
65
+ shrunk = true
66
+ return {
67
+ ...m,
68
+ content:
69
+ m.content.slice(0, keepHead) +
70
+ `\n[... ${m.content.length - keepHead - keepTail} chars truncated — single message too large for context window ...]\n` +
71
+ m.content.slice(-keepTail),
72
+ }
73
+ })
74
+ if (shrunk) {
75
+ agent.history = next
76
+ // Same as compaction: measured token baseline is invalidated by the changed history, fall back to estimation until next response
77
+ agent._lastPromptTokens = null
78
+ agent._usageAtLen = null
79
+ }
80
+ return shrunk
81
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * context-echo.mjs — 回声安全面(D-CC18 ∥ D-CC19 恢复面归并 · 自 `context.mjs` 迁出 · 2026-10-01 core 拆分批 #755 ∥ #786)。
3
+ * 内容 = `COMPACTION_PLACEHOLDER` ∥ 前缀器族(`prefixContent` / `withPlaceholderPrefix`)∥ 回声对归并
4
+ * (`isAssistantEchoPair` / `contentTextOf` / `absorbEchoContent` / `mergeAdjacentAssistantEchoes`)——迁出块逐字。
5
+ * 原档 `context.mjs` 经 `export { … } from` 转口保名(消费面 / 批内件 import 面零改);
6
+ * 跨档被引的原私有面(`COMPACTION_PLACEHOLDER` ∥ `withPlaceholderPrefix`)补 `export` 关键字。
7
+ */
8
+
9
+ /** Placeholder assistant reply committed right after compaction (D9); D-CC18 merges it into an adjacent tail assistant instead of emitting it as a separate message */
10
+ export const COMPACTION_PLACEHOLDER = "Understood. I'll continue from these notes, re-verifying anything transient."
11
+
12
+ /**
13
+ * Content-shape-safe prefixing (D-CC18, generalized for D-CC19 merge reuse):
14
+ * string → text + blank line + original; multimodal array → text part prepended;
15
+ * empty string / null / undefined / other → text alone.
16
+ */
17
+ function prefixContent(content, text) {
18
+ if (typeof content === "string" && content.length > 0) return `${text}\n\n${content}`
19
+ if (Array.isArray(content)) return [{ type: "text", text }, ...content]
20
+ return text
21
+ }
22
+
23
+ /**
24
+ * D-CC18 echo safety: prefix the placeholder onto an existing assistant message's content.
25
+ * Thin wrapper over prefixContent (behavioral semantics unchanged).
26
+ */
27
+ export function withPlaceholderPrefix(content) {
28
+ return prefixContent(content, COMPACTION_PLACEHOLDER)
29
+ }
30
+
31
+ /**
32
+ * D-CC19 restore-path echo merge (2026-09-16 ENGINE-DEBT 批 ED-1): persisted `contextHistory`
33
+ * is loaded back VERBATIM on session restore, so the D-CC18 pathological shape (an assistant
34
+ * WITHOUT reasoning_content directly followed by another assistant — DeepSeek-family thinking
35
+ * mode rejects the first request with 400) can revive from disk. Pure in-core function: scans
36
+ * only at restore time, never prompts the user, never rewrites the session file. Merge direction
37
+ * matches D-CC18 (the reasoning-less message is absorbed INTO its follower — the follower's
38
+ * tool_calls / reasoning_content / other fields are kept verbatim; texts joined with a blank
39
+ * line). Iterates to a fixed point (chains collapse in full). Copy-on-write: messages may be
40
+ * shared with other lines, so the merged message is always a NEW object; clean input returns
41
+ * the SAME array reference (zero copy).
42
+ */
43
+
44
+ /** Pair predicate: prev = assistant with no/empty reasoning_content and no tool_calls,
45
+ * directly followed by another assistant. (Prev WITH tool_calls is never merged — pairing
46
+ * safety, F-3.) */
47
+ export function isAssistantEchoPair(prev, next) {
48
+ return prev?.role === "assistant"
49
+ && next?.role === "assistant"
50
+ && !prev.reasoning_content
51
+ && !(Array.isArray(prev.tool_calls) && prev.tool_calls.length > 0)
52
+ }
53
+
54
+ /** Text of a message content in any supported shape (string / parts array / null-ish). */
55
+ function contentTextOf(content) {
56
+ if (typeof content === "string") return content
57
+ if (Array.isArray(content)) {
58
+ return content.filter((p) => p?.type === "text").map((p) => p.text ?? "").join("\n\n")
59
+ }
60
+ return ""
61
+ }
62
+
63
+ /** Absorb prev's text into next's content (blank-line join; empty prev text → next unchanged). */
64
+ function absorbEchoContent(prevContent, nextContent) {
65
+ const text = contentTextOf(prevContent)
66
+ if (text.length === 0) return nextContent
67
+ return prefixContent(nextContent, text)
68
+ }
69
+
70
+ export function mergeAdjacentAssistantEchoes(history) {
71
+ if (!Array.isArray(history)) return history
72
+ let src = history
73
+ for (;;) {
74
+ let changed = false
75
+ const next = []
76
+ for (let i = 0; i < src.length; i++) {
77
+ if (i + 1 < src.length && isAssistantEchoPair(src[i], src[i + 1])) {
78
+ next.push({ ...src[i + 1], content: absorbEchoContent(src[i].content, src[i + 1].content) })
79
+ i += 1
80
+ changed = true
81
+ } else {
82
+ next.push(src[i])
83
+ }
84
+ }
85
+ if (!changed) return src === history ? history : src
86
+ src = next
87
+ }
88
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * context-push.mjs — 消息双线写入单点(写缝面 · 自 `context.mjs` 迁出 · 2026-10-01 core 拆分批 #755 ∥ #786)。
3
+ * `pushReal` = 真实消息写缝(机器线 `agent.history` + 人读线 `agent._fullHistory` 同源双写);
4
+ * `pushRecord` = 人读线留档记录单点(机器线零触)。迁出块逐字;原档 `context.mjs` 经
5
+ * `export { … } from` 转口保名(消费面 / 批内件 import 面零改)。
6
+ */
7
+
8
+ /**
9
+ * pushReal — the single entry point for REAL conversation messages.
10
+ * A real message (user input, assistant reply, tool result, multimodal image) is appended to BOTH:
11
+ * agent.history — the machine context (compaction shrinks this)
12
+ * agent._fullHistory — the human-readable record (persistence source)
13
+ * Machine-only messages ([System reminder:...], compaction notes, task/plan/checkpoint re-injections)
14
+ * are pushed directly to agent.history WITHOUT going through here, so they never enter _fullHistory.
15
+ * The two lines are written independently at the source — no after-the-fact delta sync.
16
+ * Message timestamps (SESSION.md §6.9): stamped HERE once at push time (epoch ms) — a single
17
+ * point covers every real message. Pre-existing ts (e.g. from another end writing the shared slot)
18
+ * is preserved; restored old messages keep no ts rather than getting a misleading backdate (D-S3).
19
+ * ts is a LOCAL-ONLY field — the send layer strips it before any provider request (T-S3).
20
+ *
21
+ * TUI-OOM-ROOTCAUSE 批(SESSION.md §6.14)——人读线内存有界 + 磁盘为准:
22
+ * ① `agent._recordStore?.append(msg)`:记录同步追加(磁盘为准——append-only sidecar);
23
+ * ② 窗口驱逐:绑定态(agent._historyWindow = 200)下 _fullHistory 只保最近窗口条——
24
+ * 更早内容仅存磁盘(翻页/检索/保存从盘按需读)。未绑定(模式 F)不驱逐(零回归)。
25
+ * 追加失败不阻断回合(独立 try/catch——尽力面 N-S6;store 内部另置 degraded 并停写)。
26
+ */
27
+ export function pushReal(agent, msg) {
28
+ if (!Array.isArray(agent._fullHistory)) agent._fullHistory = []
29
+ if (msg && msg.ts === undefined) msg.ts = Date.now()
30
+ agent._fullHistory.push(msg)
31
+ try { agent._recordStore?.append(msg) } catch { /* 尽力面:落盘失败不阻断回合(N-S6) */ }
32
+ const win = agent._historyWindow
33
+ if (win > 0 && agent._fullHistory.length > win) {
34
+ agent._fullHistory.splice(0, agent._fullHistory.length - win)
35
+ }
36
+ agent.history.push(msg)
37
+ }
38
+
39
+ /**
40
+ * pushRecord — 人读线留档记录单点(消化生命周期面 · #726;形 ∥ 写缝 ∥ 判据单源 = SESSION.md §6.26)。
41
+ * `pushReal` 双胞(直复用——零算法副本):`ts` 打点 ∥ 记录存储追加(`_recordStore?.append`——§6.14)∥
42
+ * 尾窗驱逐三面同源;**机器线零触** = `history` 以一次性弃数组承接 ⇒ 记录不入 `agent.history` /
43
+ * `contextHistory`(不喂模型)。未绑定(`_recordStore` 缺)⇒ 人读线追加照常 ∥ 存储腿空转(零抛——模式 F
44
+ * 零回归);载体缺位(无活跃会话)⇒ 零动作(零抛——日志归端侧)。
45
+ */
46
+ export function pushRecord(agent, record) {
47
+ if (!agent || typeof agent !== "object") return // 载体缺位:零动作(零抛——§6.26 失败面)
48
+ if (!Array.isArray(agent._fullHistory)) agent._fullHistory = []
49
+ // 载体形人读线半提取(先例 = 桌面 `session-io.appendRecord`):`history` 弃数组承接 ⇒ 机器线零触
50
+ pushReal({ _fullHistory: agent._fullHistory, _recordStore: agent._recordStore, _historyWindow: agent._historyWindow, history: [] }, record)
51
+ }
package/context.mjs CHANGED
@@ -13,7 +13,9 @@
13
13
 
14
14
  import { chat } from "./provider/index.mjs"
15
15
  import { buildCompressMessages } from "./compress-form.mjs"
16
- import { contextUsage, estimateTokens, collectStaleToolOutputs, keepTailSize, splitHistory, tailBudgetTokens } from "./token-window.mjs"
16
+ import { contextUsage, estimateTokens, keepTailSize, splitHistory, tailBudgetTokens } from "./token-window.mjs"
17
+ import { COMPACTION_PLACEHOLDER, withPlaceholderPrefix } from "./context-echo.mjs"
18
+ import { shrinkOversized } from "./context-degrade.mjs"
17
19
 
18
20
  export const SUMMARIZE_PROMPT = `The conversation above is our work log so far — summarize it into a compact summary for use as context in the ongoing conversation.
19
21
  Requirements:
@@ -34,9 +36,6 @@ const COMPACTION_PREFIX =
34
36
  "Treat it as notes, not proof — trust its conclusions (don't redo what it reports as done) " +
35
37
  "but re-verify transient state with tools. Check memory search for any missing decisions.]\n\n"
36
38
 
37
- /** Placeholder assistant reply committed right after compaction (D9); D-CC18 merges it into an adjacent tail assistant instead of emitting it as a separate message */
38
- const COMPACTION_PLACEHOLDER = "Understood. I'll continue from these notes, re-verifying anything transient."
39
-
40
39
  /** After this many consecutive compaction summary failures, degrade to deterministic truncation (losing info is better than task-killing 400 errors) */
41
40
  export const COMPRESS_FAILURE_LIMIT = 3
42
41
 
@@ -71,115 +70,6 @@ function anchorText(agent) {
71
70
  return lines.length > 0 ? lines.join("\n") : null
72
71
  }
73
72
 
74
- /**
75
- * pushReal — the single entry point for REAL conversation messages.
76
- * A real message (user input, assistant reply, tool result, multimodal image) is appended to BOTH:
77
- * agent.history — the machine context (compaction shrinks this)
78
- * agent._fullHistory — the human-readable record (persistence source)
79
- * Machine-only messages ([System reminder:...], compaction notes, task/plan/checkpoint re-injections)
80
- * are pushed directly to agent.history WITHOUT going through here, so they never enter _fullHistory.
81
- * The two lines are written independently at the source — no after-the-fact delta sync.
82
- * Message timestamps (SESSION.md §6.9): stamped HERE once at push time (epoch ms) — a single
83
- * point covers every real message. Pre-existing ts (e.g. from another end writing the shared slot)
84
- * is preserved; restored old messages keep no ts rather than getting a misleading backdate (D-S3).
85
- * ts is a LOCAL-ONLY field — the send layer strips it before any provider request (T-S3).
86
- *
87
- * TUI-OOM-ROOTCAUSE 批(SESSION.md §6.14)——人读线内存有界 + 磁盘为准:
88
- * ① `agent._recordStore?.append(msg)`:记录同步追加(磁盘为准——append-only sidecar);
89
- * ② 窗口驱逐:绑定态(agent._historyWindow = 200)下 _fullHistory 只保最近窗口条——
90
- * 更早内容仅存磁盘(翻页/检索/保存从盘按需读)。未绑定(模式 F)不驱逐(零回归)。
91
- * 追加失败不阻断回合(独立 try/catch——尽力面 N-S6;store 内部另置 degraded 并停写)。
92
- */
93
- export function pushReal(agent, msg) {
94
- if (!Array.isArray(agent._fullHistory)) agent._fullHistory = []
95
- if (msg && msg.ts === undefined) msg.ts = Date.now()
96
- agent._fullHistory.push(msg)
97
- try { agent._recordStore?.append(msg) } catch { /* 尽力面:落盘失败不阻断回合(N-S6) */ }
98
- const win = agent._historyWindow
99
- if (win > 0 && agent._fullHistory.length > win) {
100
- agent._fullHistory.splice(0, agent._fullHistory.length - win)
101
- }
102
- agent.history.push(msg)
103
- }
104
-
105
- /**
106
- * Content-shape-safe prefixing (D-CC18, generalized for D-CC19 merge reuse):
107
- * string → text + blank line + original; multimodal array → text part prepended;
108
- * empty string / null / undefined / other → text alone.
109
- */
110
- function prefixContent(content, text) {
111
- if (typeof content === "string" && content.length > 0) return `${text}\n\n${content}`
112
- if (Array.isArray(content)) return [{ type: "text", text }, ...content]
113
- return text
114
- }
115
-
116
- /**
117
- * D-CC18 echo safety: prefix the placeholder onto an existing assistant message's content.
118
- * Thin wrapper over prefixContent (behavioral semantics unchanged).
119
- */
120
- function withPlaceholderPrefix(content) {
121
- return prefixContent(content, COMPACTION_PLACEHOLDER)
122
- }
123
-
124
- /**
125
- * D-CC19 restore-path echo merge (2026-09-16 ENGINE-DEBT 批 ED-1): persisted `contextHistory`
126
- * is loaded back VERBATIM on session restore, so the D-CC18 pathological shape (an assistant
127
- * WITHOUT reasoning_content directly followed by another assistant — DeepSeek-family thinking
128
- * mode rejects the first request with 400) can revive from disk. Pure in-core function: scans
129
- * only at restore time, never prompts the user, never rewrites the session file. Merge direction
130
- * matches D-CC18 (the reasoning-less message is absorbed INTO its follower — the follower's
131
- * tool_calls / reasoning_content / other fields are kept verbatim; texts joined with a blank
132
- * line). Iterates to a fixed point (chains collapse in full). Copy-on-write: messages may be
133
- * shared with other lines, so the merged message is always a NEW object; clean input returns
134
- * the SAME array reference (zero copy).
135
- */
136
-
137
- /** Pair predicate: prev = assistant with no/empty reasoning_content and no tool_calls,
138
- * directly followed by another assistant. (Prev WITH tool_calls is never merged — pairing
139
- * safety, F-3.) */
140
- export function isAssistantEchoPair(prev, next) {
141
- return prev?.role === "assistant"
142
- && next?.role === "assistant"
143
- && !prev.reasoning_content
144
- && !(Array.isArray(prev.tool_calls) && prev.tool_calls.length > 0)
145
- }
146
-
147
- /** Text of a message content in any supported shape (string / parts array / null-ish). */
148
- function contentTextOf(content) {
149
- if (typeof content === "string") return content
150
- if (Array.isArray(content)) {
151
- return content.filter((p) => p?.type === "text").map((p) => p.text ?? "").join("\n\n")
152
- }
153
- return ""
154
- }
155
-
156
- /** Absorb prev's text into next's content (blank-line join; empty prev text → next unchanged). */
157
- function absorbEchoContent(prevContent, nextContent) {
158
- const text = contentTextOf(prevContent)
159
- if (text.length === 0) return nextContent
160
- return prefixContent(nextContent, text)
161
- }
162
-
163
- export function mergeAdjacentAssistantEchoes(history) {
164
- if (!Array.isArray(history)) return history
165
- let src = history
166
- for (;;) {
167
- let changed = false
168
- const next = []
169
- for (let i = 0; i < src.length; i++) {
170
- if (i + 1 < src.length && isAssistantEchoPair(src[i], src[i + 1])) {
171
- next.push({ ...src[i + 1], content: absorbEchoContent(src[i].content, src[i + 1].content) })
172
- i += 1
173
- changed = true
174
- } else {
175
- next.push(src[i])
176
- }
177
- }
178
- if (!changed) return src === history ? history : src
179
- src = next
180
- }
181
- }
182
-
183
73
  /** Replace middle with a note, then re-inject task/plan state (shared by LLM summary and truncation fallback) */
184
74
  function applyCompression(agent, headEnd, tailStart, note) {
185
75
  // _fullHistory already holds every real message (written at the source via pushReal),
@@ -314,7 +204,7 @@ export async function compressIfNeeded(agent, threshold, callbacks, extras = {},
314
204
  messages,
315
205
  tools: extras?.tools,
316
206
  signal,
317
- // §18.6 D-TR4:轨迹元数据增补——kind=compress(上下文构建面——agent 元数据透出;
207
+ // TRACES.md §6.1 D-TR4:轨迹元数据增补——kind=compress(上下文构建面——agent 元数据透出;
318
208
  // depth 经 extras.traceDepth——agent.mjs 主作用域传入——compress 调用点补齐)
319
209
  logCtx: {
320
210
  stage: "compress", child: agent._logId, kind: "compress",
@@ -342,37 +232,6 @@ export async function compressIfNeeded(agent, threshold, callbacks, extras = {},
342
232
  return true
343
233
  }
344
234
 
345
- /** prune stub 逐字(§6.16.3):只给「已清理 + 原长度 + 重跑路径」——prune 是**删**不是摘要,不声称可复原。 */
346
- const pruneStub = (chars) => `[pruned: stale tool output dropped (${chars} chars) — re-run the tool if you need it again.]`
347
-
348
- /**
349
- * 陈旧工具输出清理(F-CC3 · §6.16.3):合格集 = `token-window.mjs` 单源(保护尾外 ∧ `role:"tool"` ∧ ≥ 门槛);
350
- * 命中项**原位换「内容」**(`history[i] = { ...m, content: stub }`——数组引用 / 长度 / 索引 / `tool_call_id` 全不变
351
- * ⇒ 配对**结构上不可能被拆**)。记录面零改(copy-on-write:消息对象与人读线共享);基线失效同 `shrinkOversized` 先例。
352
- * @returns {{pruned:number, freed:number, candidates:number, tailKept:number, belowMin:number}}
353
- */
354
- export function pruneStaleToolOutputs(agent) {
355
- const history = agent.history
356
- const stale = collectStaleToolOutputs(history, agent.provider)
357
- const counts = {
358
- pruned: stale.indexes.length,
359
- freed: stale.tokens,
360
- candidates: stale.candidates,
361
- tailKept: stale.tailKept,
362
- belowMin: stale.belowMin,
363
- }
364
- if (counts.pruned === 0) return counts
365
- for (const i of stale.indexes) {
366
- const m = history[i]
367
- // 多模态 tool 结果(content 数组)整体替换为 stub ⇒ 图像 part 丢弃(不可再取——prune 是删;回执只给重跑路径)
368
- const chars = typeof m.content === "string" ? m.content.length : JSON.stringify(m.content ?? "").length
369
- history[i] = { ...m, content: pruneStub(chars) }
370
- }
371
- agent._lastPromptTokens = null
372
- agent._usageAtLen = null
373
- return counts
374
- }
375
-
376
235
  /**
377
236
  * Deterministic truncation fallback: called when the summary LLM fails repeatedly, no network call.
378
237
  * Drops the middle so the task can continue. Returns whether truncation happened.
@@ -389,50 +248,12 @@ export function compressFallback(agent) {
389
248
  return true
390
249
  }
391
250
 
392
- /** Hard truncation limit for a single message body: when exceeded and the splitter can't find a middle section, truncate to a stub (prevents one giant message from blocking compaction) */
393
- const OVERSIZE_CONTENT_LIMIT = 8_000
394
-
395
- /**
396
- * Deterministic shrinking: last resort when splitHistory can't find a middle section (history too short) but threshold is exceeded. No LLM call.
397
- * Truncates user/tool message bodies exceeding OVERSIZE_CONTENT_LIMIT to a stub (keeps head + tail);
398
- * does not touch reasoning_content (DeepSeek/Kimi echo protocol) or tool_calls pairing structure — no protocol 400
399
- * risk from this path (this module's echo-safety face is `applyCompression`: the compaction placeholder is never
400
- * committed as a separate reasoning-less assistant next to another assistant — D-CC18, 2026-09-16 batch).
401
- * Only called after compressIfNeeded determines threshold is exceeded. Returns whether any message was truncated.
402
- */
403
- function shrinkOversized(agent, limit = OVERSIZE_CONTENT_LIMIT) {
404
- let shrunk = false
405
- // Copy-on-write: build a NEW array and replace only truncated entries. pushReal stores the SAME
406
- // message object in both `agent.history` (machine line) and `agent._fullHistory` (human/persistence
407
- // line), so in-place `m.content = ...` would ALSO truncate the never-compacted human line and lose
408
- // the original pasted content on session persist (session.mjs persists _fullHistory). VS Code port
409
- // already copies (`history.map(m => ({ ...m }))`); this brings CLI to parity.
410
- const next = agent.history.map((m) => {
411
- if ((m.role !== "user" && m.role !== "tool") || typeof m.content !== "string") return m
412
- if (m.content.length <= limit) return m
413
- // Truncate keeping head + tail, insert stub in between; keepHead/keepTail proportional but not exceeding 50%/25% of limit
414
- const keepHead = Math.min(Math.floor(limit * 0.5), 4000)
415
- const keepTail = Math.min(Math.floor(limit * 0.25), 2000)
416
- shrunk = true
417
- return {
418
- ...m,
419
- content:
420
- m.content.slice(0, keepHead) +
421
- `\n[... ${m.content.length - keepHead - keepTail} chars truncated — single message too large for context window ...]\n` +
422
- m.content.slice(-keepTail),
423
- }
424
- })
425
- if (shrunk) {
426
- agent.history = next
427
- // Same as compaction: measured token baseline is invalidated by the changed history, fall back to estimation until next response
428
- agent._lastPromptTokens = null
429
- agent._usageAtLen = null
430
- }
431
- return shrunk
432
- }
433
-
434
251
  // ─── 迁出面(import 面保持:TUI / verify-compress / VSC 对拍经本档取——先例 = 下方 explore-distill 再导出)──
435
252
  export { estimateTokens }
253
+ // ─── 拆分转口(2026-10-01 拆分批 · #755 ∥ #786):写缝 ∥ 回声 ∥ 降级三面外提——消费面 / 批内件 import 面零改 ───
254
+ export { pushReal, pushRecord } from "./context-push.mjs"
255
+ export { isAssistantEchoPair, mergeAdjacentAssistantEchoes } from "./context-echo.mjs"
256
+ export { pruneStaleToolOutputs } from "./context-degrade.mjs"
436
257
  // ─── End-of-run exploration distillation(2026-09-05 module-split:524 > 500 硬限——verbatim
437
258
  // 迁至 explore-distill.mjs,语义零变——VS Code compact.mjs 同款联动;cross-repo parity 锚改指
438
259
  // explore-distill.mjs——消费方 import 面不变(re-export))───────────────────────