thincoder 0.12.62 → 0.12.64

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 (231) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +46 -49
  3. package/bin/thincoder.mjs +68 -34
  4. package/package.json +7 -6
  5. package/src/acp/bridge.mjs +35 -15
  6. package/src/acp/client-caps.mjs +86 -0
  7. package/src/acp/ext.mjs +86 -0
  8. package/src/acp/handlers-session.mjs +257 -0
  9. package/src/acp/handlers-slots.mjs +196 -0
  10. package/src/acp/login.mjs +48 -0
  11. package/src/acp/session.mjs +6 -4
  12. package/src/acp.mjs +67 -379
  13. package/src/cli/distill-command.mjs +3 -3
  14. package/src/cli/make-agent.mjs +60 -17
  15. package/src/cli/memory-command.mjs +3 -3
  16. package/src/cli/permission.mjs +4 -48
  17. package/src/cli/setup-wizard.mjs +1 -1
  18. package/src/completions.mjs +3 -1
  19. package/src/crash-reports.mjs +1 -1
  20. package/src/distill.mjs +4 -4
  21. package/src/heap-watch.mjs +1 -1
  22. package/src/prompt-injections.mjs +20 -0
  23. package/src/tui/agent-turn.mjs +40 -9
  24. package/src/tui/cmd-advisor.mjs +5 -5
  25. package/src/tui/cmd-config.mjs +8 -8
  26. package/src/tui/cmd-eng.mjs +35 -9
  27. package/src/tui/cmd-mcp.mjs +9 -8
  28. package/src/tui/cmd-model.mjs +1 -1
  29. package/src/tui/cmd-new.mjs +6 -5
  30. package/src/tui/cmd-plan.mjs +9 -0
  31. package/src/tui/cmd-reindex.mjs +1 -1
  32. package/src/tui/cmd-restore.mjs +2 -2
  33. package/src/tui/cmd-session.mjs +24 -1
  34. package/src/tui/cmd-skills.mjs +1 -1
  35. package/src/tui/cmd-think.mjs +22 -9
  36. package/src/tui/config-helpers.mjs +1 -1
  37. package/src/tui/display-budget.mjs +33 -11
  38. package/src/tui/index.mjs +18 -10
  39. package/src/tui/interaction.mjs +16 -7
  40. package/src/tui/key-modes.mjs +9 -4
  41. package/src/tui/ledger-surface.mjs +22 -59
  42. package/src/tui/model-catalog.mjs +4 -4
  43. package/src/tui/model-picker.mjs +8 -7
  44. package/src/tui/mouse.mjs +11 -6
  45. package/src/tui/pickers.mjs +15 -2
  46. package/src/tui/render-conversation.mjs +1 -1
  47. package/src/tui/render-frame.mjs +15 -6
  48. package/src/tui/render-loop.mjs +1 -1
  49. package/src/tui/render-segments.mjs +3 -1
  50. package/src/tui/slash-commands.mjs +1 -1
  51. package/src/tui/startup.mjs +14 -14
  52. package/src/tui/subagent-blocks.mjs +20 -3
  53. package/src/tui/subagent-freeze.mjs +73 -2
  54. package/src/tui/suspension-drive.mjs +57 -23
  55. package/src/tui/tool-events.mjs +11 -8
  56. package/src/tui/tui-lifecycle.mjs +9 -0
  57. package/src/tui/wizard.mjs +3 -3
  58. package/src/tui/wrapped-spawn.mjs +6 -2
  59. package/src/abort-provenance.mjs +0 -116
  60. package/src/advisor/citations.mjs +0 -139
  61. package/src/advisor/compaction.mjs +0 -174
  62. package/src/advisor/convergence.mjs +0 -80
  63. package/src/advisor/history.mjs +0 -77
  64. package/src/advisor/loop.mjs +0 -293
  65. package/src/advisor/messages.mjs +0 -299
  66. package/src/advisor/project-context.mjs +0 -194
  67. package/src/advisor/repos.mjs +0 -150
  68. package/src/advisor/run.mjs +0 -293
  69. package/src/advisor/truncate.mjs +0 -57
  70. package/src/advisor.mjs +0 -290
  71. package/src/agent/completion.mjs +0 -146
  72. package/src/agent/dispatch.mjs +0 -489
  73. package/src/agent/helpers.mjs +0 -384
  74. package/src/agent/post-turn.mjs +0 -70
  75. package/src/agent/record-results.mjs +0 -174
  76. package/src/agent/relay-prefix.mjs +0 -39
  77. package/src/agent/run-stages.mjs +0 -244
  78. package/src/agent/setup-reminders.mjs +0 -69
  79. package/src/agent/setup.mjs +0 -354
  80. package/src/agent/spawn-child.mjs +0 -243
  81. package/src/agent-tools/advisor-async.mjs +0 -346
  82. package/src/agent-tools/advisor-settle.mjs +0 -231
  83. package/src/agent-tools/advisor.mjs +0 -260
  84. package/src/agent-tools/async-settle.mjs +0 -204
  85. package/src/agent-tools/batch-segment.mjs +0 -195
  86. package/src/agent-tools/consult.mjs +0 -473
  87. package/src/agent-tools/design-token.mjs +0 -117
  88. package/src/agent-tools/digest-budget.mjs +0 -76
  89. package/src/agent-tools/eng.mjs +0 -67
  90. package/src/agent-tools/escalate-async.mjs +0 -295
  91. package/src/agent-tools/goal.mjs +0 -119
  92. package/src/agent-tools/plan.mjs +0 -81
  93. package/src/agent-tools/read-history.mjs +0 -309
  94. package/src/agent-tools/recent-changes.mjs +0 -24
  95. package/src/agent-tools/review-streak.mjs +0 -93
  96. package/src/agent-tools/settings.mjs +0 -265
  97. package/src/agent-tools/skill.mjs +0 -47
  98. package/src/agent-tools/subagent-actions.mjs +0 -482
  99. package/src/agent-tools/subagent-async.mjs +0 -434
  100. package/src/agent-tools/subagent-panel.mjs +0 -160
  101. package/src/agent-tools/subagent-run.mjs +0 -205
  102. package/src/agent-tools/subagent-scheduler.mjs +0 -392
  103. package/src/agent-tools/subagent-spawn.mjs +0 -459
  104. package/src/agent-tools/subagent.mjs +0 -404
  105. package/src/agent-tools/task.mjs +0 -87
  106. package/src/agent-tools/timer.mjs +0 -46
  107. package/src/agent-tools/verify.mjs +0 -271
  108. package/src/agent-tools.mjs +0 -17
  109. package/src/agent.mjs +0 -417
  110. package/src/auto-think.mjs +0 -115
  111. package/src/config-migrate.mjs +0 -70
  112. package/src/config.mjs +0 -496
  113. package/src/context.mjs +0 -392
  114. package/src/conventions.mjs +0 -223
  115. package/src/embedding.mjs +0 -120
  116. package/src/escape.mjs +0 -152
  117. package/src/expand-home.mjs +0 -16
  118. package/src/explore-distill.mjs +0 -155
  119. package/src/generate-title.mjs +0 -88
  120. package/src/git/checkpoint.mjs +0 -448
  121. package/src/git/gitmem.mjs +0 -100
  122. package/src/hooks.mjs +0 -97
  123. package/src/ledger.mjs +0 -227
  124. package/src/log.mjs +0 -195
  125. package/src/markdown.mjs +0 -106
  126. package/src/mcp/helpers.mjs +0 -51
  127. package/src/mcp/transport-http.mjs +0 -248
  128. package/src/mcp/transport-stdio.mjs +0 -140
  129. package/src/mcp/transport-ws.mjs +0 -122
  130. package/src/mcp.mjs +0 -295
  131. package/src/memory/code-index.mjs +0 -219
  132. package/src/memory/code-sync.mjs +0 -415
  133. package/src/memory/core.mjs +0 -299
  134. package/src/memory/delete.mjs +0 -236
  135. package/src/memory/docs.mjs +0 -419
  136. package/src/memory/file-walk.mjs +0 -109
  137. package/src/memory/scan.mjs +0 -95
  138. package/src/memory/schema.mjs +0 -452
  139. package/src/memory.mjs +0 -21
  140. package/src/model-ref.mjs +0 -66
  141. package/src/model-specs.mjs +0 -179
  142. package/src/peer-domains.mjs +0 -265
  143. package/src/peer-instances.mjs +0 -231
  144. package/src/prompt-overlays.mjs +0 -82
  145. package/src/prompts/advisor-design.md +0 -41
  146. package/src/prompts/advisor-round1.md +0 -41
  147. package/src/prompts/advisor-round2.md +0 -46
  148. package/src/prompts/advisor-round3.md +0 -42
  149. package/src/prompts/common.md +0 -115
  150. package/src/prompts/consult-base.md +0 -19
  151. package/src/prompts/discipline-engineering.md +0 -258
  152. package/src/prompts/discipline-normal.md +0 -185
  153. package/src/prompts/persona-coder.md +0 -21
  154. package/src/prompts/persona-eng-coder.md +0 -37
  155. package/src/prompts/persona-eng-designer.md +0 -60
  156. package/src/prompts/persona-engineering.md +0 -55
  157. package/src/prompts/persona-explore.md +0 -15
  158. package/src/prompts/persona-normal.md +0 -27
  159. package/src/prompts/persona-plan.md +0 -26
  160. package/src/provider/anthropic.mjs +0 -225
  161. package/src/provider/core.mjs +0 -476
  162. package/src/provider/errors.mjs +0 -101
  163. package/src/provider/google.mjs +0 -257
  164. package/src/provider/index.mjs +0 -7
  165. package/src/provider/list-models.mjs +0 -93
  166. package/src/provider/normalize.mjs +0 -81
  167. package/src/provider/rate.mjs +0 -108
  168. package/src/provider/responses.mjs +0 -495
  169. package/src/provider/retry.mjs +0 -88
  170. package/src/provider/sse.mjs +0 -264
  171. package/src/proxy.mjs +0 -261
  172. package/src/rules.mjs +0 -53
  173. package/src/session-gc.mjs +0 -221
  174. package/src/session-guard.mjs +0 -59
  175. package/src/session-migrate.mjs +0 -48
  176. package/src/session-rename.mjs +0 -38
  177. package/src/session-segments.mjs +0 -100
  178. package/src/session-slots.mjs +0 -492
  179. package/src/session-store.mjs +0 -441
  180. package/src/session.mjs +0 -492
  181. package/src/skills.mjs +0 -153
  182. package/src/text-budget.mjs +0 -46
  183. package/src/token-ttl.mjs +0 -274
  184. package/src/tools/apply_patch.md +0 -15
  185. package/src/tools/bash.md +0 -37
  186. package/src/tools/bash.mjs +0 -268
  187. package/src/tools/checklist-sync.mjs +0 -181
  188. package/src/tools/checklist.md +0 -13
  189. package/src/tools/checklist.mjs +0 -299
  190. package/src/tools/delete.md +0 -13
  191. package/src/tools/edit-batch.mjs +0 -191
  192. package/src/tools/edit-diff.mjs +0 -348
  193. package/src/tools/edit.md +0 -30
  194. package/src/tools/execute.md +0 -21
  195. package/src/tools/execute.mjs +0 -228
  196. package/src/tools/fetch.md +0 -12
  197. package/src/tools/file.mjs +0 -469
  198. package/src/tools/file_ops.md +0 -17
  199. package/src/tools/get_current_time.md +0 -8
  200. package/src/tools/git-checkpoint.mjs +0 -143
  201. package/src/tools/git-ext.mjs +0 -173
  202. package/src/tools/git.md +0 -54
  203. package/src/tools/git.mjs +0 -356
  204. package/src/tools/glob-dialect.mjs +0 -130
  205. package/src/tools/glob.md +0 -11
  206. package/src/tools/grep.md +0 -19
  207. package/src/tools/hashline_edit.md +0 -14
  208. package/src/tools/index.mjs +0 -36
  209. package/src/tools/insert_after.md +0 -15
  210. package/src/tools/lint.md +0 -10
  211. package/src/tools/linter.mjs +0 -128
  212. package/src/tools/ls.md +0 -12
  213. package/src/tools/lsp.md +0 -10
  214. package/src/tools/lsp.mjs +0 -316
  215. package/src/tools/ops.mjs +0 -299
  216. package/src/tools/patch.mjs +0 -282
  217. package/src/tools/process.md +0 -10
  218. package/src/tools/question.md +0 -16
  219. package/src/tools/question.mjs +0 -26
  220. package/src/tools/read.md +0 -20
  221. package/src/tools/read_image.md +0 -8
  222. package/src/tools/repomap.mjs +0 -314
  223. package/src/tools/search.mjs +0 -236
  224. package/src/tools/shared.mjs +0 -446
  225. package/src/tools/tree.md +0 -14
  226. package/src/tools/tree.mjs +0 -66
  227. package/src/tools/wait_for.md +0 -22
  228. package/src/tools/web.mjs +0 -224
  229. package/src/tools/websearch.md +0 -16
  230. package/src/tools/write.md +0 -11
  231. package/src/traces/trace-store.mjs +0 -355
package/src/context.mjs DELETED
@@ -1,392 +0,0 @@
1
- /**
2
- * context.mjs — Context management and compaction
3
- * When no measured token count is available, use estimation as fallback (ASCII/4 + non-ASCII/1, no tokenizer dependency).
4
- * When a measured value exists (response usage.prompt_tokens), trust it — estimation underestimates CJK by 3-4x and relying solely on it may never trigger compaction.
5
- * Compaction strategy: summarize everything before the tail into one LLM note, keep the latest N messages verbatim.
6
- * NOTE: no dedicated head is kept (KEEP_HEAD = 0) — in multi-task sessions the earliest messages are
7
- * typically a COMPLETED earlier task; preserving them verbatim anchored the model's attention on stale
8
- * work after compaction. The earliest messages now go into the summary (which distinguishes completed
9
- * vs in-progress work), so the post-compaction context anchors on the current task (recent tail) only.
10
- */
11
-
12
- import { chat } from "./provider/index.mjs"
13
- import { estimateText } from "./provider/rate.mjs"
14
- import { providerSpec } from "./config.mjs"
15
-
16
- const IMAGE_TOKEN_ESTIMATE = 2000 // rough estimate for image content tokens (CLI legacy 256 underestimated real image costs, delaying compaction)
17
-
18
- /** Rough token count for a list of messages (body + reasoning + tool_calls params) */
19
- export function estimateTokens(messages) {
20
- let tokens = 0
21
- for (const m of messages) {
22
- if (typeof m.content === "string") tokens += estimateText(m.content)
23
- else if (Array.isArray(m.content)) {
24
- for (const part of m.content) {
25
- if (part.type === "text") tokens += estimateText(part.text)
26
- else if (part.type === "image_url") tokens += IMAGE_TOKEN_ESTIMATE
27
- }
28
- }
29
- if (typeof m.reasoning_content === "string") tokens += estimateText(m.reasoning_content)
30
- for (const tc of m.tool_calls ?? []) {
31
- tokens += estimateText(tc.function?.name ?? "") + estimateText(tc.function?.arguments ?? "")
32
- }
33
- }
34
- return tokens
35
- }
36
-
37
- const KEEP_HEAD = 0 // No dedicated head: earliest messages may be a COMPLETED earlier task in multi-task
38
- // sessions — keeping them verbatim anchored attention on stale work. Everything before the tail is
39
- // summarized (the summary itself distinguishes completed vs in-progress work; see SUMMARIZE_PROMPT).
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
47
- function keepTailSize(provider, historyLen) {
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
53
- return Math.min(Math.max(10, Math.floor((ctxWindow / 100_000) * 30)), Math.floor(historyLen * 0.4))
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
- }
59
-
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.
61
- Requirements:
62
- - Write in first person, present tense — these are "my" handover notes, continuing my own train of thought
63
- - Most important: preserve design decisions and their reasons — architecture choices, API contracts, naming conventions, trade-off rationale. These are the anchors the subsequent code must not deviate from
64
- - Distinguish COMPLETED vs IN-PROGRESS work: completed tasks get a ONE-LINE recap each (what was done, key outcome); spend the detail budget on unresolved issues, next steps, and the CURRENT task
65
- - The user's most recent request defines the current task — anchor on it. Earlier requests are likely already completed and only need the one-line recap; do NOT preserve them at full fidelity
66
- - Explicitly list FILES CHANGED: every modified file path plus a one-line "why" — so post-compaction work can re-locate what was edited and where
67
- - Explicitly list UNRESOLVED ISSUES / TODOs: anything still open plus the next steps — so post-compaction recovery knows where to resume
68
- - Drop: pleasantries, repetition, fine-grained tool output details
69
- - Honestly mark uncertain items: anything not actually verified must say "unverified"; do not present guesses as facts
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.
71
-
72
- Work log:
73
- `
74
-
75
- /** Context prefix after compaction, informing the agent what happened */
76
- const COMPACTION_PREFIX =
77
- "[Context was automatically compacted. Below is a summary of earlier work. " +
78
- "Treat it as notes, not proof — trust its conclusions (don't redo what it reports as done) " +
79
- "but re-verify transient state with tools. Check memory search for any missing decisions.]\n\n"
80
-
81
- /** After this many consecutive compaction summary failures, degrade to deterministic truncation (losing info is better than task-killing 400 errors) */
82
- export const COMPRESS_FAILURE_LIMIT = 3
83
-
84
- /** Task re-injection reminder prefix (after compaction, clear old versions from history first for a single source of truth) */
85
- const TASK_REINJECT_PREFIX = "[System reminder: your current task list after compaction:"
86
-
87
- /** Truncation fallback note (used when the summary LLM fails repeatedly; no LLM call) */
88
- const FALLBACK_NOTE =
89
- "[Context was truncated after repeated summarization failures. " +
90
- "The middle portion of earlier work was dropped WITHOUT a summary. " +
91
- "Re-verify any state you need with tools before relying on it.]\n\n"
92
-
93
- /**
94
- * Split history into head / middle (to be summarized) / tail; return null if no middle to compress.
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).
100
- */
101
- function splitHistory(history, keepTail, budgetTokens = null) {
102
- if (history.length <= KEEP_HEAD + keepTail + 1) return null
103
- let headEnd = KEEP_HEAD
104
- // head must not end with dangling tool_calls: when assistant declares tool_calls, all its tool results must stay in head.
105
- // Parallel calls: one assistant followed by multiple tool messages — accepting only one still causes 400, must collect all
106
- if (history[headEnd - 1]?.role === "assistant" && history[headEnd - 1].tool_calls?.length) {
107
- while (headEnd < history.length && history[headEnd].role === "tool") headEnd++
108
- }
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
- }
119
-
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) {
127
- const tailToolIds = new Set()
128
- for (let i = tailStart; i < history.length; i++) {
129
- if (history[i].role === "tool") tailToolIds.add(history[i].tool_call_id)
130
- }
131
- for (let i = tailStart - 1; i > headEnd; i--) {
132
- const m = history[i]
133
- if (m.role === "assistant" && m.tool_calls?.some((tc) => tailToolIds.has(tc.id))) {
134
- tailStart = i
135
- break
136
- }
137
- }
138
- while (tailStart < history.length && tailStart > headEnd && history[tailStart].role === "tool") {
139
- tailStart++
140
- }
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
160
- }
161
-
162
- /**
163
- * pushReal — the single entry point for REAL conversation messages.
164
- * A real message (user input, assistant reply, tool result, multimodal image) is appended to BOTH:
165
- * agent.history — the machine context (compaction shrinks this)
166
- * agent._fullHistory — the human-readable record (persistence source)
167
- * Machine-only messages ([System reminder:...], compaction notes, task/plan/checkpoint re-injections)
168
- * are pushed directly to agent.history WITHOUT going through here, so they never enter _fullHistory.
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).
174
- *
175
- * TUI-OOM-ROOTCAUSE 批(SESSION.md §14.3.5)——人读线内存有界 + 磁盘为准:
176
- * ① `agent._recordStore?.append(msg)`:记录同步追加(磁盘为准——append-only sidecar);
177
- * ② 窗口驱逐:绑定态(agent._historyWindow = 200)下 _fullHistory 只保最近窗口条——
178
- * 更早内容仅存磁盘(翻页/检索/保存从盘按需读)。未绑定(模式 F)不驱逐(零回归)。
179
- * 追加失败不阻断回合(独立 try/catch——尽力面 N-S6;store 内部另置 degraded 并停写)。
180
- */
181
- export function pushReal(agent, msg) {
182
- if (!Array.isArray(agent._fullHistory)) agent._fullHistory = []
183
- if (msg && msg.ts === undefined) msg.ts = Date.now()
184
- agent._fullHistory.push(msg)
185
- try { agent._recordStore?.append(msg) } catch { /* 尽力面:落盘失败不阻断回合(N-S6) */ }
186
- const win = agent._historyWindow
187
- if (win > 0 && agent._fullHistory.length > win) {
188
- agent._fullHistory.splice(0, agent._fullHistory.length - win)
189
- }
190
- agent.history.push(msg)
191
- }
192
-
193
- /** Replace middle with a note, then re-inject task/plan state (shared by LLM summary and truncation fallback) */
194
- function applyCompression(agent, headEnd, tailStart, note) {
195
- // _fullHistory already holds every real message (written at the source via pushReal),
196
- // so compaction only shrinks the machine line — nothing to preserve here.
197
- // head is normally empty (KEEP_HEAD = 0) — the summary note becomes the first message,
198
- // which is exactly the intent: post-compaction context anchors on the current task, not on
199
- // possibly-completed earlier requests.
200
- const head = agent.history.slice(0, headEnd)
201
- const tail = agent.history.slice(tailStart)
202
- // SESSION.md §9 D-S1: compaction-injected messages (note + "Understood") carry a ts —
203
- // Date.now() at the compaction moment. They are machine-only (never in _fullHistory),
204
- // but the machine-line timeline stays consistent for any audit use.
205
- const now = Date.now()
206
- agent.history = [
207
- ...head,
208
- { role: "user", content: note, ts: now },
209
- { role: "assistant", content: "Understood. I'll continue from these notes, re-verifying anything transient.", ts: now },
210
- ...tail,
211
- ]
212
- // Compaction REBUILDS the machine line (head + note + "Understood" + tail), so the pre-compaction
213
- // _runStartHistoryLen index is stale — a longer array shrank beneath it, and end-of-run exploration
214
- // distillation would then silently skip or slice from the wrong offset. Reset the boundary to the
215
- // verbatim tail start (head.length + 2: the note and the "Understood" placeholder sit between head
216
- // and tail). Exploration before the tail was already covered by the compaction summary, so only the
217
- // still-raw tail needs distilling. `head` is empty today (KEEP_HEAD = 0) — the formula stays
218
- // correct if KEEP_HEAD ever grows. (shrinkOversized only truncates message bodies in place and
219
- // leaves the array length unchanged, so this boundary stays valid there — no reset needed.)
220
- agent._runStartHistoryLen = head.length + 2
221
- // Measured token baseline is invalidated along with old history (prompt_tokens were for pre-compaction context), fall back to estimation until next response
222
- agent._lastPromptTokens = null
223
- agent._usageAtLen = null
224
-
225
- // After compaction, re-inject the task list (the agent needs to know what it was doing).
226
- // Single source of truth: first remove any stale re-injections from the tail, then inject the latest version —
227
- // no longer embedded in the summary body (would duplicate and grow stale)
228
- agent.history = agent.history.filter(
229
- (m) => !(m.role === "user" && typeof m.content === "string" && m.content.startsWith(TASK_REINJECT_PREFIX))
230
- )
231
- if (agent.tasks.length > 0) {
232
- const taskSummary = agent.tasks.map((t) => `- [${t.status}] ${t.title}`).join("\n")
233
- agent.history.push({
234
- role: "user",
235
- content: `${TASK_REINJECT_PREFIX}\n${taskSummary}\nContinue from where you left off.]`,
236
- })
237
- }
238
-
239
- // Plan mode compaction: re-inject plan mode guidance
240
- if (agent.planMode) {
241
- agent.history.push({
242
- role: "user",
243
- content: "[System reminder: plan mode is active. Explore the codebase read-only, design your solution, then call plan with action='exit' to present it for user approval.]",
244
- })
245
- }
246
- }
247
-
248
- /**
249
- * If history exceeds threshold, compact it. Returns whether compaction happened.
250
- * Only called at safe points in the loop (history ends with user or tool message — a complete exchange boundary).
251
- * Automatically re-injects task list state after compaction.
252
- * @param {object} agent
253
- * @param {number} threshold - compaction threshold in tokens
254
- * @param {object} callbacks - { onToken, onReasoning, onCompress, onCompressStart } — summary
255
- * generation is SILENT (never forwards onToken/onReasoning: the compaction process is an
256
- * internal mechanism, not a model reply); onCompressStart fires right before the summary call
257
- * (§7 D-C1, compression lifecycle visibility — panel start state)
258
- * @param {object} extras - { systemPrompt?, tools? } — estimated overhead for the pure-estimation
259
- * path (no measured baseline); the measured path already includes system+tools in prompt_tokens.
260
- */
261
- export async function compressIfNeeded(agent, threshold, callbacks, extras = {}, signal) {
262
- const history = agent.history
263
- // Prefer the real baseline: the last response's prompt_tokens is the measured value for the full context (system+tools+history).
264
- // Subsequent appended messages use estimation as increment; when no measured value exists (first turn / after restore / right after compaction), fall back to pure estimation
265
- const overhead =
266
- (extras.systemPrompt ? estimateText(extras.systemPrompt) : 0) +
267
- (extras.tools ? estimateText(JSON.stringify(extras.tools)) : 0)
268
- const tokens =
269
- agent._lastPromptTokens != null
270
- ? agent._lastPromptTokens + estimateTokens(history.slice(agent._usageAtLen ?? history.length))
271
- : estimateTokens(history) + overhead
272
- if (tokens <= threshold) return false
273
-
274
- const keepTail = keepTailSize(agent.provider, history.length)
275
- const split = splitHistory(history, keepTail, tailBudgetTokens(agent.provider))
276
- if (!split) {
277
- // History is too short (≤KEEP_HEAD+keepTail+1 messages) to find a middle section, but tokens exceed threshold — typically a single giant message
278
- // (large paste / huge injection). When summarization has no room, degrade to deterministic shrinking to ensure context always reduces
279
- return shrinkOversized(agent)
280
- }
281
-
282
- const middle = history.slice(split.headEnd, split.tailStart)
283
- const serialized = middle
284
- .map((m) => {
285
- const toolNote = m.tool_calls ? ` [called tools: ${m.tool_calls.map((t) => t.function?.name).join(", ")}]` : ""
286
- // user messages get a wider cap (8000): cutting off a long user-pasted requirement loses original intent; tool/assistant capped at 2000 is enough
287
- const cap = m.role === "user" ? 8000 : 2000
288
- // Multimodal messages (array content): extract the TEXT parts — the image itself can't be
289
- // summarized, but any accompanying text (e.g. "看这张图" + image) must not be silently lost
290
- let text = ""
291
- if (typeof m.content === "string") text = m.content
292
- else if (Array.isArray(m.content)) text = m.content.filter((p) => p?.type === "text").map((p) => p.text ?? "").join(" ")
293
- return `[${m.role}]${toolNote} ${text.slice(0, cap)}`
294
- })
295
- .join("\n")
296
-
297
- // The summary is a plain-text task, no reasoning needed — passing thinking to the compaction provider wastes tokens.
298
- // Silent by design (D11): no onToken/onReasoning — the compaction process must not stream to the frontend.
299
- // signal propagates user cancellation (Ctrl+C) to the in-flight summary call.
300
- // Compression visibility (CONTEXT-COMPACTION.md §7 D-C1/D-C2): the frontend learns the compression
301
- // STARTED right before the summary LLM call ("Compressing context… / summarizing N messages" panel) — only
302
- // the lifecycle is surfaced, never the summary body. N = the number of history messages being summarized.
303
- callbacks?.onCompressStart?.({ messages: middle.length })
304
- const startedAt = performance.now()
305
- const summary = await chat({ ...agent.provider, thinking: null, reasoningEffort: null }, {
306
- messages: [{ role: "user", content: SUMMARIZE_PROMPT + serialized }],
307
- signal,
308
- // §18.6 D-TR4:轨迹元数据增补——kind=compress(上下文构建面——agent 元数据透出;
309
- // depth 经 extras.traceDepth——agent.mjs 主作用域传入——compress 调用点补齐)
310
- logCtx: {
311
- stage: "compress", child: agent._logId, kind: "compress",
312
- role: agent._role ?? null, depth: extras?.traceDepth ?? null,
313
- session: agent._sessionStart ?? null, cwd: agent.cwd,
314
- traces: agent.config?.traces?.enabled !== false,
315
- },
316
- })
317
-
318
- applyCompression(agent, split.headEnd, split.tailStart, COMPACTION_PREFIX + summary.content)
319
-
320
- // Completion info for the compression panel (D-C2): tokens freed = the pre-compression prompt
321
- // estimate (`tokens` — the value that tripped the threshold, incl. system/tools overhead on the
322
- // pure-estimation path) minus the post-compression estimate on the same basis. Elapsed = the
323
- // summary call + splice duration. agent.mjs forwards this to onCompress unchanged.
324
- agent._lastCompressInfo = {
325
- mode: "summary",
326
- tokensFreed: Math.max(0, Math.round(tokens - (estimateTokens(agent.history) + overhead))),
327
- elapsedMs: performance.now() - startedAt,
328
- }
329
- return true
330
- }
331
-
332
- /**
333
- * Deterministic truncation fallback: called when the summary LLM fails repeatedly, no network call.
334
- * Drops the middle so the task can continue. Returns whether truncation happened.
335
- */
336
- export function compressFallback(agent) {
337
- const keepTail = keepTailSize(agent.provider, agent.history.length)
338
- const split = splitHistory(agent.history, keepTail, tailBudgetTokens(agent.provider))
339
- if (!split) return false
340
- const tailMessages = agent.history.length - split.tailStart
341
- applyCompression(agent, split.headEnd, split.tailStart, FALLBACK_NOTE)
342
- // Fallback completion info (D-C2): mode marks the deterministic-truncation path — the panel
343
- // shows the degradation note ("truncated to N messages") ONLY after 3 consecutive failures.
344
- agent._lastCompressInfo = { mode: "fallback", tailMessages }
345
- return true
346
- }
347
-
348
- /** 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) */
349
- const OVERSIZE_CONTENT_LIMIT = 8_000
350
-
351
- /**
352
- * Deterministic shrinking: last resort when splitHistory can't find a middle section (history too short) but threshold is exceeded. No LLM call.
353
- * Truncates user/tool message bodies exceeding OVERSIZE_CONTENT_LIMIT to a stub (keeps head + tail);
354
- * does not touch reasoning_content (DeepSeek/Kimi echo protocol) or tool_calls pairing structure — no protocol 400 risk.
355
- * Only called after compressIfNeeded determines threshold is exceeded. Returns whether any message was truncated.
356
- */
357
- function shrinkOversized(agent, limit = OVERSIZE_CONTENT_LIMIT) {
358
- let shrunk = false
359
- // Copy-on-write: build a NEW array and replace only truncated entries. pushReal stores the SAME
360
- // message object in both `agent.history` (machine line) and `agent._fullHistory` (human/persistence
361
- // line), so in-place `m.content = ...` would ALSO truncate the never-compacted human line and lose
362
- // the original pasted content on session persist (session.mjs persists _fullHistory). VS Code port
363
- // already copies (`history.map(m => ({ ...m }))`); this brings CLI to parity.
364
- const next = agent.history.map((m) => {
365
- if ((m.role !== "user" && m.role !== "tool") || typeof m.content !== "string") return m
366
- if (m.content.length <= limit) return m
367
- // Truncate keeping head + tail, insert stub in between; keepHead/keepTail proportional but not exceeding 50%/25% of limit
368
- const keepHead = Math.min(Math.floor(limit * 0.5), 4000)
369
- const keepTail = Math.min(Math.floor(limit * 0.25), 2000)
370
- shrunk = true
371
- return {
372
- ...m,
373
- content:
374
- m.content.slice(0, keepHead) +
375
- `\n[... ${m.content.length - keepHead - keepTail} chars truncated — single message too large for context window ...]\n` +
376
- m.content.slice(-keepTail),
377
- }
378
- })
379
- if (shrunk) {
380
- agent.history = next
381
- // Same as compaction: measured token baseline is invalidated by the changed history, fall back to estimation until next response
382
- agent._lastPromptTokens = null
383
- agent._usageAtLen = null
384
- }
385
- return shrunk
386
- }
387
-
388
- // ─── End-of-run exploration distillation(2026-09-05 module-split:524 > 500 硬限——verbatim
389
- // 迁至 explore-distill.mjs,语义零变——VS Code compact.mjs 同款联动;cross-repo parity 锚改指
390
- // explore-distill.mjs——消费方 import 面不变(re-export))───────────────────────
391
-
392
- export { summarizeRunExplorations, EXPLORE_TOOLS, EXPLORE_SUMMARY_PROMPT } from "./explore-distill.mjs"
@@ -1,223 +0,0 @@
1
- /**
2
- * conventions.mjs — the single authority for code / doc / temp path classification,
3
- * plus the project convention declaration surface (`.thincoder/conventions.json`).
4
- *
5
- * Why one module: engineering-mode gates and guards (design gate, review-doc gate,
6
- * mutation accounting, verify fast path) each carried their own copy of the
7
- * "what counts as product code" predicate — anchored `^src/` regexes, `docs/`
8
- * prefix checks, component regexes. Each copy drifted, and each hardcoded THIS
9
- * repository's layout: a project whose code lives outside `src/` slipped through
10
- * the design gate silently (PORTABILITY FR12 / PO-10). One classifier + one
11
- * declaration file = one truth.
12
- *
13
- * Defaults are DATA (`DEFAULT_CODE_PATHS`) — overridable per project through the
14
- * declaration file (§4.1 schema). Missing file → pure defaults (no noise);
15
- * corrupt/unreadable file → defaults + console.warn + a log event (never crash,
16
- * never swallow — PORTABILITY FR10).
17
- *
18
- * Classification vocabulary (PORTABILITY design §3.1):
19
- * code — inside a declared code segment (default: the path segment `src`), or
20
- * not a documentation extension; doc — documentation extension outside
21
- * any code segment; temp — tmp-* name or .tmp/.temp extension.
22
- */
23
- import { readFileSync } from "node:fs"
24
- import { join, resolve } from "node:path"
25
- import { logEvent } from "./log.mjs"
26
-
27
- /** Default code-path segments (data, not logic — a project may replace them). */
28
- export const DEFAULT_CODE_PATHS = ["src"]
29
-
30
- /** Project declaration file, relative to the project root. */
31
- export const CONVENTIONS_REL_PATH = ".thincoder/conventions.json"
32
-
33
- /** Documentation predicate (moved here verbatim from advisor/repos.mjs — one copy). */
34
- const DOC_FILE = /(?:^|[/\\])(?:LICENSE|NOTICE|CHANGELOG|AUTHORS)(?:\.\w+)?$|\.(?:md|markdown|mdx|txt|rst|adoc)$/i
35
-
36
- /** Temp/scratch predicate (moved here verbatim from advisor/repos.mjs). */
37
- const TEMP_FILE = /(?:^|[/\\])tmp-[^/\\]+$|\.(?:tmp|temp)$/i
38
-
39
- /** True when a path is a throwaway temp file (tmp-* name or .tmp/.temp extension). */
40
- export function isTempPath(p) {
41
- return TEMP_FILE.test(p ?? "")
42
- }
43
-
44
- /** Path → segments (both separators accepted; absolute and relative alike). */
45
- function segmentsOf(p) {
46
- return String(p ?? "").replace(/\\/g, "/").split("/").filter(Boolean)
47
- }
48
-
49
- /**
50
- * True when the path contains a declared code segment sequence at any depth.
51
- * Segment matching (not a prefix anchor) is what closes the nested-layout hole:
52
- * `packages/foo/src/x.md` is product code, not a document. Comparison is
53
- * case-insensitive — on case-insensitive filesystems `Src/x.mjs` is the same
54
- * directory, and the gate must not be bypassable by casing.
55
- */
56
- function hasCodeSegment(p, conv) {
57
- const parts = segmentsOf(p).map((s) => s.toLowerCase())
58
- const wanted = conv?.codePaths ?? DEFAULT_CODE_PATHS
59
- for (const entry of wanted) {
60
- const want = segmentsOf(entry).map((s) => s.toLowerCase())
61
- if (want.length === 0) continue
62
- for (let i = 0; i + want.length <= parts.length; i++) {
63
- if (want.every((seg, j) => parts[i + j] === seg)) return true
64
- }
65
- }
66
- return false
67
- }
68
-
69
- /** "code" | "doc" | "temp" — the single classification decision.
70
- * Precedence: code segment first (src/** stays product code even when the name
71
- * looks scratch — the pre-existing unconditional-src rule), then temp, then a
72
- * documentation extension, else code (anything not doc/temp is product code). */
73
- export function classifyPath(p, conv) {
74
- const s = String(p ?? "")
75
- if (hasCodeSegment(s, conv)) return "code"
76
- if (TEMP_FILE.test(s)) return "temp"
77
- if (DOC_FILE.test(s)) return "doc"
78
- return "code"
79
- }
80
-
81
- /** True when the path is product code (see classifyPath for the precedence). */
82
- export function isCodePath(p, conv) {
83
- return classifyPath(p, conv) === "code"
84
- }
85
-
86
- /** True when the path is a documentation file — a doc extension that does NOT
87
- * live inside a declared code segment (src/prompts/*.md is product code). */
88
- export function isDocPath(p, conv) {
89
- const s = String(p ?? "")
90
- return DOC_FILE.test(s) && !hasCodeSegment(s, conv)
91
- }
92
-
93
- // ─────────────────────────────────────────────────────────────────────────────
94
- // Declaration loading (cached per project root — `clearConventionsCache()` is
95
- // the test seam; declaration files change rarely and only at session scope).
96
- // ─────────────────────────────────────────────────────────────────────────────
97
-
98
- function normalizeExtensions(v) {
99
- if (!Array.isArray(v)) return []
100
- const out = []
101
- for (const e of v) {
102
- if (typeof e !== "string") continue
103
- const t = e.trim().toLowerCase()
104
- if (!t) continue
105
- const ext = t.startsWith(".") ? t : `.${t}`
106
- if (!out.includes(ext)) out.push(ext)
107
- }
108
- return out
109
- }
110
-
111
- /** Declared code paths replace the default (replacement, not union — §4.1). */
112
- function normalizeCodePaths(v) {
113
- if (!Array.isArray(v)) return null
114
- const out = []
115
- for (const e of v) {
116
- if (typeof e !== "string") continue
117
- const s = e.trim().replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "")
118
- if (!s || out.includes(s)) continue
119
- out.push(s)
120
- }
121
- return out.length > 0 ? out : null
122
- }
123
-
124
- function normalizeString(v) {
125
- return typeof v === "string" && v.trim() ? v.trim() : ""
126
- }
127
-
128
- /** Per-key type check for recognized keys (present but wrong type). §3.2/§4.1: a
129
- * type error degrades WITH a warning — never silently (the fallback semantics stay
130
- * per-key; only the visibility is added here). */
131
- function typeErrorsOf(raw) {
132
- const isObj = (v) => v !== undefined && v !== null && typeof v === "object" && !Array.isArray(v)
133
- const strArray = (v) => Array.isArray(v) && v.every((x) => typeof x === "string")
134
- const errs = []
135
- if (raw.codePaths !== undefined && !strArray(raw.codePaths)) errs.push("codePaths must be an array of strings")
136
- const idx = raw.index
137
- if (idx !== undefined && !isObj(idx)) errs.push("index must be an object")
138
- else if (isObj(idx)) {
139
- for (const k of ["codeExtensions", "docExtensions"]) {
140
- if (idx[k] !== undefined && !strArray(idx[k])) errs.push(`index.${k} must be an array of strings`)
141
- }
142
- }
143
- const adv = raw.advisor
144
- if (adv !== undefined && !isObj(adv)) errs.push("advisor must be an object")
145
- else if (isObj(adv)) {
146
- for (const k of ["docMap", "standardsDoc"]) {
147
- if (adv[k] !== undefined && typeof adv[k] !== "string") errs.push(`advisor.${k} must be a string`)
148
- }
149
- }
150
- return errs
151
- }
152
-
153
- function buildConventions(raw) {
154
- const codePaths = normalizeCodePaths(raw?.codePaths)
155
- const codeExtensions = normalizeExtensions(raw?.index?.codeExtensions)
156
- const docExtensions = normalizeExtensions(raw?.index?.docExtensions)
157
- const docMap = normalizeString(raw?.advisor?.docMap)
158
- const standardsDoc = normalizeString(raw?.advisor?.standardsDoc)
159
- // `declared` = the declaration actually took effect (at least one recognized key
160
- // honored) — the design-gate hint reads it to decide whether to point at the
161
- // declaration file ("declare project conventions … to adjust").
162
- const declared = Boolean(codePaths || codeExtensions.length || docExtensions.length || docMap || standardsDoc)
163
- return Object.freeze({
164
- declared,
165
- codePaths: Object.freeze(codePaths ?? [...DEFAULT_CODE_PATHS]),
166
- index: Object.freeze({
167
- codeExtensions: Object.freeze(codeExtensions),
168
- docExtensions: Object.freeze(docExtensions),
169
- }),
170
- advisor: Object.freeze({ docMap, standardsDoc }),
171
- })
172
- }
173
-
174
- /** Full-default conventions (no declaration) — the fallback every consumer gets. */
175
- export const DEFAULT_CONVENTIONS = buildConventions(null)
176
-
177
- const _cache = new Map()
178
-
179
- /** Drop the per-root cache (test seam — declaration files are read once per root). */
180
- export function clearConventionsCache() {
181
- _cache.clear()
182
- }
183
-
184
- /**
185
- * Load (and cache) the normalized conventions for a project root.
186
- * @param {string} cwd — project root (declaration lives at .thincoder/conventions.json)
187
- * @returns {Readonly<{declared: boolean, codePaths: readonly string[],
188
- * index: {codeExtensions: string[], docExtensions: string[]},
189
- * advisor: {docMap: string, standardsDoc: string}}>}
190
- */
191
- export function loadConventions(cwd) {
192
- const root = resolve(cwd ?? process.cwd())
193
- const hit = _cache.get(root)
194
- if (hit) return hit
195
- let conv = DEFAULT_CONVENTIONS
196
- try {
197
- const text = readFileSync(join(root, CONVENTIONS_REL_PATH), "utf8")
198
- try {
199
- const raw = JSON.parse(text)
200
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("top level must be a JSON object")
201
- conv = buildConventions(raw)
202
- const typeErrs = typeErrorsOf(raw)
203
- if (typeErrs.length > 0) {
204
- // Wrong-typed keys fall back per-key — but the user must SEE that their
205
- // declaration did not take effect (never silently swallowed).
206
- console.warn(`[conventions] ${CONVENTIONS_REL_PATH} has invalid value types (${typeErrs.join("; ")}) — those keys fall back to defaults`)
207
- logEvent("conventions:error", { cwd: root, err: `type errors: ${typeErrs.join("; ").slice(0, 160)}` })
208
- }
209
- } catch (e) {
210
- // Corrupt file / wrong shape → defaults, visible: warn + event (never silent).
211
- console.warn(`[conventions] ${CONVENTIONS_REL_PATH} unreadable (${e?.message ?? e}) — falling back to defaults`)
212
- logEvent("conventions:error", { cwd: root, err: String(e?.message ?? e).slice(0, 200) })
213
- }
214
- } catch (e) {
215
- if (e?.code !== "ENOENT") {
216
- // File exists but cannot be read (EACCES etc.) — same visible degradation.
217
- console.warn(`[conventions] ${CONVENTIONS_REL_PATH} not readable (${e?.message ?? e}) — falling back to defaults`)
218
- logEvent("conventions:error", { cwd: root, err: String(e?.message ?? e).slice(0, 200) })
219
- }
220
- }
221
- _cache.set(root, conv)
222
- return conv
223
- }