@thincoder/core 0.9.2 → 0.9.4

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 (54) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +1 -0
  3. package/agent/completion.mjs +3 -1
  4. package/agent/family-tools.mjs +12 -9
  5. package/agent/helpers.mjs +11 -2
  6. package/agent/run-stages.mjs +27 -5
  7. package/agent/setup.mjs +6 -0
  8. package/agent/write-gate.mjs +5 -5
  9. package/agent-tools/advisor-async.mjs +4 -4
  10. package/agent-tools/advisor.mjs +1 -1
  11. package/agent-tools/async-discard.mjs +1 -1
  12. package/agent-tools/audit-block.mjs +106 -0
  13. package/agent-tools/batch-lifecycle.mjs +72 -16
  14. package/agent-tools/batch-skeleton.mjs +69 -7
  15. package/agent-tools/batch.mjs +19 -6
  16. package/agent-tools/context.mjs +174 -0
  17. package/agent-tools/goal.mjs +7 -0
  18. package/agent-tools/parent-channel.mjs +2 -2
  19. package/agent-tools/plan.mjs +6 -6
  20. package/agent-tools/read-history.mjs +122 -24
  21. package/agent-tools/settings.mjs +4 -2
  22. package/agent-tools/subagent-async.mjs +3 -3
  23. package/agent-tools/subagent-spawn.mjs +29 -101
  24. package/agent-tools/task.mjs +11 -0
  25. package/agent-tools.mjs +4 -1
  26. package/agent.mjs +10 -4
  27. package/config-presets.mjs +2 -2
  28. package/config.mjs +1 -1
  29. package/context.mjs +66 -121
  30. package/fts-text.mjs +41 -0
  31. package/memory/core.mjs +4 -18
  32. package/memory/schema.mjs +4 -11
  33. package/model-specs.mjs +22 -7
  34. package/package.json +5 -1
  35. package/prompts/common.md +2 -2
  36. package/prompts/discipline-engineering.md +17 -2
  37. package/prompts/persona-engineering.md +1 -1
  38. package/session-gc.mjs +11 -0
  39. package/session-index-build.mjs +298 -0
  40. package/session-index-cmd.mjs +61 -0
  41. package/session-index-pass.mjs +95 -0
  42. package/session-index-query.mjs +102 -0
  43. package/session-index.mjs +285 -0
  44. package/session-slots-manifest.mjs +19 -0
  45. package/token-window.mjs +188 -0
  46. package/tools/bash.mjs +4 -15
  47. package/tools/execute.mjs +5 -13
  48. package/tools/git-checkpoint.mjs +1 -1
  49. package/tools/git-ext.mjs +23 -20
  50. package/tools/git-run.mjs +141 -0
  51. package/tools/git.mjs +42 -36
  52. package/tools/index.mjs +3 -1
  53. package/tools/process-tree.mjs +20 -0
  54. package/tools/shared.mjs +8 -4
package/context.mjs CHANGED
@@ -7,56 +7,13 @@
7
7
  * typically a COMPLETED earlier task; preserving them verbatim anchored the model's attention on stale
8
8
  * work after compaction. The earliest messages now go into the summary (which distinguishes completed
9
9
  * vs in-progress work), so the post-compaction context anchors on the current task (recent tail) only.
10
+ *
11
+ * 2026-09-21(context-tool 批 · CONTEXT-COMPACTION.md §6.16.8):计量 / 尾族 / 切分三族**逐字迁出** `token-window.mjs`(495 → ≈428——硬限 500 内);`estimateTokens` 经本档**再导出**保既有 import 面;新增面 = 模型主动压缩接线(`force` / `focus` + 焦点块 + anchor)与 prune 应用面。
10
12
  */
11
13
 
12
14
  import { chat } from "./provider/index.mjs"
13
- import { estimateText } from "./provider/rate.mjs"
14
- import { providerSpec } from "./config.mjs"
15
15
  import { buildCompressMessages } from "./compress-form.mjs"
16
-
17
- const IMAGE_TOKEN_ESTIMATE = 2000 // rough estimate for image content tokens (CLI legacy 256 underestimated real image costs, delaying compaction)
18
-
19
- /** Rough token count for a list of messages (body + reasoning + tool_calls params) */
20
- export function estimateTokens(messages) {
21
- let tokens = 0
22
- for (const m of messages) {
23
- if (typeof m.content === "string") tokens += estimateText(m.content)
24
- else if (Array.isArray(m.content)) {
25
- for (const part of m.content) {
26
- if (part.type === "text") tokens += estimateText(part.text)
27
- else if (part.type === "image_url") tokens += IMAGE_TOKEN_ESTIMATE
28
- }
29
- }
30
- if (typeof m.reasoning_content === "string") tokens += estimateText(m.reasoning_content)
31
- for (const tc of m.tool_calls ?? []) {
32
- tokens += estimateText(tc.function?.name ?? "") + estimateText(tc.function?.arguments ?? "")
33
- }
34
- }
35
- return tokens
36
- }
37
-
38
- const KEEP_HEAD = 0 // No dedicated head: earliest messages may be a COMPLETED earlier task in multi-task
39
- // sessions — keeping them verbatim anchored attention on stale work. Everything before the tail is
40
- // summarized (the summary itself distinguishes completed vs in-progress work; see SUMMARIZE_PROMPT).
41
- // Tail count formula (D4): window-adaptive (~30 msgs per 100K — old fixed 10 too thin on 1M), capped
42
- // at 40% of history; §6.4④ D-T1/D-T2 make the count only a CANDIDATE — a token budget (TAIL_BUDGET_FRACTION
43
- // × window − SUMMARY_TOKEN_ESTIMATE ≈1K, §6.9) tightens it over pair-safe boundaries when compaction runs,
44
- // never below TAIL_FLOOR_MESSAGES; ordinary sessions never reach it (D-T4: trigger 0.6 untouched).
45
- const TAIL_BUDGET_FRACTION = 0.15
46
- const SUMMARY_TOKEN_ESTIMATE = 1000 // §6.9: summary output target ~1K tokens — reserved from the 15%
47
- const TAIL_FLOOR_MESSAGES = 10 // §6.4④ D-T2: the tail keeps ≥10 verbatim messages — floor beats budget
48
- function keepTailSize(provider, historyLen) {
49
- // provider is guaranteed at every call site (runAgent always builds one); providerSpec
50
- // degrades to DEFAULT_SPEC (128K) only if provider is somehow absent — acceptable
51
- // because the 40% history cap still bounds the tail. providers[].context override
52
- // (K units) is honored here (PROVIDER.md §6.15 T-C2: tail formula follows the window).
53
- const ctxWindow = providerSpec(provider).context
54
- return Math.min(Math.max(10, Math.floor((ctxWindow / 100_000) * 30)), Math.floor(historyLen * 0.4))
55
- }
56
- // §6.4④ D-T1 tail token budget: window×15% − summary ~1K — the compressed history segment (summary + placeholder + tail) lands ≈ 15% (B 口径 §6.4④).
57
- function tailBudgetTokens(provider) {
58
- return Math.max(0, Math.floor(providerSpec(provider).context * TAIL_BUDGET_FRACTION) - SUMMARY_TOKEN_ESTIMATE)
59
- }
16
+ import { contextUsage, estimateTokens, collectStaleToolOutputs, keepTailSize, splitHistory, tailBudgetTokens } from "./token-window.mjs"
60
17
 
61
18
  export const SUMMARIZE_PROMPT = `The conversation above is our work log so far — summarize it into a compact summary for use as context in the ongoing conversation.
62
19
  Requirements:
@@ -93,72 +50,25 @@ const FALLBACK_NOTE =
93
50
  "Re-verify any state you need with tools before relying on it.]\n\n"
94
51
 
95
52
  /**
96
- * Split history into head / middle (to be summarized) / tail; return null if no middle to compress.
97
- * 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.
98
- * 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.
99
- * `budgetTokens` (optional, §6.4④ D-T1): when the candidate's estimate exceeds it, the boundary moves
100
- * forward until the tail fits — never below the D-T2 floor (10 msgs, or the candidate itself when
101
- * the 40% cap made it < 10 — short history).
53
+ * focus 指令块(F-CC2 · §6.16.2):模型主动压缩时追加在摘要指令**尾段**——摘要按它加权取舍。`anchor` = task/goal 状态行;
54
+ * `anchor == null`(无 task 且无 goal)⇒ **anchor 段省略**,focus 正文恒保留(不产空标题)。
102
55
  */
103
- function splitHistory(history, keepTail, budgetTokens = null) {
104
- if (history.length <= KEEP_HEAD + keepTail + 1) return null
105
- let headEnd = KEEP_HEAD
106
- // head must not end with dangling tool_calls: when assistant declares tool_calls, all its tool results must stay in head.
107
- // Parallel calls: one assistant followed by multiple tool messages — accepting only one still causes 400, must collect all
108
- if (history[headEnd - 1]?.role === "assistant" && history[headEnd - 1].tool_calls?.length) {
109
- while (headEnd < history.length && history[headEnd].role === "tool") headEnd++
110
- }
111
- const candidate = repairedTailStart(history, headEnd, history.length - keepTail)
112
- if (candidate <= headEnd) return null
113
- let tailStart = candidate
114
- // §6.4④ 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.
115
- if (budgetTokens > 0 && keepTail > TAIL_FLOOR_MESSAGES) {
116
- const floor = repairedTailStart(history, headEnd, history.length - TAIL_FLOOR_MESSAGES)
117
- if (floor > candidate) tailStart = tightenTailByBudget(history, candidate, floor, budgetTokens)
118
- }
119
- return { headEnd, tailStart }
120
- }
56
+ const focusBlock = (focus, anchor) =>
57
+ `\n\nThis compaction happens at my own request and is weighted toward the work coming next:\n${focus}\n\n` +
58
+ `Keep what that work needs at full fidelity — files, decisions, constraints, open threads; compress ` +
59
+ `everything else harder.` +
60
+ (anchor ? ` Current task/goal state (attached automatically):\n${anchor}` : "")
121
61
 
122
62
  /**
123
- * D5 tail-side pairing repair for a raw cut at history.length − tailCount: pull into the tail any
124
- * assistant whose tool results are in the tail (the summary swallowing the owner leaves orphan tool
125
- * results → protocol 400), then skip orphan tool messages at the new boundary. Single-assistant
126
- * assumption (nearest owner only — a tail spans at most one assistant→tools cycle); bounds-guarded.
63
+ * anchor 取值(§6.16.2「自动附任务 / 目标(F-CC2 明文)」):取值源 = **工具面单源**——`task` / `goal` 工具写入的
64
+ * `agent.tasks` / `agent.goal`,不新增第二份状态;行格式沿用既有任务重注入形态(`- [status] title` + goal 一行)。两者皆空 null(anchor 段整体省略)。
127
65
  */
128
- function repairedTailStart(history, headEnd, tailStart) {
129
- const tailToolIds = new Set()
130
- for (let i = tailStart; i < history.length; i++) {
131
- if (history[i].role === "tool") tailToolIds.add(history[i].tool_call_id)
132
- }
133
- for (let i = tailStart - 1; i > headEnd; i--) {
134
- const m = history[i]
135
- if (m.role === "assistant" && m.tool_calls?.some((tc) => tailToolIds.has(tc.id))) {
136
- tailStart = i
137
- break
138
- }
139
- }
140
- while (tailStart < history.length && tailStart > headEnd && history[tailStart].role === "tool") {
141
- tailStart++
142
- }
143
- return tailStart
144
- }
145
-
146
- /**
147
- * §6.4④ D-T1 budget tightening (pair-safe, review #2): walk the boundary FORWARD (fewer tail messages —
148
- * the rest joins the summary) while the tail's estimated tokens exceed the budget. Only pair-safe
149
- * positions may stop the walk: a boundary ON a tool message would orphan its owner assistant into the
150
- * middle (D5); pairing is contiguous in the machine line (§6.4③) — every non-tool boundary is safe.
151
- * No fit before the floor → keep the floor, accept the overrun.
152
- */
153
- function tightenTailByBudget(history, start, floorStart, budgetTokens) {
154
- const suffixTokens = new Array(history.length + 1)
155
- suffixTokens[history.length] = 0
156
- for (let i = history.length - 1; i >= 0; i--) suffixTokens[i] = suffixTokens[i + 1] + estimateTokens([history[i]])
157
- if (suffixTokens[start] <= budgetTokens) return start // already fits — ordinary sessions stay untouched (D-T2)
158
- for (let p = start + 1; p <= floorStart; p++) { // first fit keeps the most recent verbatim context
159
- if (history[p].role !== "tool" && suffixTokens[p] <= budgetTokens) return p
160
- }
161
- return floorStart
66
+ function anchorText(agent) {
67
+ const lines = []
68
+ const g = agent?.goal
69
+ if (g?.objective) lines.push(`- goal [${g.status}] ${g.objective}${g.criteria ? ` — done when: ${g.criteria}` : ""}`)
70
+ for (const t of agent?.tasks ?? []) lines.push(`- [${t.status}] ${t.title}`)
71
+ return lines.length > 0 ? lines.join("\n") : null
162
72
  }
163
73
 
164
74
  /**
@@ -359,21 +269,16 @@ function applyCompression(agent, headEnd, tailStart, note) {
359
269
  * generation is SILENT (never forwards onToken/onReasoning: the compaction process is an
360
270
  * internal mechanism, not a model reply); onCompressStart fires right before the summary call
361
271
  * (§6.8 D-C1, compression lifecycle visibility — panel start state)
362
- * @param {object} extras - { systemPrompt?, tools? } — estimated overhead for the pure-estimation
363
- * path (no measured baseline); the measured path already includes system+tools in prompt_tokens.
272
+ * @param {object} extras - { systemPrompt?, tools?, force?, focus? } — 固定开销面(system + tools 估计)+
273
+ * 模型主动压缩面(§6.16.2):`force` **只跳过** `tokens <= threshold` 早退,其余全同;`focus` 追加在摘要指令尾段。
364
274
  */
365
275
  export async function compressIfNeeded(agent, threshold, callbacks, extras = {}, signal) {
366
276
  const history = agent.history
367
- // Prefer the real baseline: the last response's prompt_tokens is the measured value for the full context (system+tools+history).
368
- // Subsequent appended messages use estimation as increment; when no measured value exists (first turn / after restore / right after compaction), fall back to pure estimation
369
- const overhead =
370
- (extras.systemPrompt ? estimateText(extras.systemPrompt) : 0) +
371
- (extras.tools ? estimateText(JSON.stringify(extras.tools)) : 0)
372
- const tokens =
373
- agent._lastPromptTokens != null
374
- ? agent._lastPromptTokens + estimateTokens(history.slice(agent._usageAtLen ?? history.length))
375
- : estimateTokens(history) + overhead
376
- if (tokens <= threshold) return false
277
+ // 单源(§6.16.4):total / overhead stats 面同一函数(既有内联式改调 contextUsage,判定语义零改)
278
+ const usage = contextUsage(agent, extras)
279
+ const tokens = usage.total
280
+ const overhead = usage.overhead
281
+ if (!extras.force && tokens <= threshold) return false
377
282
 
378
283
  const keepTail = keepTailSize(agent.provider, history.length)
379
284
  const split = splitHistory(history, keepTail, tailBudgetTokens(agent.provider))
@@ -398,8 +303,15 @@ export async function compressIfNeeded(agent, threshold, callbacks, extras = {},
398
303
  // the lifecycle is surfaced, never the summary body. N = the number of history messages being summarized.
399
304
  callbacks?.onCompressStart?.({ messages: middle.length })
400
305
  const startedAt = performance.now()
306
+ // F-CC2 焦点块(§6.16.2):仅模型主动面携带——追加在**指令末条尾段**(`SUMMARIZE_PROMPT` 文本零改,
307
+ // 只追加焦点块;无 focus ⇒ 请求体逐字节同修前——compress-form.test.mjs 零回归)。
308
+ const messages = buildCompressMessages(history, split.tailStart, extras?.systemPrompt, SUMMARIZE_PROMPT)
309
+ if (extras.focus) {
310
+ const last = messages.at(-1)
311
+ messages[messages.length - 1] = { ...last, content: last.content + focusBlock(String(extras.focus), anchorText(agent)) }
312
+ }
401
313
  const summary = await chat({ ...agent.provider, thinking: null }, {
402
- messages: buildCompressMessages(history, split.tailStart, extras?.systemPrompt, SUMMARIZE_PROMPT),
314
+ messages,
403
315
  tools: extras?.tools,
404
316
  signal,
405
317
  // §18.6 D-TR4:轨迹元数据增补——kind=compress(上下文构建面——agent 元数据透出;
@@ -430,6 +342,37 @@ export async function compressIfNeeded(agent, threshold, callbacks, extras = {},
430
342
  return true
431
343
  }
432
344
 
345
+ /** prune stub 逐字(§6.16.3):只给「已清理 + 原长度 + 重跑路径」——prune 是**删**不是摘要,不声称可复原。 */
346
+ const pruneStub = (chars) => `[pruned: stale tool output dropped (${chars} chars) — re-run the tool if you need it again.]`
347
+
348
+ /**
349
+ * 陈旧工具输出清理(F-CC3 · §6.16.3):合格集 = `token-window.mjs` 单源(保护尾外 ∧ `role:"tool"` ∧ ≥ 门槛);
350
+ * 命中项**原位换「内容」**(`history[i] = { ...m, content: stub }`——数组引用 / 长度 / 索引 / `tool_call_id` 全不变
351
+ * ⇒ 配对**结构上不可能被拆**)。记录面零改(copy-on-write:消息对象与人读线共享);基线失效同 `shrinkOversized` 先例。
352
+ * @returns {{pruned:number, freed:number, candidates:number, tailKept:number, belowMin:number}}
353
+ */
354
+ export function pruneStaleToolOutputs(agent) {
355
+ const history = agent.history
356
+ const stale = collectStaleToolOutputs(history, agent.provider)
357
+ const counts = {
358
+ pruned: stale.indexes.length,
359
+ freed: stale.tokens,
360
+ candidates: stale.candidates,
361
+ tailKept: stale.tailKept,
362
+ belowMin: stale.belowMin,
363
+ }
364
+ if (counts.pruned === 0) return counts
365
+ for (const i of stale.indexes) {
366
+ const m = history[i]
367
+ // 多模态 tool 结果(content 数组)整体替换为 stub ⇒ 图像 part 丢弃(不可再取——prune 是删;回执只给重跑路径)
368
+ const chars = typeof m.content === "string" ? m.content.length : JSON.stringify(m.content ?? "").length
369
+ history[i] = { ...m, content: pruneStub(chars) }
370
+ }
371
+ agent._lastPromptTokens = null
372
+ agent._usageAtLen = null
373
+ return counts
374
+ }
375
+
433
376
  /**
434
377
  * Deterministic truncation fallback: called when the summary LLM fails repeatedly, no network call.
435
378
  * Drops the middle so the task can continue. Returns whether truncation happened.
@@ -488,6 +431,8 @@ function shrinkOversized(agent, limit = OVERSIZE_CONTENT_LIMIT) {
488
431
  return shrunk
489
432
  }
490
433
 
434
+ // ─── 迁出面(import 面保持:TUI / verify-compress / VSC 对拍经本档取——先例 = 下方 explore-distill 再导出)──
435
+ export { estimateTokens }
491
436
  // ─── End-of-run exploration distillation(2026-09-05 module-split:524 > 500 硬限——verbatim
492
437
  // 迁至 explore-distill.mjs,语义零变——VS Code compact.mjs 同款联动;cross-repo parity 锚改指
493
438
  // explore-distill.mjs——消费方 import 面不变(re-export))───────────────────────
package/fts-text.mjs ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * fts-text.mjs — FTS5 语言面单一来源(会话索引批 · SESSION.md §6.19 D-SE44)。
3
+ *
4
+ * `segmentCJK` / `buildFtsQuery` 自 `memory/schema.mjs` / `memory/core.mjs` **外提**为本叶子档
5
+ * (两档 re-export 保名面——零行为变更):理由 = 会话索引不得把 memory 模块链拉进
6
+ * `read_history` 的装配装载面(W8 契约②)+ 机制单源(D2——写入侧与查询侧同一处理)。
7
+ * 消费者 = memory 面(写入 / 检索)+ 会话索引面(`session-index.mjs` / `session-index-build.mjs`)。
8
+ *
9
+ * 零项目内依赖(仅 `node:`)——叶子档,可被任意装配面静态 import。
10
+ */
11
+
12
+ /** 查询词元上限(超限截断——`buildFtsQuery` 单源)。 */
13
+ export const FTS_TOKEN_MAX = 16
14
+
15
+ /**
16
+ * CJK 逐字间隔:让 unicode61 把汉字 / 假名 / 谚文逐字当作独立 token。
17
+ * 写入与查询两侧必须用同一处理才可召回(两字词如「分号」→「分 号」短语仍命中;ASCII 保持整词)。
18
+ */
19
+ export function segmentCJK(text) {
20
+ return text.replace(
21
+ /[぀-ヿ㐀-䶿一-鿿豈-﫿가-힯]+/g,
22
+ (run) => [...run].join(" "),
23
+ )
24
+ }
25
+
26
+ /**
27
+ * 构造 FTS5 查询:先按空白 / 标点切 token,再对每个 token 做 CJK 逐字分段。
28
+ * 多字 CJK 词保持为 FTS5 短语(「分号」→「分 号」→ 短语查询,邻接精确匹配),
29
+ * 不同 token 以 OR 连接(「命名 规范」→「命 名」OR「规 范」,各自短语要求自身邻接)。
30
+ * 无 token ⇒ 返回空串(消费侧据此退化——SESSION.md §6.19 边界情形表)。
31
+ */
32
+ export function buildFtsQuery(query) {
33
+ const terms = query
34
+ .split(/[\s,,。、;;!!??()()"`]+/)
35
+ .map((t) => t.trim())
36
+ .filter(Boolean)
37
+ .slice(0, FTS_TOKEN_MAX)
38
+ .map((t) => segmentCJK(t))
39
+ if (terms.length === 0) return ""
40
+ return terms.map((t) => `"${t.replaceAll('"', '""')}"`).join(" OR ")
41
+ }
package/memory/core.mjs CHANGED
@@ -12,11 +12,14 @@ import { normalizeOrigin } from "./origin.mjs"
12
12
  import { readFile, stat, readdir, writeFile, mkdir } from "node:fs/promises"
13
13
  import { join } from "node:path"
14
14
  import { segmentCJK, VALID_TYPES, SCHEMA_VERSION } from "./schema.mjs"
15
+ // FTS 语言面单源外提(会话索引批 · SESSION.md §6.19):本档只取用 + re-export 保名面
16
+ // (零行为变更——实现住叶子档 `fts-text.mjs`)。
17
+ import { buildFtsQuery } from "../fts-text.mjs"
18
+ export { buildFtsQuery } from "../fts-text.mjs"
15
19
  import { safeSliceUTF16 } from "../text-budget.mjs"
16
20
 
17
21
  const EMBED_BATCH_SIZE = 256
18
22
  export const EMBED_TEXT_MAX_LEN = 2000
19
- const FTS_TOKEN_MAX = 16
20
23
  const DEFAULT_LIST_LIMIT = 50
21
24
 
22
25
  /**
@@ -299,20 +302,3 @@ export function clearPersonal(memory) {
299
302
  const { changes } = memory.db.prepare(`DELETE FROM entries`).run()
300
303
  return changes
301
304
  }
302
-
303
- /**
304
- * Build an FTS5 query: first split by whitespace/punctuation into tokens,
305
- * then apply CJK character segmentation to each token.
306
- * This keeps multi-character CJK words as FTS5 phrases ("分号" → "分 号" → phrase query, exact adjacency match),
307
- * while different tokens are joined with OR ("命名 规范" → "命 名" OR "规 范", each phrase requires its own adjacency).
308
- */
309
- export function buildFtsQuery(query) {
310
- const terms = query
311
- .split(/[\s,,。、;;!!??()()"`]+/)
312
- .map((t) => t.trim())
313
- .filter(Boolean)
314
- .slice(0, FTS_TOKEN_MAX)
315
- .map((t) => segmentCJK(t))
316
- if (terms.length === 0) return ""
317
- return terms.map((t) => `"${t.replaceAll('"', '""')}"`).join(" OR ")
318
- }
package/memory/schema.mjs CHANGED
@@ -9,6 +9,10 @@
9
9
  import { DatabaseSync } from "node:sqlite"
10
10
  import { mkdirSync } from "node:fs"
11
11
  import { dirname } from "node:path"
12
+ // FTS 语言面单源外提(会话索引批 · SESSION.md §6.19):本档只取用 + re-export 保名面
13
+ // (零行为变更——实现住叶子档 `fts-text.mjs`,会话索引面同源)。
14
+ import { segmentCJK } from "../fts-text.mjs"
15
+ export { segmentCJK } from "../fts-text.mjs"
12
16
 
13
17
  export const VALID_TYPES = new Set(["rule", "knowledge", "decision", "pattern"])
14
18
  export const SCHEMA_VERSION = 9
@@ -50,17 +54,6 @@ export const MAX_CODE_FILE_BYTES = 1024 * 1024 // 1 MB
50
54
  export const MAX_DOC_FILE_BYTES = 512 * 1024 // 512 KB
51
55
  export const BIG_FILE_LINES = 2000
52
56
 
53
- /**
54
- * CJK character-by-character spacing: makes unicode61 treat each Han/Kana/Hangul character as an independent token.
55
- * Both write and query must use the same processing for retrieval to match.
56
- */
57
- export function segmentCJK(text) {
58
- return text.replace(
59
- /[぀-ヿ㐀-䶿一-鿿豈-﫿가-힯]+/g,
60
- (run) => [...run].join(" "),
61
- )
62
- }
63
-
64
57
  /**
65
58
  * Open/initialize the memory store. dbPath is auto-created if missing.
66
59
  * The returned memory object is the interface; all subsequent functions take it as their first argument.
package/model-specs.mjs CHANGED
@@ -113,11 +113,26 @@ const MODEL_SPECS = [
113
113
  ["qwen3.6-35b-a3b", { context: 1_000_000, maxOutput: 65_536, thinking: true, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", reasoningEffortEnum: ["none", "minimal", "low", "medium", "high", "xhigh"], tempRange: [0, 2] }],
114
114
  // MiniMax series
115
115
  ["MiniMax-M3", { context: 1_000_000, maxOutput: 128_000, thinking: true, multimodal: true, cacheMode: "auto", thinkApi: "type", thinkEnabledValue: "adaptive", tempRange: [0, 2], noUsageStream: true }],
116
- // MiMo series (Xiaomi — OpenAI-compatible https://api.xiaomimimo.com/v1;
117
- // deep thinking via thinking.type, default ON; multi-turn tool calls MUST echo
118
- // reasoning_content back exactly like DeepSeek V4, else 400 on follow-ups)
119
- ["mimo-v2.5-pro", { context: 1_000_000, maxOutput: 128_000, thinking: true, thinkApi: "type", reasoningEcho: "required", tempRange: [0, 1.5] }],
120
- ["mimo-v2.5", { context: 1_000_000, maxOutput: 128_000, thinking: true, multimodal: true, thinkApi: "type", reasoningEcho: "required", tempRange: [0, 1.5] }],
116
+ // MiMo series (Xiaomi — OpenAI-compatible https://api.xiaomimimo.com/v1; deep thinking via
117
+ // thinking.type, default ON). Family echo policy stays conservative ("required" — tool rounds
118
+ // always echo); 2026-09-22 re-probe: value / missing field / empty string all 200 — the
119
+ // 2026-09-20 "must be passed back" 400 was NOT reproduced. v2.5 rows aligned 2026-09-22:
120
+ // maxOutput 131_072 = **校验级**; cacheMode "auto" = **实测** (2nd same-prefix round cached 18,816).
121
+ ["mimo-v2.5-pro", { context: 1_000_000, maxOutput: 131_072, thinking: true, cacheMode: "auto", thinkApi: "type", reasoningEcho: "required", tempRange: [0, 1.5] }],
122
+ ["mimo-v2.5", { context: 1_000_000, maxOutput: 131_072, thinking: true, multimodal: true, cacheMode: "auto", thinkApi: "type", reasoningEcho: "required", tempRange: [0, 1.5] }],
123
+ // MiMo V2.6 series (2026-09-22 launch — three independent rows, each probed individually).
124
+ // maxOutput 131_072 / tempRange [0, 1.5] = **校验级** (400 "at most 131072 completion tokens" /
125
+ // "temperature must be within [0, 1.5]", per model); thinking = **实测** (bare request carries
126
+ // reasoning_content; thinking.type disabled → rc gone ⇒ thinkApi "type" = measured face) and
127
+ // multimodal = **实测** (8×8 pure-red PNG, pro / flash answered "Red"); cacheMode "auto" = **实测**
128
+ // (cached_tokens 18,688). context 1_000_000 = **官方口径** (docs); reasoningEcho = **族沿用**.
129
+ ["mimo-v2.6-pro", { context: 1_000_000, maxOutput: 131_072, thinking: true, multimodal: true, cacheMode: "auto", thinkApi: "type", reasoningEcho: "required", tempRange: [0, 1.5] }],
130
+ // mimo-v2.6-flash: same row shape as pro — maxOutput 131_072 / tempRange [0, 1.5] = **校验级**,
131
+ // thinking / multimodal / cacheMode = **实测**, context = **官方口径**, reasoningEcho = **族沿用**.
132
+ ["mimo-v2.6-flash", { context: 1_000_000, maxOutput: 131_072, thinking: true, multimodal: true, cacheMode: "auto", thinkApi: "type", reasoningEcho: "required", tempRange: [0, 1.5] }],
133
+ // mimo-v2.6-pro-ultraspeed: same row shape — same grades as flash (**校验级** / **实测** /
134
+ // **官方口径** / **族沿用**); image answer at a 64-token budget was truncated — "Red" at 512.
135
+ ["mimo-v2.6-pro-ultraspeed", { context: 1_000_000, maxOutput: 131_072, thinking: true, multimodal: true, cacheMode: "auto", thinkApi: "type", reasoningEcho: "required", tempRange: [0, 1.5] }],
121
136
  ["minimax-m3", { context: 1_000_000, maxOutput: 128_000, thinking: true, multimodal: true, cacheMode: "auto", thinkApi: "type", thinkEnabledValue: "adaptive", tempRange: [0, 2], noUsageStream: true }],
122
137
  ["minimax-m1", { context: 256_000, maxOutput: 128_000, thinking: false, cacheMode: "auto", noUsageStream: true }],
123
138
  // Grok series (xAI — OpenAI-compatible)
@@ -254,8 +269,8 @@ export function providerSpec(provider) {
254
269
  *
255
270
  * `reasoningEcho:"required"` families (deepseek / kimi / mimo) MUST carry the reasoning echo on
256
271
  * every tool-call assistant message: the field is NEVER omitted — an absent/non-string
257
- * `response.reasoning` is echoed as `""` (live-shape probe 2026-09-20: missing field 400
258
- * "must be passed back"; empty string 200 with reasoning still returned). `optional` /
272
+ * `response.reasoning` is echoed as `""` (2026-09-22 re-probe: value / missing field / empty
273
+ * string all 200 — the 2026-09-20 "must be passed back" 400 was NOT reproduced). `optional` /
259
274
  * undeclared families (incl. DEFAULT_SPEC) never get the field (behavior byte-identical).
260
275
  *
261
276
  * Callers: thincoder-core/agent.mjs (main loop) · thincoder-core/advisor/loop.mjs (review
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thincoder/core",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "description": "ThinCoder shared core — shared mechanism modules + shared prompt content (CLI ↔ VS Code extension).",
5
5
  "keywords": [
6
6
  "ai",
@@ -31,8 +31,12 @@
31
31
  "exports": {
32
32
  "./*": "./*"
33
33
  },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
34
37
  "files": [
35
38
  "*.mjs",
39
+ "CHANGELOG.md",
36
40
  "advisor/",
37
41
  "agent/",
38
42
  "agent-tools/",
package/prompts/common.md CHANGED
@@ -110,8 +110,8 @@ Batch independent read-only tool calls into a single reply (they run concurrentl
110
110
 
111
111
  **Destructive-command red lines**:
112
112
  - **Never hand-roll delete verbs**: `rm` / `rmdir` / `del` / `rd` / `Remove-Item` and the like are never written into a command — deletions go through the existing tool face (`delete` / `git rm`, or a very narrow allowlist).
113
- - **Diagnostics are read-only**: existence / state checks use read-only commands only (`dir` / `ls` / `where` / `type`) — never smuggle a write or delete verb in, and never tag a real action "no-op / read-only".
114
- - **No silent masking**: no `2>nul` error-swallowing on destructive / write commands, no `&` (as opposed to `&&`) chaining — a failure must be visible.
113
+ - **Diagnostics are read-only**: existence / state checks use read-only commands only (`dir` / `ls` / `where` / `type`) — never smuggle a write or delete verb in, and never tag a real action "no-op / read-only". When a dedicated tool exists for the job, the tool-routing table still wins — this bullet covers the bare-command case only.
114
+ - **No silent masking**: no `2>nul` / `2>/dev/null` error-swallowing on destructive / write commands, no `&` / `;` (as opposed to `&&`) chaining — a failure must be visible.
115
115
  - **Confirm before irreversible actions**: stop before an irreversible action — the main session asks the user; a subagent raises an upstream `ask` (`notify_parent`).
116
116
  - **Boundary**: nothing at the tool layer catches this for you (no mechanical gate, no tool-semantics change) — you write the command, so you are the first line of defense.
117
117
 
@@ -3,7 +3,7 @@
3
3
  ## 🔴 Iron laws (top — highest-frequency hard constraints; violating them means rework)
4
4
  1. **Every dev task walks the four steps, no skipping**: Requirements → Design → Development → Testing. Three steps write docs (requirements/design/test) — jumping straight to code is wrong nine times out of ten.
5
5
  2. **Hit a wrong structure — fix it, don't defer it**: when a change collides with a wrong code-structure/state-ownership, fix it on the spot; never stack minimal patches to mask the symptom; a wrong structure touched by the current change must be fixed now.
6
- 3. **Work is tracked by task lists**: after requirements are confirmed, build task entries one per requirement (`task` session-level + persistent entries in requirement docs / ledger); no entry = the requirement hasn't landed.
6
+ 3. **Work is tracked by the batch record + the ledger**: after requirements are confirmed, build task entries one per requirement (entries land in batch record §2 + ledger rows); no entry = the requirement hasn't landed. (The `task` tool is mechanically disabled in engineering mode — the tracking authority is the batch record + the ledger.)
7
7
  4. **Zero discretion**: task size is not yours to judge — in this mode EVERY user request walks the full mandatory process, regardless of size.
8
8
  "The task is too small / just a quick fix" is never a reason to skip or compress steps; no change is exempt from landing in a design doc. If you find yourself weighing "does the process apply?", the answer is always the full process — the user already did the size judgment the moment they picked engineering mode.
9
9
 
@@ -32,7 +32,7 @@ Judge the **change face** before acting — different faces, different authoriza
32
32
  - **Acceptance** — each acceptance criterion machine-checkable;
33
33
  - **Dependencies** — upstream/downstream dependencies.
34
34
 
35
- Requirements done-criterion: all five elements present, concrete enough to design from (user confirmed, or answers no longer change the requirements). After confirmation, build task entries one per requirement — the task list is the marker that requirements were accepted.
35
+ Requirements done-criterion: all five elements present, concrete enough to design from (user confirmed, or answers no longer change the requirements). After confirmation, build task entries one per requirement — **the batch record §2 entry table + ledger rows** are the marker that requirements were accepted.
36
36
  2. **Design** — the approach, architecture, how to implement, landed in a design doc: problem statement, approach & rationale, full affected-file list, verifiable acceptance criteria (each pointing back to a user story). Design settles before you start.
37
37
  - Design = a check on requirements — wherever the design can't be written, the requirements weren't clear (ask back, don't invent).
38
38
  - **Requirement-gap stop chain**: exploration finds requirements that don't hold up / conflict with implementation / unclear ownership → **stop and bounce back to the main agent**; never pick one interpretation yourself and keep writing.
@@ -92,6 +92,21 @@ The report must contain: what changed / why, the paths of files touched, how you
92
92
  8. **Out-of-repo changes = stop and report**: when this round genuinely needs to touch out-of-repo files, **stop and report** (what / why),
93
93
  and the main agent handles it **in a separate round** — never write outside this repo in this round.
94
94
 
95
+ ### Multi-implementation-face discipline
96
+
97
+ When one mechanism lands on several implementation faces (multiple ends / languages / platforms / same-source mirror docs):
98
+
99
+ 1. **Each face implements independently, semantics from one source** — each face's own text is authoritative on that face; no byte-identical requirement, no cross-face sync dependency; consistency is guarded by the shared-source design + each face's own semantic anchors.
100
+ 2. **No cross-face rewrite from a face's artifacts (alignment goes through the shared design)** — never rewrite another face from any one face's actual artifacts; **this clause describes implementation form only, and is not grounds for keeping a difference** (cross-face difference disposition = clause 6).
101
+ 3. **Differences are reported as found** — a defect in the shared-source design discovered while landing ⇒ stop and report (design-doc fix + re-review); never deviate silently.
102
+ 4. **Face-specific sections stay on their own face** — a content section unique to one implementation face stays there, not merged into another face's layout; **a face-specific section is non-mechanism content; this clause is not grounds for keeping a mechanism-face difference** (mechanism-face handling = clause 6).
103
+ 5. **Verification duty for many-faces-one-mechanism design sets** — when one mechanism is designed across several faces, each face's design is written as its own document;
104
+ **the main agent MUST verify the pieces agree** (four axes = same rulings / same criteria / same-shaped boundaries / differences explicitly registered; a silent difference = drift);
105
+ the check runs once every face's design is on disk, inside the pre-review self-check; report its conclusion plus the difference table together with the "design ready for review" message.
106
+ 6. **Cross-face difference disposition (default and exception)** — **default = eliminate**: a mechanism-face difference ⇒ collapse to one authoritative implementation / align every face to one criterion;
107
+ **keeping one requires all three — structural asymmetry + evidence + an explicit ruling** (structural asymmetry = exists on one side only / depends on a host capability that side alone has); **this discipline is not grounds for keeping a difference**;
108
+ the difference register records **ruled keeps only** — it is not a fallback for undecided differences.
109
+
95
110
  ### Rules & exceptions (precedent is not grounds for exception)
96
111
  1. **The only grounds for an exception is a judgment line**: "it was always like this / already landed in this form / other batches' precedent / existing inventory" is never grounds to deviate from a rule —
97
112
  an exception can only be granted by a **machine-checkable judgment line**; no judgment line found → **follow the rule, or stop and report** — never pass on precedent.
@@ -65,7 +65,7 @@ Batch record, dispatch task books, verification conclusions, review firing, requ
65
65
  - **Parent does not ghost-write**: you do NOT write the design doc — anything needing change goes **via a fix round** to eng-designer; three exception categories: ① your own write domain (batch record §1/§4/§6 · ledger · requirement docs) ② purely mechanical form corrections (line folding / pointer form / counts) ③ **small edits** (single-line / table-level · no new semantics · verifiable one by one).
66
66
  **All three must be marked** ("parent direct execution" + revertable). **Judgment lines**: you content-writing on a dispatched surface without a fix round ⇒ violation; mechanical form correction unmarked ⇒ violation.
67
67
  - **Close three states (no "promises")**: each round's close allows **only three states** — ① **Do** (the action **was fired THIS round**: tool call / edit landed — the report only describes **what happened this round**); ② **Wait** (real dependency: waiting for the user's nod / a subagent's return — **must state what you're waiting for**); ③ **Stop** (anomaly / pending judgment — **state the stop point**). **The fourth state "promise" is forbidden**: writing "right away / next stroke / immediately / I will / up next" + an action WITHOUT firing that action in the same round ⇒ **treated as "not done"** — that wording must not be used: either do it in the same round, or rewrite it as "Wait".
68
- - **Debts go on the list**: undispatched / unfinished items of your own ⇒ **immediately written into the task list** (or batch record §6 unresolved) — debts **must be visible**, never living only in report prose waiting for the user to chase.
68
+ - **Debts go on the list**: undispatched / unfinished items of your own ⇒ **immediately written into batch record §6 unresolved (or a ledger row)** — debts **must be visible**, never living only in report prose waiting for the user to chase.
69
69
  - **Drain first (auto mode)**: while subagents are in flight, **clear your own queue in parallel** (verification / closure / settlement / mechanical corrections) — "waiting" **only holds for real dependencies**; parking doable work on "waiting" ⇒ violation.
70
70
  - **Implementation-round role routing (judge the change face first)**: before dispatching an implementation round, **judge the change face first** — **product-code face** (source/test dirs — per project declaration) → **eng-coder**; **doc face** (`docs/**` requirement/design docs) → **eng-designer**; **engineering-tools face** (`scripts/**` · CI) → **parent direct edit** (no spawn). Judgment lines: dispatching the doc face to eng-coder = violation (sole author of design/requirement docs is eng-designer); a dispatch that reflex-maps "design passed → implementation" to eng-coder without judging the face = violation.
71
71
  - **Round field**: dispatches **must carry「round」** — **initial round** = blank start, breadth exploration allowed; **fix round** = target pinned (finding-number list), **point fixes only** (number → change → read back), **no full exploration** (small fixes back to minute-level). **Dispatches pin coordinates (file:line), forbid "sweep everything X"** — never let a subagent explore what you already know.
package/session-gc.mjs CHANGED
@@ -229,7 +229,13 @@ export async function deleteColdCwd(hash, { dir = sessionsDir(), now = Date.now(
229
229
  * --confirm <hash> 回收指定组整前缀(警告 + 文件清单 + TOCTOU 重校验)
230
230
  * --confirm --all 逐候选同型回收
231
231
  * 返回进程退出码(0/1)。dir/prefix/now/out/err/probeFn 为测试注入缝(默认生产行为)。
232
+ *
233
+ * #178(hygiene-sweep 批):显式面进度 / 预估——**只增显示行,零触判据与删除集**(口径 = 台账
234
+ * #178 真机实测 ≈6ms/候选(6,887 候选 ≈41.6s),仅用于预估显示)。
232
235
  */
236
+ const GC_MS_PER_CANDIDATE = 6
237
+ const fmtGcEstimate = (n) => `~${Math.max(1, Math.round((n * GC_MS_PER_CANDIDATE) / 1000))}s`
238
+ const GC_PROGRESS_MIN = 2 // 候选数 ≥ 本值 ⇒ 出预估 / 逐组进度行(单组面输出逐字零变)
233
239
  export async function runSessionGc(args, { dir = sessionsDir(), prefix = null, cwd = process.cwd(), now = Date.now(), out = console.log, err = console.error, probeFn = probeOwnersAsync } = {}) {
234
240
  const dryRun = args.includes("--dry-run")
235
241
  const confirmIdx = args.indexOf("--confirm")
@@ -261,6 +267,7 @@ export async function runSessionGc(args, { dir = sessionsDir(), prefix = null, c
261
267
  for (const name of residue.candidates) out(` ${name}`)
262
268
  out(`Cold/stale project candidates (cold = manifest idle > 90 days; stale = no live owner + cwd unreachable/empty + 7-day window): ${candidates.length}`)
263
269
  for (const c of candidates) out(` ${c.hash} reason ${c.reason} files ${c.files.length}`)
270
+ if (candidates.length >= GC_PROGRESS_MIN) out(`Estimate: ${fmtGcEstimate(candidates.length)} to scan ${candidates.length} candidates (measured ≈${GC_MS_PER_CANDIDATE}ms/candidate).`)
264
271
  if (candidates.length) out('Run "thincoder session gc --confirm <hash>" (or --confirm --all) to move a project prefix into the recycle bin (recoverable: sessions-trash/<timestamp>/, 7 days).')
265
272
  return 0
266
273
  }
@@ -276,7 +283,11 @@ export async function runSessionGc(args, { dir = sessionsDir(), prefix = null, c
276
283
  err(`Refused: ${confirmTarget} is not a cold/stale project (active, recent, or unknown) — nothing deleted.`)
277
284
  return 1
278
285
  }
286
+ let i = 0
287
+ if (targets.length >= GC_PROGRESS_MIN) out(`${targets.length} groups to recycle — estimate ${fmtGcEstimate(targets.length)} (progress per group below).`)
279
288
  for (const t of targets) {
289
+ i++
290
+ if (targets.length >= GC_PROGRESS_MIN) out(`[${i}/${targets.length}] ${t.hash} — ${t.files.length} files`)
280
291
  out(`WARNING: 此操作将回收该 cwd 的全部会话历史 (hash ${t.hash}, ${t.files.length} files) → ${trashRootFor(dir)}:`)
281
292
  for (const name of t.files) out(` ${name}`)
282
293
  const r = await deleteColdCwd(t.hash, { dir, now, probeFn, entries: loopEntries })