thincoder 0.12.60 → 0.12.62

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 (171) hide show
  1. package/CHANGELOG.md +49 -2
  2. package/README.md +8 -6
  3. package/bin/thincoder.mjs +38 -124
  4. package/package.json +3 -2
  5. package/src/abort-provenance.mjs +116 -0
  6. package/src/acp/bridge.mjs +38 -17
  7. package/src/acp.mjs +19 -6
  8. package/src/advisor/citations.mjs +83 -21
  9. package/src/advisor/compaction.mjs +174 -0
  10. package/src/advisor/loop.mjs +293 -0
  11. package/src/advisor/messages.mjs +36 -134
  12. package/src/advisor/project-context.mjs +194 -0
  13. package/src/advisor/repos.mjs +17 -40
  14. package/src/advisor/run.mjs +124 -329
  15. package/src/advisor/truncate.mjs +57 -0
  16. package/src/advisor.mjs +3 -2
  17. package/src/agent/completion.mjs +1 -1
  18. package/src/agent/dispatch.mjs +47 -12
  19. package/src/agent/helpers.mjs +71 -13
  20. package/src/agent/record-results.mjs +13 -5
  21. package/src/agent/relay-prefix.mjs +39 -0
  22. package/src/agent/run-stages.mjs +24 -7
  23. package/src/agent/setup-reminders.mjs +16 -9
  24. package/src/agent/setup.mjs +92 -128
  25. package/src/agent/spawn-child.mjs +43 -11
  26. package/src/agent-tools/advisor-async.mjs +70 -180
  27. package/src/agent-tools/advisor-settle.mjs +231 -0
  28. package/src/agent-tools/advisor.mjs +69 -20
  29. package/src/agent-tools/async-settle.mjs +13 -0
  30. package/src/agent-tools/batch-segment.mjs +195 -0
  31. package/src/agent-tools/consult.mjs +28 -10
  32. package/src/agent-tools/design-token.mjs +14 -1
  33. package/src/agent-tools/digest-budget.mjs +76 -0
  34. package/src/agent-tools/eng.mjs +3 -3
  35. package/src/agent-tools/escalate-async.mjs +22 -13
  36. package/src/agent-tools/read-history.mjs +31 -6
  37. package/src/agent-tools/review-streak.mjs +93 -0
  38. package/src/agent-tools/settings.mjs +130 -17
  39. package/src/agent-tools/subagent-actions.mjs +18 -6
  40. package/src/agent-tools/subagent-async.mjs +66 -14
  41. package/src/agent-tools/subagent-panel.mjs +22 -15
  42. package/src/agent-tools/subagent-run.mjs +10 -7
  43. package/src/agent-tools/subagent-scheduler.mjs +57 -8
  44. package/src/agent-tools/subagent-spawn.mjs +69 -16
  45. package/src/agent-tools/subagent.mjs +175 -49
  46. package/src/agent-tools/verify.mjs +13 -34
  47. package/src/agent-tools.mjs +1 -0
  48. package/src/agent.mjs +42 -21
  49. package/src/cli/distill-command.mjs +2 -2
  50. package/src/cli/make-agent.mjs +23 -7
  51. package/src/cli/memory-command.mjs +2 -2
  52. package/src/cli/setup-wizard.mjs +29 -9
  53. package/src/completions.mjs +114 -0
  54. package/src/config-migrate.mjs +70 -0
  55. package/src/config.mjs +132 -63
  56. package/src/context.mjs +12 -1
  57. package/src/conventions.mjs +223 -0
  58. package/src/crash-reports.mjs +35 -8
  59. package/src/expand-home.mjs +16 -0
  60. package/src/generate-title.mjs +9 -4
  61. package/src/heap-watch.mjs +88 -0
  62. package/src/hooks.mjs +7 -3
  63. package/src/ledger.mjs +227 -0
  64. package/src/memory/code-index.mjs +9 -3
  65. package/src/memory/code-sync.mjs +77 -36
  66. package/src/memory/core.mjs +8 -9
  67. package/src/memory/delete.mjs +2 -0
  68. package/src/memory/docs.mjs +17 -11
  69. package/src/memory/file-walk.mjs +109 -0
  70. package/src/memory/scan.mjs +95 -0
  71. package/src/memory/schema.mjs +15 -3
  72. package/src/model-ref.mjs +66 -0
  73. package/src/model-specs.mjs +42 -8
  74. package/src/prompt-overlays.mjs +73 -16
  75. package/src/prompts/advisor-design.md +19 -9
  76. package/src/prompts/advisor-round1.md +8 -2
  77. package/src/prompts/advisor-round2.md +14 -3
  78. package/src/prompts/advisor-round3.md +14 -3
  79. package/src/prompts/common.md +115 -0
  80. package/src/prompts/consult-base.md +2 -0
  81. package/src/prompts/discipline-engineering.md +258 -0
  82. package/src/prompts/discipline-normal.md +185 -0
  83. package/src/prompts/persona-coder.md +21 -0
  84. package/src/prompts/persona-eng-coder.md +37 -0
  85. package/src/prompts/persona-eng-designer.md +60 -0
  86. package/src/prompts/persona-engineering.md +55 -0
  87. package/src/prompts/persona-explore.md +15 -0
  88. package/src/prompts/persona-normal.md +27 -0
  89. package/src/prompts/persona-plan.md +26 -0
  90. package/src/provider/anthropic.mjs +4 -4
  91. package/src/provider/core.mjs +13 -32
  92. package/src/provider/errors.mjs +26 -1
  93. package/src/provider/google.mjs +5 -6
  94. package/src/provider/index.mjs +2 -1
  95. package/src/provider/list-models.mjs +93 -0
  96. package/src/provider/rate.mjs +2 -1
  97. package/src/provider/responses.mjs +5 -3
  98. package/src/provider/sse.mjs +3 -4
  99. package/src/proxy.mjs +9 -14
  100. package/src/session-gc.mjs +9 -2
  101. package/src/session-guard.mjs +12 -0
  102. package/src/session-segments.mjs +100 -0
  103. package/src/session-slots.mjs +10 -2
  104. package/src/session-store.mjs +441 -0
  105. package/src/session.mjs +137 -99
  106. package/src/text-budget.mjs +46 -0
  107. package/src/token-ttl.mjs +2 -1
  108. package/src/tools/{system.mjs → bash.mjs} +6 -243
  109. package/src/tools/file.mjs +30 -10
  110. package/src/tools/git.md +1 -1
  111. package/src/tools/git.mjs +15 -34
  112. package/src/tools/index.mjs +4 -2
  113. package/src/tools/ops.mjs +20 -7
  114. package/src/tools/question.md +1 -0
  115. package/src/tools/question.mjs +26 -0
  116. package/src/tools/read.md +1 -1
  117. package/src/tools/read_image.md +1 -1
  118. package/src/tools/search.mjs +236 -0
  119. package/src/traces/trace-store.mjs +195 -64
  120. package/src/tui/agent-turn.mjs +32 -13
  121. package/src/tui/ansi.mjs +2 -0
  122. package/src/tui/clipboard.mjs +7 -1
  123. package/src/tui/cmd-advisor.mjs +3 -2
  124. package/src/tui/cmd-clear.mjs +2 -0
  125. package/src/tui/cmd-config.mjs +108 -37
  126. package/src/tui/cmd-eng.mjs +11 -27
  127. package/src/tui/cmd-exit.mjs +6 -8
  128. package/src/tui/cmd-model.mjs +14 -12
  129. package/src/tui/cmd-new.mjs +5 -1
  130. package/src/tui/cmd-reindex.mjs +7 -0
  131. package/src/tui/cmd-session.mjs +8 -4
  132. package/src/tui/cmd-submodel.mjs +8 -5
  133. package/src/tui/cmd-undo.mjs +4 -3
  134. package/src/tui/display-budget.mjs +184 -0
  135. package/src/tui/index.mjs +69 -40
  136. package/src/tui/key-handler-search.mjs +9 -1
  137. package/src/tui/key-handler.mjs +61 -17
  138. package/src/tui/key-modes.mjs +86 -8
  139. package/src/tui/layout.mjs +18 -10
  140. package/src/tui/ledger-surface.mjs +69 -0
  141. package/src/tui/model-catalog.mjs +89 -0
  142. package/src/tui/model-picker.mjs +498 -0
  143. package/src/tui/mouse.mjs +47 -10
  144. package/src/tui/pickers.mjs +28 -410
  145. package/src/tui/render-frame.mjs +38 -16
  146. package/src/tui/render-loop.mjs +2 -0
  147. package/src/tui/render-segments.mjs +5 -19
  148. package/src/tui/render.mjs +37 -5
  149. package/src/tui/slash-commands.mjs +2 -2
  150. package/src/tui/startup.mjs +45 -13
  151. package/src/tui/subagent-blocks.mjs +70 -90
  152. package/src/tui/subagent-children.mjs +125 -67
  153. package/src/tui/subagent-freeze.mjs +48 -45
  154. package/src/tui/subagent-panel.mjs +21 -66
  155. package/src/tui/suspension-drive.mjs +31 -83
  156. package/src/tui/tool-args.mjs +9 -4
  157. package/src/tui/tool-display.mjs +20 -5
  158. package/src/tui/tool-events.mjs +76 -30
  159. package/src/tui/tui-lifecycle.mjs +27 -7
  160. package/src/tui/wizard.mjs +52 -18
  161. package/src/tui/wrapped-spawn.mjs +54 -0
  162. package/src/prompts/coder.md +0 -13
  163. package/src/prompts/discipline.md +0 -84
  164. package/src/prompts/eng-coder.md +0 -19
  165. package/src/prompts/engineering-sub.md +0 -14
  166. package/src/prompts/engineering.md +0 -87
  167. package/src/prompts/explore.md +0 -12
  168. package/src/prompts/main.md +0 -34
  169. package/src/prompts/methodology-template.md +0 -38
  170. package/src/prompts/plan.md +0 -9
  171. package/src/prompts/system.md +0 -44
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { parseEntry, serializeEntry, entryFilename } from "../markdown.mjs"
9
9
  import { embed, cosine, toBlob, fromBlob } from "../embedding.mjs"
10
+ import { scanVectors, createTopK } from "./scan.mjs"
10
11
  import { readFile, stat, readdir, writeFile, mkdir } from "node:fs/promises"
11
12
  import { join } from "node:path"
12
13
  import { segmentCJK, VALID_TYPES, SCHEMA_VERSION } from "./schema.mjs"
@@ -57,15 +58,13 @@ export async function search(memory, query, { limit = 5 } = {}) {
57
58
  }
58
59
  const vecFilter = memory.projectOrigin ? `AND (layer = 'team' OR origin = ?)` : ""
59
60
  const vecParams = memory.projectOrigin ? [memory.projectOrigin] : []
60
- const rows = memory.db.prepare(`
61
- SELECT 'personal:' || id AS uid, embedding FROM entries WHERE embedding IS NOT NULL
62
- UNION ALL
63
- SELECT layer || ':' || COALESCE(origin, '') || ':' || path AS uid, embedding FROM files WHERE embedding IS NOT NULL ${vecFilter}
64
- `).all(...vecParams)
65
- const vecList = rows
66
- .map((r) => ({ id: r.uid, score: cosine(qvec, fromBlob(r.embedding)) }))
67
- .sort((a, b) => b.score - a.score)
68
- .slice(0, Math.max(limit * 4, 20))
61
+ // TUI-OOM-ROOTCAUSE(MEMORY.md §10.3):分块扫描 + 有界 top-K(原全表 .all() 物化 +
62
+ // 全量排序——峰值 = 块 + K;召回语义不变)。两表各自游标扫描、共享同一 top-K。
63
+ const top = createTopK(Math.max(limit * 4, 20))
64
+ const onRow = (r) => top.push({ id: r.uid, score: cosine(qvec, fromBlob(r.embedding)) })
65
+ scanVectors(memory.db, `SELECT rowid, 'personal:' || id AS uid, embedding FROM entries WHERE embedding IS NOT NULL`, [], { onRow })
66
+ scanVectors(memory.db, `SELECT rowid, layer || ':' || COALESCE(origin, '') || ':' || path AS uid, embedding FROM files WHERE embedding IS NOT NULL ${vecFilter}`, vecParams, { onRow })
67
+ const vecList = top.list()
69
68
 
70
69
  // ---- RRF merge ----
71
70
  const K = 60
@@ -155,6 +155,8 @@ export async function deleteByUid(memory, uid, { dirs = {} } = {}) {
155
155
  const norm = /^\d+$/.test(uid) ? `personal:${uid}` : String(uid)
156
156
  const [layer, ...rest] = norm.split(":")
157
157
  if (layer === "personal") {
158
+ // 畸形尾缀拒绝(2026-09-08 代码正确性批 2.2):personal:5:extra 不得静默删 id=5
159
+ if (norm.split(":").length > 2) throw new Error(`invalid memory id: ${norm}`)
158
160
  const id = rest[0] ?? ""
159
161
  if (!/^\d+$/.test(id)) throw new Error(`invalid memory id: ${norm}`)
160
162
  const entry = fetchEntry(memory, norm)
@@ -3,14 +3,16 @@
3
3
  */
4
4
 
5
5
  import { readFile, stat } from "node:fs/promises"
6
- import { join } from "node:path"
6
+ import { isAbsolute, join } from "node:path"
7
7
  import { embed, cosine, toBlob, fromBlob } from "../embedding.mjs"
8
+ import { scanVectors, createTopK } from "./scan.mjs"
8
9
  import { commitAndPush } from "../git/gitmem.mjs"
9
- import { DOC_EXTS, SKIP_DIRS, MAX_DOC_FILE_BYTES } from "./schema.mjs"
10
+ import { MAX_DOC_FILE_BYTES } from "./schema.mjs"
10
11
  import { buildFtsQuery, put, search, putMarkdown, clearPersonal, EMBED_TEXT_MAX_LEN } from "./core.mjs"
11
12
  import { deleteByUid, matchMemoryRows, deleteWhere } from "./delete.mjs"
12
13
  import { _upsertDocFile, yieldTick } from "./code-index.mjs"
13
- import { markIndexedCommit, listProjectFiles } from "./code-sync.mjs"
14
+ import { markIndexedCommit, listProjectFiles, indexExtensions } from "./code-sync.mjs"
15
+ import { logEvent } from "../log.mjs"
14
16
 
15
17
  const DOC_EMBED_BATCH = 64
16
18
 
@@ -19,7 +21,7 @@ const DOC_EMBED_BATCH = 64
19
21
  * Incremental by mtime.
20
22
  */
21
23
  export async function docSync(memory, dir, { onProgress } = {}) {
22
- const entries = await listProjectFiles(dir, DOC_EXTS)
24
+ const { entries, unlisted } = await listProjectFiles(dir, indexExtensions(dir).doc)
23
25
  const files = [] // { abs, rel, mtimeMs }
24
26
  let overSizeSkipped = 0
25
27
  for (const { abs, rel } of entries) {
@@ -72,7 +74,10 @@ export async function docSync(memory, dir, { onProgress } = {}) {
72
74
 
73
75
  onProgress?.({ phase: "done", total: files.length, updated, removed, skipped, failed, overSizeSkipped })
74
76
  markIndexedCommit(memory, dir)
75
- return { updated, removed, skipped, failed, errors, total: files.length, overSizeSkipped }
77
+ if (unlisted.count > 0) {
78
+ logEvent("index:unlisted", { dir, kind: "doc", count: unlisted.count, exts: unlisted.exts.map((e) => e.ext) })
79
+ }
80
+ return { updated, removed, skipped, failed, errors, total: files.length, overSizeSkipped, unlistedExts: unlisted }
76
81
  }
77
82
 
78
83
  /**
@@ -105,11 +110,12 @@ export async function docSearch(memory, query, { limit = 5 } = {}) {
105
110
  console.error(`[docs] query embedding failed, falling back to FTS-only: ${e.message}`)
106
111
  return ftsList.slice(0, limit)
107
112
  }
108
- const rows = memory.db.prepare(`SELECT rowid, embedding FROM doc_chunks WHERE embedding IS NOT NULL ${vecOriginFilter}`).all(...originParams)
109
- const vecList = rows
110
- .map((r) => ({ rowid: r.rowid, score: cosine(qvec, fromBlob(r.embedding)) }))
111
- .sort((a, b) => b.score - a.score)
112
- .slice(0, Math.max(limit * 4, 20))
113
+ // TUI-OOM-ROOTCAUSE(MEMORY.md §10.3):分块扫描 + 有界 top-K(原全表 .all()——峰值 = 块 + K)
114
+ const top = createTopK(Math.max(limit * 4, 20))
115
+ scanVectors(memory.db, `SELECT rowid, embedding FROM doc_chunks WHERE embedding IS NOT NULL ${vecOriginFilter}`, originParams, {
116
+ onRow: (r) => top.push({ id: r.rowid, rowid: r.rowid, score: cosine(qvec, fromBlob(r.embedding)) }),
117
+ })
118
+ const vecList = top.list().map((c) => ({ rowid: c.id, score: c.score }))
113
119
 
114
120
  const K = 60
115
121
  const scores = new Map()
@@ -233,7 +239,7 @@ const listRowLine = (r) => `[${r.layer}] ${r.id} [${r.type}] ${r.title}(${fmtD
233
239
  * opts: { cwd, projectDir, author, team: { dir, name } | null }
234
240
  */
235
241
  export function memoryTools(memory, opts = {}) {
236
- const projectDir = opts.projectDir ? join(opts.cwd ?? process.cwd(), opts.projectDir) : null
242
+ const projectDir = opts.projectDir ? (isAbsolute(opts.projectDir) ? opts.projectDir : join(opts.cwd ?? process.cwd(), opts.projectDir)) : null
237
243
  const dirs = { project: projectDir, team: opts.team?.dir ?? null }
238
244
  return [
239
245
  {
@@ -0,0 +1,109 @@
1
+ /**
2
+ * memory/file-walk.mjs — filesystem walk fallback + the shared file-listing predicates.
3
+ *
4
+ * Why this module (PORTABILITY FR15 / P8): the index was built from `git ls-files`
5
+ * only — on a project that is not a git repository the code and doc indexes came
6
+ * back EMPTY, silently (the reviewer/agent then "searched" an empty index). The
7
+ * fallback traverses the filesystem instead, and the skip predicate lives here so
8
+ * the git path, the walk and the single-file reindex cannot drift apart against
9
+ * each other (they were three copies before).
10
+ */
11
+ import { readdir } from "node:fs/promises"
12
+ import { join } from "node:path"
13
+ import { SKIP_DIRS } from "./schema.mjs"
14
+
15
+ /** Upper guard for the walk: stop after this many matched files (runaway trees). */
16
+ export const MAX_WALK_FILES = 20000
17
+
18
+ /**
19
+ * Shared skip predicate for project-relative paths: any SKIP_DIRS basename, or any
20
+ * dot-prefixed segment (`.git`, `.cache`, `.thincoder`…). Accepts both separators.
21
+ */
22
+ export function isSkippedRelPath(rel) {
23
+ return String(rel ?? "")
24
+ .replace(/\\/g, "/")
25
+ .split("/")
26
+ .some((seg) => seg !== "" && (seg.startsWith(".") || SKIP_DIRS.has(seg)))
27
+ }
28
+
29
+ /** Lower-cased ".ext" of a path, or "" when it has no extension. */
30
+ export function extensionOf(p) {
31
+ const s = String(p ?? "")
32
+ const base = s.slice(Math.max(s.lastIndexOf("/"), s.lastIndexOf("\\")) + 1)
33
+ const i = base.lastIndexOf(".")
34
+ return i > 0 ? base.slice(i).toLowerCase() : ""
35
+ }
36
+
37
+ /**
38
+ * Unlisted-extension tally (PORTABILITY PO-9 — visibility): counts files the index
39
+ * will NOT pick up because their extension is in no list, so "my .xyz files are not
40
+ * searchable" stops being invisible. `exts` is a capped sample (the count is the
41
+ * signal; the list is the hint).
42
+ * @param {Set<string>} knownExts — every extension that IS indexed (code ∪ doc ∪ declared)
43
+ */
44
+ export function createUnlistedTally(knownExts) {
45
+ const counts = new Map()
46
+ let count = 0
47
+ return {
48
+ note(rel) {
49
+ const ext = extensionOf(rel)
50
+ if (!ext || knownExts.has(ext)) return
51
+ count++
52
+ counts.set(ext, (counts.get(ext) ?? 0) + 1)
53
+ },
54
+ result(limit = 12) {
55
+ const exts = [...counts.entries()]
56
+ .sort((a, b) => b[1] - a[1])
57
+ .slice(0, limit)
58
+ .map(([ext, n]) => ({ ext, count: n }))
59
+ return { count, exts }
60
+ },
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Recursive project walk — the no-git listing source. Symlinks are never followed
66
+ * (loop/escape guard); skipped directories are pruned; only files whose extension
67
+ * is in `exts` are returned. Hitting maxFiles is reported as `truncated`, never
68
+ * silently dropped.
69
+ * @param {string} dir — project root
70
+ * @param {Set<string>} exts — extensions to keep (lower-case, leading dot)
71
+ * @param {{maxFiles?: number, knownExts?: Set<string>|null}} [opts]
72
+ * knownExts → also tally files whose extension is in NO index list
73
+ * @returns {Promise<{files: {abs: string, rel: string}[], truncated: boolean,
74
+ * unlisted: {count: number, exts: {ext: string, count: number}[]}}>}
75
+ */
76
+ export async function walkProjectFiles(dir, exts, { maxFiles = MAX_WALK_FILES, knownExts = null } = {}) {
77
+ const files = []
78
+ const tally = knownExts ? createUnlistedTally(knownExts) : null
79
+ let truncated = false
80
+ const stack = [{ abs: dir, rel: "" }]
81
+ while (stack.length > 0) {
82
+ const cur = stack.pop()
83
+ let entries
84
+ try {
85
+ entries = await readdir(cur.abs, { withFileTypes: true })
86
+ } catch { continue /* unreadable dir — skip, the walk must not fail the sync */ }
87
+ for (const ent of entries) {
88
+ const rel = cur.rel ? `${cur.rel}/${ent.name}` : ent.name
89
+ if (ent.isSymbolicLink()) continue // never follow symlinks
90
+ if (ent.isDirectory()) {
91
+ if (!isSkippedRelPath(rel)) stack.push({ abs: join(cur.abs, ent.name), rel })
92
+ continue
93
+ }
94
+ if (!ent.isFile() || isSkippedRelPath(rel)) continue
95
+ const ext = extensionOf(ent.name)
96
+ if (!ext || !exts.has(ext)) {
97
+ tally?.note(rel)
98
+ continue
99
+ }
100
+ if (files.length >= maxFiles) {
101
+ truncated = true
102
+ break
103
+ }
104
+ files.push({ abs: join(cur.abs, ent.name), rel })
105
+ }
106
+ if (truncated) break
107
+ }
108
+ return { files, truncated, unlisted: tally ? tally.result() : { count: 0, exts: [] } }
109
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * memory/scan.mjs — 检索向量通道的分块扫描 + 有界 top-K(TUI-OOM-ROOTCAUSE 批——MEMORY.md §10)。
3
+ *
4
+ * 病灶(§10.1):三张表的向量通道全表 `.all()` 物化后逐行 cosine + 全量排序——无 SQL LIMIT、
5
+ * 无分块(本机 memory.db ~736MB,embedding BLOB 为体量主源)。本模块把扫描改为
6
+ * **rowid 游标分块**(块 `SCAN_CHUNK_ROWS`)+ **有界 top-K**(升序小顶堆——候选集
7
+ * ≤ max(limit×4, 20) 既有口径):峰值 = 块 + K;召回语义不变(仍全表评分,D-M1/D-M4)。
8
+ *
9
+ * 对外结构不变:调用方拿到的仍是 `[{id, score}]` 降序候选(RRF 融合输入)——并列分数按
10
+ * 既有排序稳定性规则(先到先留——与「全量稳定 sort + slice(0,K)」等价)。
11
+ *
12
+ * 可测缝(N-M1):`scanVectors` 接受注入的 `runChunkedQuery`(默认真实 DB 实现——
13
+ * `AND rowid > ? ORDER BY rowid LIMIT ?`);测试以假数据源直测块大小/top-K/等价性
14
+ * (不建真实大表)。
15
+ */
16
+
17
+ /** 单块行数(单源——三处共用;D-M2:块内存量级 KB~MB,随维度有界)。 */
18
+ export const SCAN_CHUNK_ROWS = 2_000
19
+
20
+ /**
21
+ * 有界 top-K(升序小顶堆):`push({id, score})` 摊销 O(log K);`list()` 返回降序
22
+ * `[{id, score}]`。并列分数先到先留(与全量稳定排序一致)。
23
+ */
24
+ export function createTopK(k) {
25
+ const cap = Math.max(1, k)
26
+ const heap = [] // 升序小顶堆(堆顶 = 当前最差)
27
+ let seq = 0
28
+
29
+ /** a 比 b 更差?(分低者差;同分 → 迟到者差(seq 大)——堆顶恒为最差) */
30
+ const worse = (a, b) => a.score < b.score || (a.score === b.score && a.seq > b.seq)
31
+ /** a 严格优于 b?(同分不互优——先到先留) */
32
+ const strictlyBetter = (a, b) => a.score > b.score || (a.score === b.score && a.seq < b.seq)
33
+ const siftUp = (i) => {
34
+ while (i > 0) {
35
+ const p = (i - 1) >> 1
36
+ if (worse(heap[i], heap[p])) { const t = heap[i]; heap[i] = heap[p]; heap[p] = t; i = p; continue }
37
+ break
38
+ }
39
+ }
40
+ const siftDown = (i) => {
41
+ for (;;) {
42
+ const l = i * 2 + 1
43
+ const r = l + 1
44
+ let worst = i
45
+ if (l < heap.length && worse(heap[l], heap[worst])) worst = l
46
+ if (r < heap.length && worse(heap[r], heap[worst])) worst = r
47
+ if (worst === i) break
48
+ const t = heap[i]; heap[i] = heap[worst]; heap[worst] = t
49
+ i = worst
50
+ }
51
+ }
52
+
53
+ return {
54
+ get size() { return heap.length },
55
+ push(item) {
56
+ const node = { id: item.id, score: item.score, seq: seq++ }
57
+ if (heap.length < cap) { heap.push(node); siftUp(heap.length - 1); return true }
58
+ if (!strictlyBetter(node, heap[0])) return false // 不优于当前最差(含同分迟到)→ 丢弃(先到先留)
59
+ heap[0] = node
60
+ siftDown(0)
61
+ return true
62
+ },
63
+ /** 降序 [{id, score}](K 上限——与「全量 sort desc + slice(0,K)」逐条等价)。 */
64
+ list() {
65
+ return [...heap]
66
+ .sort((a, b) => (b.score - a.score) || (a.seq - b.seq))
67
+ .map((n) => ({ id: n.id, score: n.score }))
68
+ },
69
+ }
70
+ }
71
+
72
+ /**
73
+ * 分块扫描(rowid 游标):逐块物化 → 逐行 `onRow` → 块内存随迭代释放。
74
+ * @param {object} db sqlite 句柄(`.prepare(sql).all(...params)`)
75
+ * @param {string} sql 单表查询(须含 `rowid` 列;不含 LIMIT)
76
+ * @param {Array} params 绑定参数(`?` 占位——顺序与 SQL 一致)
77
+ * @param {object} opts `{ chunk = SCAN_CHUNK_ROWS, onRow, runChunkedQuery }`
78
+ * @returns {number} 扫描到的行数
79
+ */
80
+ export function scanVectors(db, sql, params = [], { chunk = SCAN_CHUNK_ROWS, onRow = null, runChunkedQuery = null } = {}) {
81
+ const run = runChunkedQuery ?? ((after, take) =>
82
+ db.prepare(`${sql} AND rowid > ? ORDER BY rowid LIMIT ?`).all(...params, after, take))
83
+ let after = 0
84
+ let total = 0
85
+ for (;;) {
86
+ const rows = run(after, chunk) ?? []
87
+ if (rows.length === 0) break
88
+ for (const r of rows) { total++; onRow?.(r) }
89
+ const next = rows[rows.length - 1]?.rowid
90
+ if (next === undefined || next === null || !(next > after)) break // 防御:游标不前进即止(不空转)
91
+ after = next
92
+ if (rows.length < chunk) break // 尾块
93
+ }
94
+ return total
95
+ }
@@ -14,10 +14,22 @@ export const VALID_TYPES = new Set(["rule", "knowledge", "decision", "pattern"])
14
14
  export const SCHEMA_VERSION = 9
15
15
  export const SQLITE_BUSY_TIMEOUT = 3000
16
16
 
17
- // Code index: source file extensions
18
- export const CODE_EXTS = new Set([".mjs", ".js", ".ts", ".tsx", ".jsx", ".py", ".rs", ".go", ".java", ".c", ".h", ".cpp", ".hpp", ".rb", ".swift", ".kt", ".sh", ".bash", ".sql", ".yaml", ".yml", ".toml", ".json", ".css", ".html", ".vue", ".svelte"])
17
+ // Code index: source file extensions. Curated DEFAULTS — a project can declare
18
+ // more (union) through .thincoder/conventions.json → index.codeExtensions
19
+ // (PORTABILITY PO-9: an unlisted extension used to be invisible AND undeclarable).
20
+ export const CODE_EXTS = new Set([
21
+ ".mjs", ".js", ".cjs", ".mts", ".cts", ".ts", ".tsx", ".jsx",
22
+ ".py", ".rs", ".go", ".java", ".c", ".h", ".cpp", ".hpp",
23
+ ".rb", ".swift", ".kt", ".dart", ".lua", ".cs", ".fs", ".fsx",
24
+ ".clj", ".cljs", ".ex", ".exs", ".erl", ".hrl", ".scala", ".groovy",
25
+ ".pl", ".pm", ".r", ".jl", ".zig", ".ps1",
26
+ ".sh", ".bash", ".sql", ".yaml", ".yml", ".toml", ".json",
27
+ ".proto", ".graphql", ".tf", ".hcl",
28
+ ".css", ".html", ".vue", ".svelte",
29
+ ])
19
30
  // Doc index: markdown / plain text (separate index makes it easier for LLM to distinguish "design specs" from "existing code")
20
- export const DOC_EXTS = new Set([".md", ".mdc", ".txt", ".rst", ".adoc"])
31
+ // Same declaration rule as CODE_EXTS (index.docExtensions).
32
+ export const DOC_EXTS = new Set([".md", ".mdc", ".mdx", ".txt", ".rst", ".adoc", ".org", ".wiki", ".tex"])
21
33
  // Directory names always skipped during code/doc indexing
22
34
  // NOTE: these are case-sensitive basename matches; add common platform-specific dirs
23
35
  export const SKIP_DIRS = new Set([
@@ -0,0 +1,66 @@
1
+ /**
2
+ * model-ref.mjs — composite model reference parser (MODEL-MERGE-SESSION; v2 2026-09-10).
3
+ * A model is an explicit "provider:model" composite — config.defaultModel (top level,
4
+ * new-session starting point) and session slots both speak this language. 有效域 = **provider 存在
5
+ * + 双段非空**:清单不再人工维护(模型清单由 provider 运行期 `GET /models` 拉取决定——
6
+ * PROVIDER.md §16),候选成员校验**已废除**——显式 `p:m` 一律放行(含多冒号首分割)。
7
+ * providers[].model 是渠道默认模型(单值)——候选清单字段 models[] 已整字段退场。
8
+ *
9
+ * Strict first-colon split — deliberately does NOT reuse resolveChildProvider's loose
10
+ * three-state resolution: ① legacy single-provider configs and ② model-name-only refs
11
+ * are gone with activeProvider/activeModel (F-1), so a bare provider or bare model is
12
+ * invalid everywhere (裁定③——裸 provider 拒——显式 p:m)。
13
+ *
14
+ * Runtime model resolution:
15
+ * parseModelRef(ref, providers) → { ok, provider, model } | { ok:false, reason }
16
+ * resolveRuntimeProvider(providers, defaultModel)
17
+ * → provider object with `.model` set, or {} when
18
+ * defaultModel is null/invalid (D-S1: callers mark
19
+ * _providerInvalid — never throws)
20
+ * defaultModelReason(providers, defaultModel) → human reason for the D-S1 marker
21
+ *
22
+ * Consumers: config.mjs loadConfig (re-exported as the config hub), make-agent.mjs.
23
+ */
24
+ export function parseModelRef(ref, providers) {
25
+ if (typeof ref !== "string" || !ref.trim()) {
26
+ return { ok: false, reason: 'model reference expected as "provider:model" (e.g. deepseek:deepseek-v4-pro)' }
27
+ }
28
+ // 首冒号分割——`a:b:c` → provider=a、model=`b:c`(`ollama:llama3:70b` 式模型名可用,M4)
29
+ const sep = ref.indexOf(":")
30
+ if (sep <= 0) {
31
+ return { ok: false, reason: `invalid model reference "${ref}" — expected provider:model (bare provider/model names are not accepted)` }
32
+ }
33
+ const providerName = ref.slice(0, sep)
34
+ const model = ref.slice(sep + 1)
35
+ if (!model) {
36
+ return { ok: false, reason: `invalid model reference "${ref}" — model part is empty (expected provider:model)` }
37
+ }
38
+ const list = Array.isArray(providers) ? providers : []
39
+ const provider = list.find((p) => p?.name === providerName)
40
+ if (!provider) {
41
+ const available = list.map((p) => p.name).join(", ") || "(none)"
42
+ return { ok: false, reason: `unknown provider "${providerName}" in model reference (available: ${available})` }
43
+ }
44
+ return { ok: true, provider, model }
45
+ }
46
+
47
+ /** Resolve the runtime provider object for config.defaultModel: provider clone carrying
48
+ * `.model` = the parsed concrete model (API/spec consumers read provider.model unchanged).
49
+ * Invalid/missing defaultModel → {} (D-S1 shape — name/model/baseURL absent so
50
+ * validateProvider flags it; TUI/headless surface the reason from defaultModelReason). */
51
+ export function resolveRuntimeProvider(providers, defaultModel) {
52
+ const r = parseModelRef(defaultModel, providers)
53
+ if (!r.ok) return {}
54
+ return { ...r.provider, model: r.model }
55
+ }
56
+
57
+ /** Human-readable reason behind an invalid/empty runtime resolution (D-S1 原因). */
58
+ export function defaultModelReason(providers, defaultModel) {
59
+ const list = Array.isArray(providers) ? providers : []
60
+ if (!defaultModel) {
61
+ return list.length > 0
62
+ ? "defaultModel 未设置(config 顶层 defaultModel — 新会话起点;/config → 默认模型 设置)"
63
+ : "未配置任何 provider"
64
+ }
65
+ return parseModelRef(defaultModel, list).reason ?? `defaultModel "${defaultModel}" 无效`
66
+ }
@@ -25,10 +25,18 @@
25
25
  * tempRange: valid temperature range [min, max] (if undeclared, no clamping)
26
26
  */
27
27
  const MODEL_SPECS = [
28
- // DeepSeek V4 series
28
+ // DeepSeek V4.1 series (2026-09-11). `deepseek-flash` = DeepSeek-V4.1-Flash (in service):
29
+ // 1M ctx / 384K out, thinking on by default (effort low/high/max), Chat Prefix Completion
30
+ // beta, automatic disk cache, vision. The two legacy names below are RETIRED — the server
31
+ // still accepts them and serves them from V4.1-Flash today (no switch window), so their
32
+ // rows carry the V4.1-Flash parameters (v4-flash gains multimodal).
33
+ ["deepseek-flash", { context: 1_000_000, maxOutput: 384_000, thinking: true, prefixMode: true, cacheMode: "auto", thinkApi: "type", reasoningEcho: "required", reasoningEffortEnum: ["low", "high", "max"], tempRange: [0, 2], multimodal: true }],
34
+ // deepseek-v4-pro (V4-Pro-0813): fields unchanged — vision capability NOT verified, so no
35
+ // multimodal (conservative). From 2026-09-14 12:00 Beijing all requests route to V4.1-Flash.
29
36
  ["deepseek-v4-pro", { context: 1_000_000, maxOutput: 384_000, thinking: true, prefixMode: true, cacheMode: "auto", thinkApi: "type", reasoningEcho: "required", reasoningEffortEnum: ["low", "high", "max"], tempRange: [0, 2] }],
30
- ["deepseek-v4-flash", { context: 1_000_000, maxOutput: 384_000, thinking: true, prefixMode: true, cacheMode: "auto", thinkApi: "type", reasoningEcho: "required", reasoningEffortEnum: ["low", "high", "max"], tempRange: [0, 2] }],
31
- // DeepSeek V4 Flash Vision (experimental) — image input on top of the full V4-Flash stack
37
+ // deepseek-v4-flash: RETIRED name — still accepted, served by V4.1-Flash today → aligned row
38
+ ["deepseek-v4-flash", { context: 1_000_000, maxOutput: 384_000, thinking: true, prefixMode: true, cacheMode: "auto", thinkApi: "type", reasoningEcho: "required", reasoningEffortEnum: ["low", "high", "max"], tempRange: [0, 2], multimodal: true }],
39
+ // deepseek-v4-flash-vision-exp: retired experimental vision name — still accepted (V4.1-Flash)
32
40
  ["deepseek-v4-flash-vision-exp", { context: 1_000_000, maxOutput: 384_000, thinking: true, prefixMode: true, cacheMode: "auto", thinkApi: "type", reasoningEcho: "required", reasoningEffortEnum: ["low", "high", "max"], tempRange: [0, 2], multimodal: true }],
33
41
  // Kimi series
34
42
  ["kimi-k3", { context: 1_000_000, maxOutput: 131_072, thinking: true, partialMode: true, multimodal: true, cacheMode: "auto", thinkApi: "effort", reasoningEcho: "required", reasoningEffortEnum: ["low", "high", "max"] }],
@@ -95,11 +103,15 @@ const DEFAULT_SPEC = { context: 128_000, maxOutput: 32_000, cacheMode: "none" }
95
103
  * 且含 "/" 时,剥掉第一个 "/" 前的 namespace 再按前缀匹配一次——ZHIPU/GLM-5.3 → glm-5.3 命中
96
104
  * 真实规格,不再降级 128K 默认。kimi/kimi-k3 的显式 alias 行保留为文档锚(发送路径
97
105
  * provider.core isRouter 依赖含 "/" 判定),通用机制已覆盖同类。 */
98
- const warnedModels = new Set() // warn once per model name — specForModel is a hot path (every request)
99
- // Pre-sorted once at module scope — specForModel runs on every request (agent, provider core,
106
+ const warnedModels = new Set() // warn once per model name — spec lookup runs on every request (hot path)
107
+ // Pre-sorted once at module scope — spec lookup runs on every request (agent, provider core,
100
108
  // context, auto-think, TUI rendering); re-sorting per call was wasteful.
101
109
  const SORTED_SPECS = [...MODEL_SPECS].sort((a, b) => b[0].length - a[0].length)
102
- export function specForModel(model) {
110
+
111
+ /** Single table lookup shared by specForModel / specMatch — prefix match (case-insensitive)
112
+ * with vendor-namespace stripping. Returns null on a miss (DEFAULT_SPEC is the caller-side
113
+ * fallback and is deliberately NOT returned here — `matched` needs the miss itself). */
114
+ function lookupSpec(model) {
103
115
  const m = (model ?? "").toLowerCase()
104
116
  for (const [prefix, spec] of SORTED_SPECS) {
105
117
  if (m.startsWith(prefix.toLowerCase())) return spec
@@ -112,15 +124,37 @@ export function specForModel(model) {
112
124
  if (bare.startsWith(prefix.toLowerCase())) return spec
113
125
  }
114
126
  }
115
- // Unknown model: warn ONCE (not per request) so a typo'd ID or a missing alias surfaces
116
- // instead of silently degrading to the 128K default (IK5VGJ).
127
+ return null
128
+ }
129
+
130
+ /** Unknown model: warn ONCE (not per request) so a typo'd ID or a missing alias surfaces
131
+ * instead of silently degrading to the 128K default (IK5VGJ). The dedupe set is shared by
132
+ * specForModel / specMatch (PROVIDER.md §16 M5). */
133
+ function warnUnknownModel(model) {
134
+ const m = (model ?? "").toLowerCase()
117
135
  if (m && !warnedModels.has(m)) {
118
136
  warnedModels.add(m)
119
137
  console.warn(`[config] model "${model}" not found in MODEL_SPECS — using default spec (128K context, 32K output). Check the model ID or add an alias.`)
120
138
  }
139
+ }
140
+
141
+ export function specForModel(model) {
142
+ const hit = lookupSpec(model)
143
+ if (hit) return hit
144
+ warnUnknownModel(model)
121
145
  return DEFAULT_SPEC
122
146
  }
123
147
 
148
+ /** specMatch(model) → { spec, matched }(PROVIDER.md §16 M5——切换回显的来源判定)。
149
+ * `matched:false` = DEFAULT_SPEC 兜底(未知模型);与 specForModel 共享同一查表实现(单次),
150
+ * specForModel 的返回形状与共享对象契约零改(热路径零变)。 */
151
+ export function specMatch(model) {
152
+ const hit = lookupSpec(model)
153
+ if (hit) return { spec: hit, matched: true }
154
+ warnUnknownModel(model)
155
+ return { spec: DEFAULT_SPEC, matched: false }
156
+ }
157
+
124
158
  /**
125
159
  * providerSpec(provider) — spec with a provider-level context override (PROVIDER.md §15, 2026-09-02).
126
160
  *
@@ -1,8 +1,11 @@
1
1
  /**
2
- * prompt-overlays.mjs — prompt overlay 载荷(2026-09-05 module-split:agent.mjs
3
- * 530 > 500 硬限——explore/coder/plan/eng-coder/consult 角色 overlay 的读取与导出
4
- * verbatim 迁入;agent.mjs re-export 保 import 面(subagent-actions.mjs /
5
- * setup.mjs 动态 import 零改)。byte-stable:prompts/*.md 文件内容一次性读取。
2
+ * prompt-overlays.mjs — prompt slot constants + the scenario→slot assembly (PROMPT-SYSTEM
3
+ * 施工② G1+G2, 2026-09-10). Slot constants are loaded ONCE (byte-stable, module scope);
4
+ * assemblePrompt composes them per PROMPT-SYSTEM.md §3.2 装配矩阵 (D1 内联表——设计锚).
5
+ *
6
+ * Slot model (PROMPT-SYSTEM.md §1/§2): persona → common → discipline → [4] other
7
+ * (AGENTS/skills ride the existing tail logic). explore/coder/plan reuse PERSONA_NORMAL
8
+ * as their persona slot (蓝图 §3.1 同槽位复用——变体差异归人格层覆写; design D1 G1 note).
6
9
  */
7
10
 
8
11
  import { readFileSync } from "node:fs"
@@ -11,15 +14,69 @@ import { fileURLToPath } from "node:url"
11
14
 
12
15
  const __dirname = dirname(fileURLToPath(import.meta.url))
13
16
 
14
- // Prompt files (byte-stable, loaded once)
15
- let _EXPLORE, _CODER, _PLAN, _ENG_CODER, _CONSULT_BASE
16
- try { _EXPLORE = readFileSync(join(__dirname, "prompts", "explore.md"), "utf8") } catch { _EXPLORE = "" }
17
- try { _CODER = readFileSync(join(__dirname, "prompts", "coder.md"), "utf8") } catch { _CODER = "" }
18
- try { _PLAN = readFileSync(join(__dirname, "prompts", "plan.md"), "utf8") } catch { _PLAN = "" }
19
- try { _ENG_CODER = readFileSync(join(__dirname, "prompts", "eng-coder.md"), "utf8") } catch { _ENG_CODER = "" }
20
- try { _CONSULT_BASE = readFileSync(join(__dirname, "prompts", "consult-base.md"), "utf8") } catch { _CONSULT_BASE = "" }
21
- export const EXPLORE_OVERLAY = _EXPLORE
22
- export const CODER_OVERLAY = _CODER
23
- export const PLAN_OVERLAY = _PLAN
24
- export const ENG_CODER_OVERLAY = _ENG_CODER
25
- export const CONSULT_BASE = _CONSULT_BASE
17
+ function loadSlot(name) {
18
+ try { return readFileSync(join(__dirname, "prompts", name), "utf8") } catch { return "" }
19
+ }
20
+
21
+ // ── G1 槽位内容表(文件名 → 内容常量)──
22
+ const SLOT_CONTENTS = {
23
+ "persona-engineering.md": loadSlot("persona-engineering.md"),
24
+ "persona-normal.md": loadSlot("persona-normal.md"),
25
+ "persona-eng-coder.md": loadSlot("persona-eng-coder.md"),
26
+ "persona-eng-designer.md": loadSlot("persona-eng-designer.md"),
27
+ "persona-explore.md": loadSlot("persona-explore.md"),
28
+ "persona-coder.md": loadSlot("persona-coder.md"),
29
+ "persona-plan.md": loadSlot("persona-plan.md"),
30
+ "common.md": loadSlot("common.md"),
31
+ "discipline-engineering.md": loadSlot("discipline-engineering.md"),
32
+ "discipline-normal.md": loadSlot("discipline-normal.md"),
33
+ }
34
+
35
+ // ── G1 六件导出(评审 #1 计数口径:人格 2 + 公共 1 + 纪律 2 + consult 自含基底;
36
+ // persona-{role} 三件由 SLOT_CONTENTS 承载、不设独立常量导出——装配表唯一消费面)──
37
+ export const PERSONA_ENGINEERING = SLOT_CONTENTS["persona-engineering.md"]
38
+ export const PERSONA_NORMAL = SLOT_CONTENTS["persona-normal.md"]
39
+ export const COMMON = SLOT_CONTENTS["common.md"]
40
+ export const DISCIPLINE_ENGINEERING = SLOT_CONTENTS["discipline-engineering.md"]
41
+ export const DISCIPLINE_NORMAL = SLOT_CONTENTS["discipline-normal.md"]
42
+ export const CONSULT_BASE = loadSlot("consult-base.md")
43
+
44
+ // ── D1 场景→槽位文件表(PROMPT-SYSTEM.md §3.2 装配矩阵内联——①②并行契约锚:
45
+ // 槽文件路径字符串以本表为唯一权威——设计 §1.1 红线。explore/coder/plan 行 =
46
+ // persona-{role}(蓝图 §3.2 1:1——施工①已落地三份角色人格文件;advisor round1
47
+ // 修正:此前误按 G1 括注映射 persona-normal 使三文件永不被消费)。──
48
+ export const SCENARIO_SLOT_FILES = {
49
+ engineering: ["persona-engineering.md", "common.md", "discipline-engineering.md"],
50
+ normal: ["persona-normal.md", "common.md", "discipline-normal.md"],
51
+ "eng-coder": ["persona-eng-coder.md", "common.md", "discipline-engineering.md"],
52
+ "eng-designer": ["persona-eng-designer.md", "common.md", "discipline-engineering.md"],
53
+ explore: ["persona-explore.md", "common.md", "discipline-normal.md"],
54
+ coder: ["persona-coder.md", "common.md", "discipline-normal.md"],
55
+ plan: ["persona-plan.md", "common.md", "discipline-normal.md"],
56
+ consult: null, // §3.3 特殊模块——consult-base.md 自含基底,不入主装配链(CONSULT_BASE 直出)
57
+ }
58
+
59
+ // ── D2 警告文案(走既有 setup 警告通道 = history 注入——不新增机制;common 同款)──
60
+ export function slotWarning(fileName) {
61
+ return `[System reminder: prompt slot file ${fileName} missing — this slot is SKIPPED, no fallback from another slot (层间隔离). Prompt content may be degraded; check the prompts directory.]`
62
+ }
63
+
64
+ /**
65
+ * G2: the single prompt-assembly function — table-driven (D1), fixed order
66
+ * persona → common → discipline (§3.1 四槽位固定序;[4] AGENTS/skills 由既有尾部
67
+ * 逻辑承担). A missing slot file is SKIPPED with a prominent warning (蓝图 §3.4 降级链);
68
+ * AGENTS.md missing = silent skip in the caller's existing tail logic. Byte-stable
69
+ * per scenario (D3): fixed slot contents + fixed order — no timestamps here.
70
+ */
71
+ export function assemblePrompt(scenario) {
72
+ const files = SCENARIO_SLOT_FILES[scenario]
73
+ if (!files) return { prompt: CONSULT_BASE, warnings: [] }
74
+ const parts = []
75
+ const warnings = []
76
+ for (const file of files) {
77
+ const content = SLOT_CONTENTS[file]
78
+ if (content) parts.push(content)
79
+ else warnings.push(slotWarning(file))
80
+ }
81
+ return { prompt: parts.join("\n\n"), warnings }
82
+ }