@thincoder/core 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/abort-provenance.mjs +116 -0
  2. package/advisor/citations.mjs +139 -0
  3. package/advisor/compaction.mjs +174 -0
  4. package/advisor/convergence.mjs +80 -0
  5. package/advisor/history.mjs +77 -0
  6. package/advisor/loop.mjs +288 -0
  7. package/advisor/messages.mjs +299 -0
  8. package/advisor/notice.mjs +141 -0
  9. package/advisor/project-context.mjs +197 -0
  10. package/advisor/repos.mjs +150 -0
  11. package/advisor/run.mjs +190 -0
  12. package/advisor/truncate.mjs +57 -0
  13. package/advisor.mjs +281 -0
  14. package/agent/child-marks.mjs +24 -0
  15. package/agent/completion.mjs +145 -0
  16. package/agent/dispatch.mjs +493 -0
  17. package/agent/family-tools.mjs +174 -0
  18. package/agent/helpers.mjs +412 -0
  19. package/agent/post-turn.mjs +70 -0
  20. package/agent/record-results.mjs +174 -0
  21. package/agent/relay-prefix.mjs +39 -0
  22. package/agent/run-stages.mjs +244 -0
  23. package/agent/setup-reminders.mjs +199 -0
  24. package/agent/setup.mjs +234 -0
  25. package/agent/spawn-child.mjs +258 -0
  26. package/agent/suspension.mjs +240 -0
  27. package/agent/write-gate.mjs +87 -0
  28. package/agent-tools/advisor-async.mjs +481 -0
  29. package/agent-tools/advisor-settle.mjs +240 -0
  30. package/agent-tools/advisor.mjs +280 -0
  31. package/agent-tools/async-discard.mjs +143 -0
  32. package/agent-tools/async-settle.mjs +299 -0
  33. package/agent-tools/batch-segment.mjs +265 -0
  34. package/agent-tools/child-permission.mjs +45 -0
  35. package/agent-tools/consult.mjs +471 -0
  36. package/agent-tools/design-token.mjs +117 -0
  37. package/agent-tools/digest-budget.mjs +76 -0
  38. package/agent-tools/eng.mjs +102 -0
  39. package/agent-tools/escalate-async.mjs +302 -0
  40. package/agent-tools/goal.mjs +119 -0
  41. package/agent-tools/panel-blocks.mjs +24 -0
  42. package/agent-tools/parent-channel.mjs +231 -0
  43. package/agent-tools/plan.mjs +86 -0
  44. package/agent-tools/read-history.mjs +309 -0
  45. package/agent-tools/recent-changes.mjs +24 -0
  46. package/agent-tools/review-facts.mjs +31 -0
  47. package/agent-tools/settings.mjs +268 -0
  48. package/agent-tools/skill.mjs +63 -0
  49. package/agent-tools/spawn-gates.mjs +109 -0
  50. package/agent-tools/subagent-actions.mjs +496 -0
  51. package/agent-tools/subagent-async.mjs +456 -0
  52. package/agent-tools/subagent-panel.mjs +160 -0
  53. package/agent-tools/subagent-run.mjs +208 -0
  54. package/agent-tools/subagent-scheduler.mjs +446 -0
  55. package/agent-tools/subagent-spawn.mjs +478 -0
  56. package/agent-tools/subagent.mjs +419 -0
  57. package/agent-tools/task.mjs +87 -0
  58. package/agent-tools/timer.mjs +46 -0
  59. package/agent-tools/verify.mjs +295 -0
  60. package/agent-tools.mjs +23 -0
  61. package/agent.mjs +430 -0
  62. package/auto-think.mjs +115 -0
  63. package/compress-form.mjs +24 -0
  64. package/config-io.mjs +277 -0
  65. package/config-migrate.mjs +178 -0
  66. package/config-presets.mjs +49 -0
  67. package/config.mjs +419 -0
  68. package/context.mjs +495 -0
  69. package/conventions.mjs +223 -0
  70. package/embedding.mjs +120 -0
  71. package/escape.mjs +152 -0
  72. package/expand-home.mjs +16 -0
  73. package/explore-distill.mjs +152 -0
  74. package/generate-title.mjs +123 -0
  75. package/git/checkpoint.mjs +448 -0
  76. package/git/gitmem.mjs +100 -0
  77. package/history-window.mjs +179 -0
  78. package/hooks.mjs +108 -0
  79. package/i18n.mjs +106 -0
  80. package/index-bin.mjs +48 -0
  81. package/index-discover.mjs +176 -0
  82. package/ledger-cmd.mjs +209 -0
  83. package/ledger-db.mjs +86 -0
  84. package/ledger-surface.mjs +76 -0
  85. package/ledger.mjs +202 -0
  86. package/log.mjs +195 -0
  87. package/manifest.mjs +338 -0
  88. package/markdown.mjs +106 -0
  89. package/mcp/helpers.mjs +51 -0
  90. package/mcp/transport-http.mjs +248 -0
  91. package/mcp/transport-stdio.mjs +140 -0
  92. package/mcp/transport-ws.mjs +122 -0
  93. package/mcp.mjs +295 -0
  94. package/memory/code-index.mjs +219 -0
  95. package/memory/code-sync.mjs +427 -0
  96. package/memory/core.mjs +318 -0
  97. package/memory/delete.mjs +242 -0
  98. package/memory/docs.mjs +431 -0
  99. package/memory/file-walk.mjs +109 -0
  100. package/memory/origin.mjs +24 -0
  101. package/memory/scan.mjs +177 -0
  102. package/memory/schema.mjs +460 -0
  103. package/memory.mjs +21 -0
  104. package/model-ref.mjs +66 -0
  105. package/model-specs.mjs +277 -0
  106. package/package.json +32 -0
  107. package/peer-domains.mjs +265 -0
  108. package/peer-instances.mjs +178 -0
  109. package/permission.mjs +79 -0
  110. package/process-probe.mjs +315 -0
  111. package/prompt-files.mjs +113 -0
  112. package/prompt-overlays.mjs +78 -0
  113. package/prompts/advisor-design.md +43 -0
  114. package/prompts/advisor-round1.md +41 -0
  115. package/prompts/advisor-round2.md +46 -0
  116. package/prompts/advisor-round3.md +42 -0
  117. package/prompts/common.md +158 -0
  118. package/prompts/consult-base.md +19 -0
  119. package/prompts/discipline-engineering.md +123 -0
  120. package/prompts/discipline-normal.md +206 -0
  121. package/prompts/persona-coder.md +21 -0
  122. package/prompts/persona-eng-coder.md +41 -0
  123. package/prompts/persona-eng-designer.md +80 -0
  124. package/prompts/persona-engineering.md +160 -0
  125. package/prompts/persona-explore.md +15 -0
  126. package/prompts/persona-normal.md +35 -0
  127. package/prompts/persona-plan.md +27 -0
  128. package/provider/anthropic.mjs +225 -0
  129. package/provider/core.mjs +491 -0
  130. package/provider/errors.mjs +101 -0
  131. package/provider/google.mjs +257 -0
  132. package/provider/index.mjs +7 -0
  133. package/provider/list-models.mjs +163 -0
  134. package/provider/normalize.mjs +81 -0
  135. package/provider/rate.mjs +168 -0
  136. package/provider/responses.mjs +495 -0
  137. package/provider/retry.mjs +88 -0
  138. package/provider/sse.mjs +264 -0
  139. package/provider/wait-status.mjs +59 -0
  140. package/proxy.mjs +274 -0
  141. package/rules.mjs +53 -0
  142. package/session-gc.mjs +248 -0
  143. package/session-guard.mjs +59 -0
  144. package/session-lifecycle.mjs +305 -0
  145. package/session-migrate.mjs +48 -0
  146. package/session-rename.mjs +38 -0
  147. package/session-segments.mjs +99 -0
  148. package/session-slot-write.mjs +168 -0
  149. package/session-slots-manifest.mjs +264 -0
  150. package/session-slots.mjs +298 -0
  151. package/session-store.mjs +441 -0
  152. package/session.mjs +244 -0
  153. package/skills.mjs +234 -0
  154. package/text-budget.mjs +79 -0
  155. package/token-ttl.mjs +285 -0
  156. package/tool-docs/apply_patch.md +15 -0
  157. package/tool-docs/bash.md +38 -0
  158. package/tool-docs/delete.md +13 -0
  159. package/tool-docs/edit.md +30 -0
  160. package/tool-docs/execute.md +21 -0
  161. package/tool-docs/fetch.md +12 -0
  162. package/tool-docs/file_ops.md +17 -0
  163. package/tool-docs/get_current_time.md +8 -0
  164. package/tool-docs/git.md +54 -0
  165. package/tool-docs/glob.md +11 -0
  166. package/tool-docs/grep.md +19 -0
  167. package/tool-docs/hashline_edit.md +14 -0
  168. package/tool-docs/insert_after.md +15 -0
  169. package/tool-docs/lint.md +10 -0
  170. package/tool-docs/ls.md +12 -0
  171. package/tool-docs/lsp.md +10 -0
  172. package/tool-docs/process.md +10 -0
  173. package/tool-docs/question.md +16 -0
  174. package/tool-docs/read.md +20 -0
  175. package/tool-docs/read_image.md +8 -0
  176. package/tool-docs/tree.md +14 -0
  177. package/tool-docs/wait_for.md +22 -0
  178. package/tool-docs/websearch.md +16 -0
  179. package/tool-docs/write.md +11 -0
  180. package/tools/bash.mjs +276 -0
  181. package/tools/edit-batch.mjs +204 -0
  182. package/tools/edit-diff.mjs +388 -0
  183. package/tools/exec-run.mjs +43 -0
  184. package/tools/execute.mjs +243 -0
  185. package/tools/file.mjs +464 -0
  186. package/tools/git-checkpoint.mjs +143 -0
  187. package/tools/git-ext.mjs +173 -0
  188. package/tools/git.mjs +415 -0
  189. package/tools/glob-dialect.mjs +130 -0
  190. package/tools/index.mjs +76 -0
  191. package/tools/linter.mjs +120 -0
  192. package/tools/lsp.mjs +335 -0
  193. package/tools/ops.mjs +293 -0
  194. package/tools/patch.mjs +290 -0
  195. package/tools/question.mjs +26 -0
  196. package/tools/repomap.mjs +314 -0
  197. package/tools/search.mjs +248 -0
  198. package/tools/shared.mjs +467 -0
  199. package/tools/tree.mjs +81 -0
  200. package/tools/web.mjs +224 -0
  201. package/tools/write-path.mjs +191 -0
  202. package/traces/trace-store.mjs +303 -0
  203. package/undo-stack.mjs +47 -0
@@ -0,0 +1,179 @@
1
+ /**
2
+ * history-window.mjs — lazy human-line window (restore / render-facing):
3
+ * historyWindow + tool-call/result pairing + the real-user-message predicate.
4
+ *
5
+ * Pure functions — no fs, no host imports (node --test drives it directly).
6
+ * Window semantics follow the restore contract (turnStart / reminder-skip /
7
+ * reasoning), plus one frame rule: an assistant frame becomes ONE message
8
+ * carrying its tool cards nested (rules below).
9
+ *
10
+ * 承接:会话数据面的「人读线」窗口切分(分段恢复 / 逐页回看)——与磁盘会话档
11
+ * 同源(`{hash}.json.N`),本档只读数组、不触盘。
12
+ */
13
+
14
+ /** Real user message predicate (shared by the slot metadata digest and the window).
15
+ * NOTE: returns true for an EMPTY string content — rule 1 layers a trim() check on top.
16
+ * Null-safe(非对象 / 缺内容 ⇒ false——调用面含批量过滤)。 */
17
+ export function isRealUserMsg(m) {
18
+ return m?.role === "user" && typeof m?.content === "string" && !m.content.startsWith("[System reminder:")
19
+ }
20
+
21
+ /** Page size for lazy history loading (initial paint + scroll-back pages).
22
+ * 200 = the first-window size parity with the main line's initial window; loadOlder
23
+ * scroll-back pages step by the same constant. */
24
+ export const HISTORY_PAGE_SIZE = 200
25
+
26
+ /** Read-side timestamp compat: the real-message writer stamps `ts` (epoch-ms); older
27
+ * files may carry `timestamp`; even older carry neither → null. Field name on the
28
+ * wire stays `timestamp` (consumers zero-rename). */
29
+ function tsOf(m) {
30
+ const ts = m?.ts ?? m?.timestamp
31
+ return typeof ts === "number" ? ts : null
32
+ }
33
+
34
+ function kindOf(m) {
35
+ return m?.type ?? m?.role
36
+ }
37
+
38
+ function toolCallsOf(m) {
39
+ if (!m || !Array.isArray(m.tool_calls)) return []
40
+ return m.tool_calls.filter((tc) => tc && typeof tc === "object")
41
+ }
42
+
43
+ function reasoningOf(m) {
44
+ const r = m.reasoning_content ?? m.reasoning
45
+ return typeof r === "string" && r.trim() !== "" ? r : null
46
+ }
47
+
48
+ /** Assistant frame text = string content only (multimodal arrays are not renderable). */
49
+ function textOf(m) {
50
+ return typeof m.content === "string" ? m.content : null
51
+ }
52
+
53
+ /** Ghost frame (rules 2/6): no visible text ∧ no tool_calls ∧ no reasoning → skip
54
+ * (everything else is kept — a content-null pure-tool frame renders through its cards). */
55
+ function isGhostAssistant(m) {
56
+ const text = textOf(m)
57
+ const hasText = text !== null && text.trim() !== ""
58
+ return !hasText && toolCallsOf(m).length === 0 && reasoningOf(m) === null
59
+ }
60
+
61
+ /**
62
+ * Rules 3/4 — full-history rolling pairing: every tool entry is matched to the most
63
+ * recent assistant frame (before it) that declared its tool_call_id; each tool entry
64
+ * is consumed at most once (parallel batches complete out of order — id matching,
65
+ * declaration order preserved per frame). Orphan determination is against the FULL
66
+ * history: a window-local "orphan" whose frame lives outside the window is still
67
+ * consumed here, so it never renders standalone on a second page (防跨页双显).
68
+ * Returns { frameResults, ownerOf }:
69
+ * - frameResults: frame idx → Map(callId → tool entry idx) — cards per frame
70
+ * - ownerOf: tool entry idx → consuming frame idx (absent = true orphan)
71
+ */
72
+ function pairToolEntries(history) {
73
+ const total = history.length
74
+ const frameResults = new Map()
75
+ const ownerOf = new Map()
76
+ const pending = [] // unpaired declarations (declaration order) — [{ frame, callId }]
77
+ for (let i = 0; i < total; i++) {
78
+ const m = history[i]
79
+ if (kindOf(m) === "assistant") {
80
+ for (const tc of toolCallsOf(m)) {
81
+ if (tc.id !== undefined && tc.id !== null) pending.push({ frame: i, callId: tc.id })
82
+ }
83
+ } else if (kindOf(m) === "tool" && m.tool_call_id !== undefined && m.tool_call_id !== null) {
84
+ // most-recent declarer first (a normal file has at most one unpaired declaration per id)
85
+ for (let p = pending.length - 1; p >= 0; p--) {
86
+ if (String(pending[p].callId) === String(m.tool_call_id)) {
87
+ const { frame } = pending[p]
88
+ pending.splice(p, 1)
89
+ let map = frameResults.get(frame)
90
+ if (!map) { map = new Map(); frameResults.set(frame, map) }
91
+ map.set(String(m.tool_call_id), i)
92
+ ownerOf.set(i, frame)
93
+ break
94
+ }
95
+ }
96
+ }
97
+ }
98
+ return { frameResults, ownerOf }
99
+ }
100
+
101
+ /**
102
+ * Window into the human line for lazy history loading (rules 1-6). `before` = null
103
+ * takes the LAST page (first paint); otherwise the page [s, e) ending just before
104
+ * `before`. Half-open [s, e) keeps loadOlder pages from re-rendering the boundary
105
+ * message. `idx` values are GLOBAL history indexes — pagination never renumbers.
106
+ */
107
+ export function historyWindow(history, before, pageSize = HISTORY_PAGE_SIZE) {
108
+ const total = Array.isArray(history) ? history.length : 0
109
+ if (total === 0) return { messages: [], hasOlder: false }
110
+ const { frameResults, ownerOf } = pairToolEntries(history)
111
+ const end = before == null ? total : Math.max(0, Math.min(before, total))
112
+ const start = Math.max(0, end - pageSize)
113
+ const messages = []
114
+ const visible = (i) => {
115
+ const k = kindOf(history[i])
116
+ if (k === "user") {
117
+ const m = history[i]
118
+ return isRealUserMsg(m) && typeof m.content === "string" && m.content.trim() !== ""
119
+ }
120
+ if (k === "assistant") return !isGhostAssistant(history[i])
121
+ if (k === "tool") return !ownerOf.has(i) // true orphan (unowned across the whole history) → top-level fallback
122
+ return false
123
+ }
124
+ for (let i = start; i < end; i++) {
125
+ const m = history[i]
126
+ const kind = kindOf(m)
127
+ if (kind === "user") {
128
+ // Rule 1: skip = ¬(isRealUserMsg ∧ content.trim() ≠ "")
129
+ if (isRealUserMsg(m) && typeof m.content === "string" && m.content.trim() !== "") {
130
+ messages.push({ kind: "user", text: m.content, timestamp: tsOf(m), idx: i })
131
+ }
132
+ } else if (kind === "assistant") {
133
+ if (isGhostAssistant(m)) continue
134
+ // Rule 5: turnStart = the previous VISIBLE message is a user or there is none —
135
+ // scan back past skipped entries (reminder / consumed tool / ghost frame — a skip
136
+ // never resets the turn, matching the main line's in-turn semantics)
137
+ let prevVisibleKind = null
138
+ for (let j = i - 1; j >= 0; j--) {
139
+ if (visible(j)) { prevVisibleKind = kindOf(history[j]); break }
140
+ }
141
+ // Rules 3/4: frame tools[] = one card per declared call, declaration order (a result
142
+ // may sit past the window's end — pairing is against the full history)
143
+ const matched = frameResults.get(i)
144
+ const tools = []
145
+ for (const tc of toolCallsOf(m)) {
146
+ const toolIdx = matched?.get(String(tc.id))
147
+ const toolMsg = toolIdx !== undefined ? history[toolIdx] : null
148
+ tools.push({
149
+ id: tc.id ?? null,
150
+ name: tc.function?.name ?? null,
151
+ args: typeof tc.function?.arguments === "string" ? tc.function.arguments : null,
152
+ result: toolMsg && typeof toolMsg.content === "string" ? toolMsg.content : null,
153
+ })
154
+ }
155
+ messages.push({
156
+ kind: "assistant",
157
+ text: textOf(m),
158
+ reasoning: reasoningOf(m),
159
+ timestamp: tsOf(m),
160
+ idx: i,
161
+ turnStart: prevVisibleKind === null || prevVisibleKind === "user",
162
+ tools,
163
+ })
164
+ } else if (kind === "tool") {
165
+ // A consumed tool entry produces no message of its own (rendered with its frame) —
166
+ // only a true orphan falls back to the top level (rules 3/4)
167
+ if (!ownerOf.has(i)) {
168
+ messages.push({
169
+ kind: "tool",
170
+ name: typeof m.name === "string" ? m.name : null,
171
+ text: typeof m.content === "string" ? m.content : null,
172
+ timestamp: tsOf(m),
173
+ idx: i,
174
+ })
175
+ }
176
+ }
177
+ }
178
+ return { messages, hasOlder: start > 0 }
179
+ }
package/hooks.mjs ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * hooks.mjs — Lifecycle hook engine
3
+ *
4
+ * Hooks are user-defined shell commands executed at key agent lifecycle points.
5
+ * Configured in ~/.thincoder/config.json under "hooks".
6
+ *
7
+ * Event types:
8
+ * PreToolUse — before each tool call (can block execution)
9
+ * PostToolUse — after successful tool execution
10
+ * PostToolUseFailure — after failed tool execution
11
+ * Stop — main-session run end (no tool name; block is meaningless)
12
+ *
13
+ * Each hook: { matcher?, command, args?, timeout?, action? }
14
+ * matcher: regex against tool name (default: match all)
15
+ * command: executable path/name
16
+ * args: optional CLI args; without args, stdin receives JSON payload
17
+ * timeout: ms (default 10000)
18
+ * action: "allow" | "block" | "notify" (default "notify")
19
+ *
20
+ * "block" hooks: exit code 0 = allow, non-zero = block.
21
+ * "allow"/"notify": exit code ignored.
22
+ */
23
+
24
+ import { spawn } from "node:child_process"
25
+
26
+ /** @param {string} event @param {object} ctx @returns {Promise<boolean>} false if blocked */
27
+ export async function runHooks(event, ctx) {
28
+ const hooks = ctx.agent?.config?.hooks?.[event]
29
+ if (!hooks?.length) return true
30
+
31
+ for (const hook of hooks) {
32
+ // Matcher = tool-name regex: tool events always carry a toolName; no-tool-name
33
+ // events (Stop) ignore it — a configured matcher must not silently never fire.
34
+ if (hook.matcher && ctx.toolName != null) {
35
+ try {
36
+ if (!new RegExp(hook.matcher).test(ctx.toolName)) continue
37
+ } catch { /* invalid regex → skip */ }
38
+ }
39
+
40
+ const allowed = await runOneHook(event, hook, ctx)
41
+ if (hook.action === "block" && !allowed) return false
42
+ }
43
+ return true
44
+ }
45
+
46
+ async function runOneHook(event, hook, ctx) {
47
+ const payload = JSON.stringify({
48
+ event,
49
+ toolName: ctx.toolName ?? null,
50
+ toolArgs: ctx.toolArgs ?? null,
51
+ result: ctx.result ?? null,
52
+ error: ctx.error?.message ?? null,
53
+ // Event-specific fields (Stop: turn / reason) — existing callers pass no extra ⇒ payload unchanged.
54
+ ...(ctx.extra ?? {}),
55
+ timestamp: new Date().toISOString(),
56
+ })
57
+
58
+ return new Promise((resolve) => {
59
+ const timeout = hook.timeout ?? 10_000
60
+ let settled = false
61
+ let guard = null
62
+ const done = (code) => {
63
+ if (settled) return
64
+ settled = true
65
+ if (guard) clearTimeout(guard) // #135-④:守卫句柄收尾清理(unref 后的显式释放)
66
+ resolve(code === 0)
67
+ }
68
+
69
+ let proc
70
+ try {
71
+ // 不传 spawn 的 `timeout` 选项(#135-④):其内部定时器在 spawn 失败(ENOENT)路径
72
+ // 不被清理、也不 unref ⇒ 单独维持事件循环整整 `timeout` ms(实测:命令不存在时
73
+ // 10023ms 退出,去掉该选项后 6ms)——超时杀子进程改由下方自有 guard 承担(同时点、
74
+ // 同默认信号 SIGTERM,语义等价)。
75
+ if (hook.args?.length) {
76
+ proc = spawn(hook.command, hook.args, {
77
+ stdio: ["pipe", "ignore", "ignore"],
78
+ windowsHide: true,
79
+ })
80
+ } else {
81
+ proc = spawn(hook.command, [], {
82
+ stdio: ["pipe", "ignore", "ignore"],
83
+ windowsHide: true,
84
+ })
85
+ }
86
+ } catch {
87
+ // command not found or spawn failure — don't block, don't crash
88
+ return resolve(true)
89
+ }
90
+
91
+ proc.on("error", () => done(0)) // spawn failure → allow
92
+ proc.on("close", (code) => done(code ?? 0))
93
+ proc.on("exit", (code) => done(code ?? 0))
94
+
95
+ // Send payload via stdin
96
+ try { proc.stdin?.end(payload) } catch { /* */ }
97
+
98
+ // Timeout guard(#135-④:`timeout` ms 到点杀掉子进程 + 放行——原由 spawn 的 `timeout`
99
+ // 选项承担,见上)。句柄捕获 + `unref()`:进程存活期间守卫照常触发(真 CLI 运行事件循环
100
+ // 另有 handle),但不再单独维持事件循环存活 ⇒ 子进程钩子用例的测试进程零尾挂;
101
+ // 任一出口经 `done` 收尾 clearTimeout(house 先例 = mcp/transport-stdio.mjs:20)。
102
+ guard = setTimeout(() => {
103
+ try { proc.kill() } catch { /* already gone */ }
104
+ done(0)
105
+ }, timeout)
106
+ guard.unref?.()
107
+ })
108
+ }
package/i18n.mjs ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * i18n.mjs — 核内文案常量容器(§2.5 #185 / 丁组 D1 裁定「容器归一、投影端差」)。
3
+ *
4
+ * 形态(用户 2026-09-13 裁定 · 按建议):
5
+ * - `CORE_MESSAGES` = 键 → `{ en, zh }`:核内文案**单一权威容器**(本批收编两端同语义文案)。
6
+ * - `t(key, vars, locale)` = 核侧解析(CLI 直接用常量;缺键回退返回 key——不抛)。
7
+ * - `projectDictionary(locale)` = **VSC 字典投影**:核常量 → VSC `locales/*.json` 的扁平
8
+ * 字典形态(VSC 的 `i18n.mjs` `t()` 壳在 S2 接线时并入本投影,壳内零文案)。
9
+ *
10
+ * 契约(CORE-UNIFICATION §2.12.2 第 12 行 · 输出文案类):**机器消费面冻结**——本表键值
11
+ * 与现 VSC `locales/{en,zh}.json` 逐字一致(已锁定串不得改);人读文案后续变更须登记 +
12
+ * CHANGELOG(S2 落地物)。`${name}` = 插值占位(两端同约定)。
13
+ *
14
+ * 边界:本模块只收「核域」文案(压缩 / 消化 / 限流状态 / 异步池状态 / 会诊 / 任务 / goal /
15
+ * 思考状态);webview 面板专属文案(welcome / toolbar / settings / perm 卡等)留在 VSC 侧
16
+ * 字典(④ 端特有面——宿主 UI)。
17
+ */
18
+
19
+ /** 支持的语言(与 VSC locales 文件名一致)。 */
20
+ export const SUPPORTED_LOCALES = ["en", "zh"]
21
+
22
+ /**
23
+ * 核内文案常量容器:键 → { en, zh }。
24
+ * 值 = 现 VSC `locales/{en,zh}.json` 逐字(机器消费面冻结——改值须走 A11 登记 + CHANGELOG)。
25
+ */
26
+ export const CORE_MESSAGES = Object.freeze({
27
+ // ── 压缩(context compaction)──
28
+ "compress.start": { en: "Compressing context… (summarizing ${n} messages)", zh: "正在压缩上下文…(摘要 ${n} 条消息)" },
29
+ "compress.starting": { en: "Compressing context…", zh: "正在压缩上下文…" },
30
+ "compress.done": { en: "Compressed: ${tokens} tokens freed (${seconds}s)", zh: "已压缩:释放 ${tokens} tokens(${seconds} 秒)" },
31
+ "compress.failed": { en: "Compression failed: ${error}", zh: "压缩失败:${error}" },
32
+ "compress.fallback": { en: "Compression failed — fallback: truncated to ${n} messages", zh: "压缩失败 — 降级:截断为 ${n} 条消息" },
33
+ // ── 消化(auto-turn digest)──
34
+ "digest.start": { en: "Digesting ${n} background report(s)…", zh: "正在消化 ${n} 份后台报告…" },
35
+ "digest.done": { en: "Digested ${n} background report(s) (${seconds}s)", zh: "已消化 ${n} 份后台报告(${seconds}s)" },
36
+ "digest.aborted": { en: "Digestion interrupted (${seconds}s)", zh: "消化中断(${seconds}s)" },
37
+ "digest.turnLabel": { en: "[auto-turn: digesting finished subagent reports…]", zh: "自动回合:消化已完成的子代理报告…" },
38
+ // M4(2026-09-20 端差·显示面消差批):起跑标签三档(en = CLI `suspension-drive.mjs:175-176`
39
+ // 逐字;zh = 与上方既有档同式译法)——VSC 本地档不重复定义(单一权威容器)。
40
+ "digest.turnLabelAsk": { en: "[auto-turn: answering a subagent's in-flight message…]", zh: "自动回合:答复子代理的在飞提问…" },
41
+ "digest.turnLabelAuto": { en: "[auto-turn: continuing background work…]", zh: "自动回合:继续后台推进…" },
42
+ "digest.capAuto": { en: "[auto-turn: continuing past turn cap…]", zh: "自动回合:越过轮次上限,继续推进…" },
43
+ "digest.capStop": { en: "[auto-turn stopped at ${turns} turns — partial digest; finished reports stay in history]", zh: "自动回合在 ${turns} 轮处停止——部分消化;已完成的报告保留在历史中" },
44
+ // ── 限流 / 供应商状态行 ──
45
+ "status.rateWait": { en: "TPM throttle wait ~${s}s", zh: "TPM 限流等待 ~${s}s" },
46
+ "status.rateLimited": { en: "Rate-limited 429, retry in ${s}s", zh: "限流 429,${s}s 后重试" },
47
+ "status.overloaded": { en: "Server overloaded, retrying in ${s}s", zh: "服务过载,${s}s 后重试" },
48
+ "status.quota": { en: "quota exhausted: ${msg}", zh: "配额耗尽:${msg}" },
49
+ // ── 异步池状态词 ──
50
+ "sub.running": { en: "running", zh: "运行中" },
51
+ "sub.queued": { en: "queued", zh: "排队中" },
52
+ "sub.stopped": { en: "stopped", zh: "已停止" },
53
+ "sub.done": { en: "done", zh: "完成" },
54
+ "sub.error": { en: "error", zh: "错误" },
55
+ "sub.cancelled": { en: "cancelled", zh: "已取消" },
56
+ "sub.awaitingDigest": { en: "done · awaiting digestion", zh: "已完成 · 等待消化" },
57
+ "sub.queueSlot": { en: "queued · position ${n} (slot full)", zh: "排队中 · 位置 ${n}(槽满等位)" },
58
+ // ── 会诊状态词 ──
59
+ "consult.answered": { en: "answered", zh: "已回复" },
60
+ "consult.terminated": { en: "terminated", zh: "已终止" },
61
+ // ── 任务 / goal ──
62
+ "task.in_progress": { en: "in progress", zh: "进行中" },
63
+ "goal.criteria": { en: "Criteria", zh: "验收标准" },
64
+ // ── 思考 / 工具状态词 ──
65
+ "status.stopped": { en: "[stopped]", zh: "[已停止]" },
66
+ "status.thinking": { en: "Thinking", zh: "思考中" },
67
+ "status.turn": { en: "turn ${n}/${m}", zh: "轮次 ${n}/${m}" },
68
+ "tool.running": { en: "running…", zh: "执行中…" },
69
+ "tool.done": { en: "done", zh: "完成" },
70
+ })
71
+ // 深度冻结(机器消费面冻结——嵌套 { en, zh } 也冻结;浅冻结下值可被静默改写)。
72
+ for (const entry of Object.values(CORE_MESSAGES)) Object.freeze(entry)
73
+
74
+ /** 语言归一(BCP-47:`zh-CN` → `zh`;未知 → `en`——镜像 VSC `_load` 的候选回退)。 */
75
+ export function normalizeLocale(locale) {
76
+ const raw = String(locale ?? "").trim()
77
+ if (!raw) return "en"
78
+ if (SUPPORTED_LOCALES.includes(raw)) return raw
79
+ const base = raw.split("-")[0]
80
+ return SUPPORTED_LOCALES.includes(base) ? base : "en"
81
+ }
82
+
83
+ /**
84
+ * 核侧解析:`t("compress.starting")` / `t("digest.done", { n: 2, seconds: "1.2" })`。
85
+ * 缺键回退返回 key 本身(两端同口径——不抛)。
86
+ */
87
+ export function t(key, vars = {}, locale = "en") {
88
+ const entry = CORE_MESSAGES[key]
89
+ if (entry === undefined) return key
90
+ let val = entry[normalizeLocale(locale)] ?? entry.en
91
+ for (const [k, v] of Object.entries(vars)) {
92
+ val = val.replace("${" + k + "}", String(v))
93
+ }
94
+ return val
95
+ }
96
+
97
+ /**
98
+ * VSC 字典投影:核常量 → 扁平 `{ key: value }`(VSC `locales/*.json` 形态)。
99
+ * S2 接线:VSC `i18n.mjs` 的 `_load` 以本投影为核域底座 + 端特有键(webview 面)叠加。
100
+ */
101
+ export function projectDictionary(locale = "en") {
102
+ const lang = normalizeLocale(locale)
103
+ const out = {}
104
+ for (const [key, entry] of Object.entries(CORE_MESSAGES)) out[key] = entry[lang] ?? entry.en
105
+ return out
106
+ }
package/index-bin.mjs ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * index-bin.mjs — binary I/O for the semantic vector index(CORE-UNIFICATION §2.5 #137
3
+ * 「融合:随核内索引面一并归位(向量编解码)」)。
4
+ *
5
+ * 来源 = `thincoder-vscode/src/index-bin.mjs`(VSC 拆档——CLI 侧无切分档)——**逐字随迁**;
6
+ * 零依赖(只 `node:buffer` 的 `Buffer`)⇒ 直接归位。
7
+ *
8
+ * vectors.bin layout: [4B dim][4B count][count × 4B offsets][all raw Float32 vectors]
9
+ */
10
+
11
+ export function encodeVectors(dim, vectors) {
12
+ const count = vectors.length
13
+ const headerSize = 8 + count * 4
14
+ const dataSize = count * dim * 4
15
+ const buf = Buffer.alloc(headerSize + dataSize)
16
+
17
+ buf.writeUInt32LE(dim, 0)
18
+ buf.writeUInt32LE(count, 4)
19
+
20
+ let offset = headerSize
21
+ for (let i = 0; i < count; i++) {
22
+ buf.writeUInt32LE(offset, 8 + i * 4)
23
+ const vec = vectors[i]
24
+ for (let j = 0; j < dim; j++) {
25
+ buf.writeFloatLE(vec[j], offset + j * 4)
26
+ }
27
+ offset += dim * 4
28
+ }
29
+
30
+ return buf
31
+ }
32
+
33
+ export function decodeVectors(buf) {
34
+ const dim = buf.readUInt32LE(0)
35
+ const count = buf.readUInt32LE(4)
36
+
37
+ const vectors = []
38
+ for (let i = 0; i < count; i++) {
39
+ const offset = buf.readUInt32LE(8 + i * 4)
40
+ const vec = new Float32Array(dim)
41
+ for (let j = 0; j < dim; j++) {
42
+ vec[j] = buf.readFloatLE(offset + j * 4)
43
+ }
44
+ vectors.push(vec)
45
+ }
46
+
47
+ return { dim, vectors }
48
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * index-discover.mjs — file discovery + kind classification for the semantic index
3
+ * (CORE-UNIFICATION §2.5 #137「融合:随核内索引面一并归位(走查规则)」)。
4
+ *
5
+ * 来源 = `thincoder-vscode/src/index-discover.mjs`(VSC 拆档——CLI 无切分档;VSC 头注自述
6
+ * 「CLI-aligned」⇒ 走查规则两端同源)——**逐字随迁**;依赖 `./conventions.mjs`(核内在位)。
7
+ *
8
+ * PORTABILITY VSC mirror · VP-9 (P9): the extension tables were 14 code + 5 doc
9
+ * entries with no declaration surface — a project in any other mainstream language
10
+ * was invisible AND undeclarable, silently. Now: the tables carry the CLI-aligned
11
+ * additions, `.thincoder/conventions.json` (index.codeExtensions / index.docExtensions)
12
+ * unions in project-specific extensions, and files whose extension is in NO list are
13
+ * TALLIED (`collectUnlisted`) so "my .xyz files are not searchable" stops being
14
+ * invisible (buildIndex → panel hint + a log event).
15
+ */
16
+ import { readdirSync } from "node:fs"
17
+ import { join, relative } from "node:path"
18
+ import { DEFAULT_CONVENTIONS, loadConventions } from "./conventions.mjs"
19
+
20
+ const CODE_EXTS = new Set([
21
+ ".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx", ".py", ".rs", ".go", ".java", ".c", ".cpp", ".h", ".hpp",
22
+ // VP-9 additions (CLI-table alignment): languages whose absence made a whole
23
+ // project invisible. Anything still missing is declarable (index.codeExtensions).
24
+ ".mts", ".cts", ".dart", ".lua", ".cs", ".fs", ".fsx", ".clj", ".cljs", ".ex", ".exs", ".erl", ".hrl",
25
+ ".scala", ".pl", ".pm", ".r", ".jl", ".zig", ".groovy", ".ps1", ".proto", ".graphql", ".tf", ".hcl",
26
+ ])
27
+ const DOC_EXTS = new Set([".md", ".markdown", ".txt", ".rst", ".adoc", ".mdx", ".org", ".wiki", ".tex"])
28
+ export const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", ".turbo", "coverage", "__pycache__", ".next"])
29
+
30
+ /** Lower-cased ".ext" of a path/name ("" when it has no extension). */
31
+ function extensionOf(p) {
32
+ const s = String(p ?? "")
33
+ const base = s.slice(Math.max(s.lastIndexOf("/"), s.lastIndexOf("\\")) + 1)
34
+ const i = base.lastIndexOf(".")
35
+ return i > 0 ? base.slice(i).toLowerCase() : ""
36
+ }
37
+
38
+ /** Effective extension sets for one conventions object: defaults ∪ declared
39
+ * (declaration is a UNION — §4.1: index.*Extensions APPEND). Cached per frozen
40
+ * conventions object (the loader hands out stable per-root instances). */
41
+ const _knownCache = new WeakMap()
42
+ function knownExtensions(conv) {
43
+ const key = conv ?? DEFAULT_CONVENTIONS
44
+ let hit = _knownCache.get(key)
45
+ if (!hit) {
46
+ const code = new Set(CODE_EXTS)
47
+ const doc = new Set(DOC_EXTS)
48
+ for (const e of key.index?.codeExtensions ?? []) code.add(e)
49
+ for (const e of key.index?.docExtensions ?? []) doc.add(e)
50
+ hit = { code, doc, all: new Set([...code, ...doc]) }
51
+ _knownCache.set(key, hit)
52
+ }
53
+ return hit
54
+ }
55
+
56
+ /** Unlisted-extension tally (VP-9 visibility): counts files the index will NOT
57
+ * pick up because their extension is in no list. `exts` is a capped sample (the
58
+ * count is the signal; the list is the hint). */
59
+ export const UNLISTED_SAMPLE_CAP = 20
60
+ function createUnlistedTally() {
61
+ const counts = new Map()
62
+ let count = 0
63
+ return {
64
+ note(ext) {
65
+ if (!ext) return
66
+ count++
67
+ counts.set(ext, (counts.get(ext) ?? 0) + 1)
68
+ },
69
+ result() {
70
+ const exts = [...counts.entries()]
71
+ .sort((a, b) => b[1] - a[1])
72
+ .slice(0, UNLISTED_SAMPLE_CAP)
73
+ .map(([ext, n]) => ({ ext, count: n }))
74
+ return { count, exts }
75
+ },
76
+ }
77
+ }
78
+
79
+ /** Shared walker — ONE rule set for whole-tree discovery, the ignored-subtree walk
80
+ * (needsRebuild B2) and the memory self-check (listMemoryFiles): SKIP_DIRS / dot-dirs are
81
+ * not entered, .thincoder is entered only for memory/, files are filtered by extension.
82
+ * Keeping a single implementation is what makes "discovery" and "rebuild decision" unable
83
+ * to disagree (§4.2 契约六/七). With `collectUnlisted`, files whose extension is in no
84
+ * list are tallied instead of silently dropped. */
85
+ function collectFiles(rootDir, cwd, inThincoder, signal, conv, collectUnlisted) {
86
+ const { all } = knownExtensions(conv)
87
+ const tally = collectUnlisted ? createUnlistedTally() : null
88
+ const files = []
89
+ function walk(dir, insideThincoder) {
90
+ let entries
91
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
92
+ for (const e of entries) {
93
+ signal?.throwIfAborted()
94
+ // Inside .thincoder keep ONLY memory/ (skip index/, sessions/, checklist.md, …)
95
+ if (insideThincoder && e.name !== "memory") continue
96
+ if (e.isDirectory()) {
97
+ // Enter .thincoder at the project root (and its memory/ subdir); skip other dot-dirs.
98
+ if (SKIP_DIRS.has(e.name)) continue
99
+ if (e.name.startsWith(".") && e.name !== ".thincoder") continue
100
+ walk(join(dir, e.name), e.name === ".thincoder")
101
+ } else if (e.isFile()) {
102
+ if (isIndexableName(e.name, all)) {
103
+ files.push(relative(cwd, join(dir, e.name)).replaceAll("\\", "/"))
104
+ } else {
105
+ tally?.note(extensionOf(e.name))
106
+ }
107
+ }
108
+ }
109
+ }
110
+ walk(rootDir, inThincoder)
111
+ return { files, unlisted: tally ? tally.result() : { count: 0, exts: [] } }
112
+ }
113
+
114
+ function isIndexableName(name, allExts) {
115
+ const ext = extensionOf(name)
116
+ return ext !== "" && allExts.has(ext)
117
+ }
118
+
119
+ /**
120
+ * Discover indexable files under the project root.
121
+ * @param {string} cwd — project root
122
+ * @param {AbortSignal} [signal]
123
+ * @param {{collectUnlisted?: boolean}} [opts] — collectUnlisted: ALSO tally files whose
124
+ * extension is in no list; the return becomes `{ files, unlisted }` where
125
+ * `unlisted = { count, exts: [{ext, count}] }` (sample capped). Default: the plain
126
+ * files array (backward-compatible with needsRebuild / the memory self-check).
127
+ */
128
+ export function discoverFiles(cwd, signal, { collectUnlisted = false } = {}) {
129
+ const res = collectFiles(cwd, cwd, false, signal, loadConventions(cwd), collectUnlisted)
130
+ return collectUnlisted ? res : res.files
131
+ }
132
+
133
+ /** Indexable files under one subtree, relative to `cwd` — same walk rules as discoverFiles
134
+ * (used by needsRebuild to walk an ignored directory entry: §4.2 契约六). */
135
+ export function discoverFilesUnder(cwd, subdir, { collectUnlisted = false } = {}) {
136
+ const base = String(subdir ?? "").replaceAll("\\", "/").replace(/\/+$/, "")
137
+ if (!base) return collectUnlisted ? { files: [], unlisted: { count: 0, exts: [] } } : []
138
+ const res = collectFiles(join(cwd, base), cwd, false, undefined, loadConventions(cwd), collectUnlisted)
139
+ return collectUnlisted ? res : res.files
140
+ }
141
+
142
+ /** Extension-level predicate (defaults ∪ declared). */
143
+ export function isIndexableFile(filePath, conv) {
144
+ return isIndexableName(String(filePath ?? ""), knownExtensions(conv).all)
145
+ }
146
+
147
+ /** Full predicate: is this relative path a file the index tracks? (extension + the same
148
+ * directory exclusions discoverFiles applies — SKIP_DIRS and hidden dirs, except the
149
+ * .thincoder/memory/ special case). Used by needsRebuild so "discovery" and "rebuild
150
+ * decision" can never disagree. */
151
+ export function shouldIndexFile(relPath, conv) {
152
+ const p = relPath.replaceAll("\\", "/")
153
+ if (p.startsWith(".thincoder/memory/")) return isIndexableFile(p, conv)
154
+ for (const d of p.split("/").slice(0, -1)) {
155
+ if (SKIP_DIRS.has(d) || d.startsWith(".")) return false
156
+ }
157
+ return isIndexableFile(p, conv)
158
+ }
159
+
160
+ export function kindFor(filePath, conv) {
161
+ // memory files live in .thincoder/memory/ — check FIRST: they're .md, DOC_EXTS would
162
+ // otherwise classify them as docs.
163
+ if (filePath.startsWith(".thincoder/memory/")) return "memory"
164
+ const { code, doc } = knownExtensions(conv)
165
+ const ext = extensionOf(filePath)
166
+ if (code.has(ext)) return "code"
167
+ if (doc.has(ext)) return "doc"
168
+ return "doc"
169
+ }
170
+
171
+ /** Memory files the index tracks — recursive, through the SAME walk as discovery
172
+ * (§4.2 契约七: the old flat readdirSync missed nested files, so they were indexed and
173
+ * then judged file-removed on every needsRebuild — an endless rebuild loop). */
174
+ export function listMemoryFiles(cwd) {
175
+ return discoverFilesUnder(cwd, ".thincoder/memory")
176
+ }