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.
- package/CHANGELOG.md +27 -0
- package/README.md +46 -49
- package/bin/thincoder.mjs +68 -34
- package/package.json +7 -6
- package/src/acp/bridge.mjs +35 -15
- package/src/acp/client-caps.mjs +86 -0
- package/src/acp/ext.mjs +86 -0
- package/src/acp/handlers-session.mjs +257 -0
- package/src/acp/handlers-slots.mjs +196 -0
- package/src/acp/login.mjs +48 -0
- package/src/acp/session.mjs +6 -4
- package/src/acp.mjs +67 -379
- package/src/cli/distill-command.mjs +3 -3
- package/src/cli/make-agent.mjs +60 -17
- package/src/cli/memory-command.mjs +3 -3
- package/src/cli/permission.mjs +4 -48
- package/src/cli/setup-wizard.mjs +1 -1
- package/src/completions.mjs +3 -1
- package/src/crash-reports.mjs +1 -1
- package/src/distill.mjs +4 -4
- package/src/heap-watch.mjs +1 -1
- package/src/prompt-injections.mjs +20 -0
- package/src/tui/agent-turn.mjs +40 -9
- package/src/tui/cmd-advisor.mjs +5 -5
- package/src/tui/cmd-config.mjs +8 -8
- package/src/tui/cmd-eng.mjs +35 -9
- package/src/tui/cmd-mcp.mjs +9 -8
- package/src/tui/cmd-model.mjs +1 -1
- package/src/tui/cmd-new.mjs +6 -5
- package/src/tui/cmd-plan.mjs +9 -0
- package/src/tui/cmd-reindex.mjs +1 -1
- package/src/tui/cmd-restore.mjs +2 -2
- package/src/tui/cmd-session.mjs +24 -1
- package/src/tui/cmd-skills.mjs +1 -1
- package/src/tui/cmd-think.mjs +22 -9
- package/src/tui/config-helpers.mjs +1 -1
- package/src/tui/display-budget.mjs +33 -11
- package/src/tui/index.mjs +18 -10
- package/src/tui/interaction.mjs +16 -7
- package/src/tui/key-modes.mjs +9 -4
- package/src/tui/ledger-surface.mjs +22 -59
- package/src/tui/model-catalog.mjs +4 -4
- package/src/tui/model-picker.mjs +8 -7
- package/src/tui/mouse.mjs +11 -6
- package/src/tui/pickers.mjs +15 -2
- package/src/tui/render-conversation.mjs +1 -1
- package/src/tui/render-frame.mjs +15 -6
- package/src/tui/render-loop.mjs +1 -1
- package/src/tui/render-segments.mjs +3 -1
- package/src/tui/slash-commands.mjs +1 -1
- package/src/tui/startup.mjs +14 -14
- package/src/tui/subagent-blocks.mjs +20 -3
- package/src/tui/subagent-freeze.mjs +73 -2
- package/src/tui/suspension-drive.mjs +57 -23
- package/src/tui/tool-events.mjs +11 -8
- package/src/tui/tui-lifecycle.mjs +9 -0
- package/src/tui/wizard.mjs +3 -3
- package/src/tui/wrapped-spawn.mjs +6 -2
- package/src/abort-provenance.mjs +0 -116
- package/src/advisor/citations.mjs +0 -139
- package/src/advisor/compaction.mjs +0 -174
- package/src/advisor/convergence.mjs +0 -80
- package/src/advisor/history.mjs +0 -77
- package/src/advisor/loop.mjs +0 -293
- package/src/advisor/messages.mjs +0 -299
- package/src/advisor/project-context.mjs +0 -194
- package/src/advisor/repos.mjs +0 -150
- package/src/advisor/run.mjs +0 -293
- package/src/advisor/truncate.mjs +0 -57
- package/src/advisor.mjs +0 -290
- package/src/agent/completion.mjs +0 -146
- package/src/agent/dispatch.mjs +0 -489
- package/src/agent/helpers.mjs +0 -384
- package/src/agent/post-turn.mjs +0 -70
- package/src/agent/record-results.mjs +0 -174
- package/src/agent/relay-prefix.mjs +0 -39
- package/src/agent/run-stages.mjs +0 -244
- package/src/agent/setup-reminders.mjs +0 -69
- package/src/agent/setup.mjs +0 -354
- package/src/agent/spawn-child.mjs +0 -243
- package/src/agent-tools/advisor-async.mjs +0 -346
- package/src/agent-tools/advisor-settle.mjs +0 -231
- package/src/agent-tools/advisor.mjs +0 -260
- package/src/agent-tools/async-settle.mjs +0 -204
- package/src/agent-tools/batch-segment.mjs +0 -195
- package/src/agent-tools/consult.mjs +0 -473
- package/src/agent-tools/design-token.mjs +0 -117
- package/src/agent-tools/digest-budget.mjs +0 -76
- package/src/agent-tools/eng.mjs +0 -67
- package/src/agent-tools/escalate-async.mjs +0 -295
- package/src/agent-tools/goal.mjs +0 -119
- package/src/agent-tools/plan.mjs +0 -81
- package/src/agent-tools/read-history.mjs +0 -309
- package/src/agent-tools/recent-changes.mjs +0 -24
- package/src/agent-tools/review-streak.mjs +0 -93
- package/src/agent-tools/settings.mjs +0 -265
- package/src/agent-tools/skill.mjs +0 -47
- package/src/agent-tools/subagent-actions.mjs +0 -482
- package/src/agent-tools/subagent-async.mjs +0 -434
- package/src/agent-tools/subagent-panel.mjs +0 -160
- package/src/agent-tools/subagent-run.mjs +0 -205
- package/src/agent-tools/subagent-scheduler.mjs +0 -392
- package/src/agent-tools/subagent-spawn.mjs +0 -459
- package/src/agent-tools/subagent.mjs +0 -404
- package/src/agent-tools/task.mjs +0 -87
- package/src/agent-tools/timer.mjs +0 -46
- package/src/agent-tools/verify.mjs +0 -271
- package/src/agent-tools.mjs +0 -17
- package/src/agent.mjs +0 -417
- package/src/auto-think.mjs +0 -115
- package/src/config-migrate.mjs +0 -70
- package/src/config.mjs +0 -496
- package/src/context.mjs +0 -392
- package/src/conventions.mjs +0 -223
- package/src/embedding.mjs +0 -120
- package/src/escape.mjs +0 -152
- package/src/expand-home.mjs +0 -16
- package/src/explore-distill.mjs +0 -155
- package/src/generate-title.mjs +0 -88
- package/src/git/checkpoint.mjs +0 -448
- package/src/git/gitmem.mjs +0 -100
- package/src/hooks.mjs +0 -97
- package/src/ledger.mjs +0 -227
- package/src/log.mjs +0 -195
- package/src/markdown.mjs +0 -106
- package/src/mcp/helpers.mjs +0 -51
- package/src/mcp/transport-http.mjs +0 -248
- package/src/mcp/transport-stdio.mjs +0 -140
- package/src/mcp/transport-ws.mjs +0 -122
- package/src/mcp.mjs +0 -295
- package/src/memory/code-index.mjs +0 -219
- package/src/memory/code-sync.mjs +0 -415
- package/src/memory/core.mjs +0 -299
- package/src/memory/delete.mjs +0 -236
- package/src/memory/docs.mjs +0 -419
- package/src/memory/file-walk.mjs +0 -109
- package/src/memory/scan.mjs +0 -95
- package/src/memory/schema.mjs +0 -452
- package/src/memory.mjs +0 -21
- package/src/model-ref.mjs +0 -66
- package/src/model-specs.mjs +0 -179
- package/src/peer-domains.mjs +0 -265
- package/src/peer-instances.mjs +0 -231
- package/src/prompt-overlays.mjs +0 -82
- package/src/prompts/advisor-design.md +0 -41
- package/src/prompts/advisor-round1.md +0 -41
- package/src/prompts/advisor-round2.md +0 -46
- package/src/prompts/advisor-round3.md +0 -42
- package/src/prompts/common.md +0 -115
- package/src/prompts/consult-base.md +0 -19
- package/src/prompts/discipline-engineering.md +0 -258
- package/src/prompts/discipline-normal.md +0 -185
- package/src/prompts/persona-coder.md +0 -21
- package/src/prompts/persona-eng-coder.md +0 -37
- package/src/prompts/persona-eng-designer.md +0 -60
- package/src/prompts/persona-engineering.md +0 -55
- package/src/prompts/persona-explore.md +0 -15
- package/src/prompts/persona-normal.md +0 -27
- package/src/prompts/persona-plan.md +0 -26
- package/src/provider/anthropic.mjs +0 -225
- package/src/provider/core.mjs +0 -476
- package/src/provider/errors.mjs +0 -101
- package/src/provider/google.mjs +0 -257
- package/src/provider/index.mjs +0 -7
- package/src/provider/list-models.mjs +0 -93
- package/src/provider/normalize.mjs +0 -81
- package/src/provider/rate.mjs +0 -108
- package/src/provider/responses.mjs +0 -495
- package/src/provider/retry.mjs +0 -88
- package/src/provider/sse.mjs +0 -264
- package/src/proxy.mjs +0 -261
- package/src/rules.mjs +0 -53
- package/src/session-gc.mjs +0 -221
- package/src/session-guard.mjs +0 -59
- package/src/session-migrate.mjs +0 -48
- package/src/session-rename.mjs +0 -38
- package/src/session-segments.mjs +0 -100
- package/src/session-slots.mjs +0 -492
- package/src/session-store.mjs +0 -441
- package/src/session.mjs +0 -492
- package/src/skills.mjs +0 -153
- package/src/text-budget.mjs +0 -46
- package/src/token-ttl.mjs +0 -274
- package/src/tools/apply_patch.md +0 -15
- package/src/tools/bash.md +0 -37
- package/src/tools/bash.mjs +0 -268
- package/src/tools/checklist-sync.mjs +0 -181
- package/src/tools/checklist.md +0 -13
- package/src/tools/checklist.mjs +0 -299
- package/src/tools/delete.md +0 -13
- package/src/tools/edit-batch.mjs +0 -191
- package/src/tools/edit-diff.mjs +0 -348
- package/src/tools/edit.md +0 -30
- package/src/tools/execute.md +0 -21
- package/src/tools/execute.mjs +0 -228
- package/src/tools/fetch.md +0 -12
- package/src/tools/file.mjs +0 -469
- package/src/tools/file_ops.md +0 -17
- package/src/tools/get_current_time.md +0 -8
- package/src/tools/git-checkpoint.mjs +0 -143
- package/src/tools/git-ext.mjs +0 -173
- package/src/tools/git.md +0 -54
- package/src/tools/git.mjs +0 -356
- package/src/tools/glob-dialect.mjs +0 -130
- package/src/tools/glob.md +0 -11
- package/src/tools/grep.md +0 -19
- package/src/tools/hashline_edit.md +0 -14
- package/src/tools/index.mjs +0 -36
- package/src/tools/insert_after.md +0 -15
- package/src/tools/lint.md +0 -10
- package/src/tools/linter.mjs +0 -128
- package/src/tools/ls.md +0 -12
- package/src/tools/lsp.md +0 -10
- package/src/tools/lsp.mjs +0 -316
- package/src/tools/ops.mjs +0 -299
- package/src/tools/patch.mjs +0 -282
- package/src/tools/process.md +0 -10
- package/src/tools/question.md +0 -16
- package/src/tools/question.mjs +0 -26
- package/src/tools/read.md +0 -20
- package/src/tools/read_image.md +0 -8
- package/src/tools/repomap.mjs +0 -314
- package/src/tools/search.mjs +0 -236
- package/src/tools/shared.mjs +0 -446
- package/src/tools/tree.md +0 -14
- package/src/tools/tree.mjs +0 -66
- package/src/tools/wait_for.md +0 -22
- package/src/tools/web.mjs +0 -224
- package/src/tools/websearch.md +0 -16
- package/src/tools/write.md +0 -11
- 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"
|
package/src/conventions.mjs
DELETED
|
@@ -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
|
-
}
|