thincoder 0.12.60 → 0.12.61

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 (152) hide show
  1. package/CHANGELOG.md +24 -1
  2. package/README.md +8 -6
  3. package/bin/thincoder.mjs +27 -122
  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 +6 -1
  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 +21 -6
  23. package/src/agent/setup-reminders.mjs +16 -9
  24. package/src/agent/setup.mjs +92 -128
  25. package/src/agent/spawn-child.mjs +26 -9
  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/batch-segment.mjs +195 -0
  30. package/src/agent-tools/consult.mjs +23 -10
  31. package/src/agent-tools/design-token.mjs +14 -1
  32. package/src/agent-tools/digest-budget.mjs +76 -0
  33. package/src/agent-tools/eng.mjs +3 -3
  34. package/src/agent-tools/escalate-async.mjs +16 -13
  35. package/src/agent-tools/read-history.mjs +13 -3
  36. package/src/agent-tools/review-streak.mjs +93 -0
  37. package/src/agent-tools/settings.mjs +130 -17
  38. package/src/agent-tools/subagent-actions.mjs +15 -6
  39. package/src/agent-tools/subagent-async.mjs +66 -14
  40. package/src/agent-tools/subagent-panel.mjs +22 -15
  41. package/src/agent-tools/subagent-run.mjs +9 -6
  42. package/src/agent-tools/subagent-scheduler.mjs +57 -8
  43. package/src/agent-tools/subagent-spawn.mjs +63 -16
  44. package/src/agent-tools/subagent.mjs +175 -49
  45. package/src/agent-tools/verify.mjs +13 -34
  46. package/src/agent-tools.mjs +1 -0
  47. package/src/agent.mjs +38 -21
  48. package/src/cli/distill-command.mjs +2 -2
  49. package/src/cli/make-agent.mjs +23 -7
  50. package/src/cli/memory-command.mjs +2 -2
  51. package/src/cli/setup-wizard.mjs +29 -9
  52. package/src/completions.mjs +114 -0
  53. package/src/config-migrate.mjs +70 -0
  54. package/src/config.mjs +132 -63
  55. package/src/conventions.mjs +223 -0
  56. package/src/crash-reports.mjs +7 -2
  57. package/src/expand-home.mjs +16 -0
  58. package/src/generate-title.mjs +1 -1
  59. package/src/hooks.mjs +7 -3
  60. package/src/memory/code-index.mjs +9 -3
  61. package/src/memory/code-sync.mjs +70 -31
  62. package/src/memory/delete.mjs +2 -0
  63. package/src/memory/docs.mjs +10 -6
  64. package/src/memory/file-walk.mjs +109 -0
  65. package/src/memory/schema.mjs +15 -3
  66. package/src/model-ref.mjs +66 -0
  67. package/src/model-specs.mjs +42 -8
  68. package/src/prompt-overlays.mjs +73 -16
  69. package/src/prompts/advisor-design.md +18 -8
  70. package/src/prompts/advisor-round1.md +7 -1
  71. package/src/prompts/advisor-round2.md +13 -2
  72. package/src/prompts/advisor-round3.md +13 -2
  73. package/src/prompts/common.md +115 -0
  74. package/src/prompts/consult-base.md +2 -0
  75. package/src/prompts/discipline-engineering.md +217 -0
  76. package/src/prompts/discipline-normal.md +179 -0
  77. package/src/prompts/persona-coder.md +21 -0
  78. package/src/prompts/persona-eng-coder.md +37 -0
  79. package/src/prompts/persona-eng-designer.md +55 -0
  80. package/src/prompts/persona-engineering.md +54 -0
  81. package/src/prompts/persona-explore.md +15 -0
  82. package/src/prompts/persona-normal.md +27 -0
  83. package/src/prompts/persona-plan.md +26 -0
  84. package/src/provider/anthropic.mjs +4 -4
  85. package/src/provider/core.mjs +13 -32
  86. package/src/provider/errors.mjs +26 -1
  87. package/src/provider/google.mjs +5 -6
  88. package/src/provider/index.mjs +2 -1
  89. package/src/provider/list-models.mjs +93 -0
  90. package/src/provider/rate.mjs +2 -1
  91. package/src/provider/responses.mjs +5 -3
  92. package/src/provider/sse.mjs +3 -4
  93. package/src/proxy.mjs +9 -14
  94. package/src/session-slots.mjs +7 -2
  95. package/src/session.mjs +59 -38
  96. package/src/token-ttl.mjs +2 -1
  97. package/src/tools/{system.mjs → bash.mjs} +6 -243
  98. package/src/tools/file.mjs +30 -10
  99. package/src/tools/git.md +1 -1
  100. package/src/tools/git.mjs +15 -34
  101. package/src/tools/index.mjs +4 -2
  102. package/src/tools/ops.mjs +20 -7
  103. package/src/tools/question.md +1 -0
  104. package/src/tools/question.mjs +26 -0
  105. package/src/tools/read.md +1 -1
  106. package/src/tools/read_image.md +1 -1
  107. package/src/tools/search.mjs +236 -0
  108. package/src/tui/agent-turn.mjs +32 -13
  109. package/src/tui/ansi.mjs +2 -0
  110. package/src/tui/clipboard.mjs +7 -1
  111. package/src/tui/cmd-advisor.mjs +3 -2
  112. package/src/tui/cmd-config.mjs +108 -37
  113. package/src/tui/cmd-eng.mjs +11 -27
  114. package/src/tui/cmd-exit.mjs +6 -8
  115. package/src/tui/cmd-model.mjs +14 -12
  116. package/src/tui/cmd-reindex.mjs +7 -0
  117. package/src/tui/cmd-submodel.mjs +8 -5
  118. package/src/tui/cmd-undo.mjs +4 -3
  119. package/src/tui/index.mjs +38 -37
  120. package/src/tui/key-handler.mjs +61 -17
  121. package/src/tui/key-modes.mjs +86 -8
  122. package/src/tui/layout.mjs +18 -10
  123. package/src/tui/model-catalog.mjs +89 -0
  124. package/src/tui/model-picker.mjs +498 -0
  125. package/src/tui/mouse.mjs +47 -10
  126. package/src/tui/pickers.mjs +28 -410
  127. package/src/tui/render-frame.mjs +32 -16
  128. package/src/tui/render-loop.mjs +2 -0
  129. package/src/tui/render-segments.mjs +5 -19
  130. package/src/tui/render.mjs +37 -5
  131. package/src/tui/slash-commands.mjs +2 -2
  132. package/src/tui/subagent-blocks.mjs +69 -90
  133. package/src/tui/subagent-children.mjs +50 -64
  134. package/src/tui/subagent-freeze.mjs +40 -43
  135. package/src/tui/subagent-panel.mjs +21 -66
  136. package/src/tui/suspension-drive.mjs +28 -82
  137. package/src/tui/tool-args.mjs +4 -2
  138. package/src/tui/tool-display.mjs +4 -3
  139. package/src/tui/tool-events.mjs +23 -16
  140. package/src/tui/tui-lifecycle.mjs +18 -5
  141. package/src/tui/wizard.mjs +52 -18
  142. package/src/tui/wrapped-spawn.mjs +38 -0
  143. package/src/prompts/coder.md +0 -13
  144. package/src/prompts/discipline.md +0 -84
  145. package/src/prompts/eng-coder.md +0 -19
  146. package/src/prompts/engineering-sub.md +0 -14
  147. package/src/prompts/engineering.md +0 -87
  148. package/src/prompts/explore.md +0 -12
  149. package/src/prompts/main.md +0 -34
  150. package/src/prompts/methodology-template.md +0 -38
  151. package/src/prompts/plan.md +0 -9
  152. package/src/prompts/system.md +0 -44
@@ -4,13 +4,30 @@
4
4
  import { readFile, stat } from "node:fs/promises"
5
5
  import { join, relative } from "node:path"
6
6
  import { embed, cosine, toBlob, fromBlob } from "../embedding.mjs"
7
- import { CODE_EXTS, DOC_EXTS, SKIP_DIRS, MAX_CODE_FILE_BYTES, MAX_DOC_FILE_BYTES } from "./schema.mjs"
7
+ import { CODE_EXTS, DOC_EXTS, MAX_CODE_FILE_BYTES, MAX_DOC_FILE_BYTES } from "./schema.mjs"
8
8
  import { buildFtsQuery, ensureEmbeddings, EMBED_TEXT_MAX_LEN } from "./core.mjs"
9
9
  import { detectLanguage, _upsertCodeFile, _upsertDocFile, yieldTick } from "./code-index.mjs"
10
+ import { walkProjectFiles, isSkippedRelPath, extensionOf, createUnlistedTally, MAX_WALK_FILES } from "./file-walk.mjs"
11
+ import { loadConventions } from "../conventions.mjs"
12
+ import { logEvent } from "../log.mjs"
10
13
 
11
14
  const DIFF_FULL_SYNC_THRESHOLD = 200
12
15
  const CODE_EMBED_BATCH = 64
13
16
 
17
+ /**
18
+ * Index extension sets = built-in tables ∪ project declaration
19
+ * (`.thincoder/conventions.json` → index.codeExtensions / index.docExtensions;
20
+ * PORTABILITY PO-9/§3.7 — a project may declare extensions this product does not
21
+ * ship a default for). Declaration only ADDS (union), never removes.
22
+ */
23
+ export function indexExtensions(dir) {
24
+ const conv = loadConventions(dir)
25
+ return {
26
+ code: new Set([...CODE_EXTS, ...conv.index.codeExtensions]),
27
+ doc: new Set([...DOC_EXTS, ...conv.index.docExtensions]),
28
+ }
29
+ }
30
+
14
31
  /**
15
32
  * git-driven incremental indexing: use git diff to find files changed since
16
33
  * the last index, and only rebuild FTS5 chunks for those files (vectors are untouched).
@@ -19,6 +36,7 @@ const CODE_EMBED_BATCH = 64
19
36
  */
20
37
  export async function gitSync(memory, dir, { onProgress } = {}) {
21
38
  const { execFile: _execFile } = await import("node:child_process")
39
+ const { code: codeExts, doc: docExts } = indexExtensions(dir)
22
40
  const gitRun = (args) => new Promise((resolve, reject) => {
23
41
  _execFile("git", args, { cwd: dir, encoding: "utf8", timeout: 10000, windowsHide: true }, (err, stdout) => {
24
42
  if (err) reject(err); else resolve(stdout)
@@ -55,17 +73,16 @@ export async function gitSync(memory, dir, { onProgress } = {}) {
55
73
  for (let i = 0; i < diffOut.length; i++) {
56
74
  const rel = diffOut[i].replaceAll("\\", "/")
57
75
  const abs = join(dir, rel)
58
- const ext = rel.slice(rel.lastIndexOf(".")).toLowerCase()
76
+ const ext = extensionOf(rel)
59
77
 
60
- const pathDirs = rel.split("/")
61
- if (pathDirs.some((d) => SKIP_DIRS.has(d) || d.startsWith("."))) continue
78
+ if (isSkippedRelPath(rel)) continue
62
79
 
63
- if (!CODE_EXTS.has(ext) && !DOC_EXTS.has(ext)) { skipped++; continue }
80
+ if (!codeExts.has(ext) && !docExts.has(ext)) { skipped++; continue }
64
81
 
65
82
  try {
66
83
  const text = await readFile(abs, "utf8")
67
84
  const lines = text.split("\n")
68
- if (CODE_EXTS.has(ext)) {
85
+ if (codeExts.has(ext)) {
69
86
  const lang = detectLanguage(abs)
70
87
  let mtimeMs = 0
71
88
  try { mtimeMs = Math.floor((await stat(abs)).mtimeMs) } catch { /* new file */ }
@@ -79,7 +96,7 @@ export async function gitSync(memory, dir, { onProgress } = {}) {
79
96
  } catch (e) {
80
97
  const isDeleted = e.code === "ENOENT"
81
98
  if (isDeleted) {
82
- if (CODE_EXTS.has(ext)) memory.db.prepare(`DELETE FROM code_chunks WHERE origin = ? AND path = ?`).run(dir, rel)
99
+ if (codeExts.has(ext)) memory.db.prepare(`DELETE FROM code_chunks WHERE origin = ? AND path = ?`).run(dir, rel)
83
100
  else memory.db.prepare(`DELETE FROM doc_chunks WHERE origin = ? AND path = ?`).run(dir, rel)
84
101
  removed++
85
102
  } else {
@@ -104,16 +121,27 @@ export async function gitSync(memory, dir, { onProgress } = {}) {
104
121
 
105
122
  /**
106
123
  * List project files matching the given extensions.
107
- * Only indexes git repos if dir isn't inside a git worktree, returns [].
108
- * Uses `git ls-files --cached --others --exclude-standard` to get the file list
109
- * (tracked + untracked-not-ignored, respecting .gitignore).
110
- * Returns an array of { abs, rel } pairs (abs = full path, rel = path relative to dir).
124
+ * Preferred source = git (`git ls-files --cached --others --exclude-standard`:
125
+ * tracked + untracked-not-ignored, .gitignore respected). Projects WITHOUT git
126
+ * fall back to a filesystem walk (PORTABILITY FR15/P8) — before, the index was
127
+ * silently empty there. The walk cannot honour .gitignore (no git no such
128
+ * concept) and skips the shared SKIP_DIRS/dot-directory set instead.
129
+ * Returns { entries, unlisted }: `entries` = { abs, rel } pairs (rel relative to
130
+ * dir); `unlisted` = files whose extension is in NO index list (count + sample),
131
+ * the visible signal behind "my .xyz files are not searchable" (PORTABILITY PO-9).
132
+ * `truncated` = the non-git walk hit its file cap (capped trees are logged, never
133
+ * silently indexed-partial). opts.maxFiles = the walk cap (test seam).
111
134
  */
112
- export async function listProjectFiles(dir, exts) {
135
+ export async function listProjectFiles(dir, exts, { maxFiles } = {}) {
113
136
  const { execFile: _execFile } = await import("node:child_process")
114
137
  const { join: joinPath } = await import("node:path")
138
+ const { code, doc } = indexExtensions(dir)
139
+ const tally = createUnlistedTally(new Set([...code, ...doc]))
115
140
 
116
- // Only index git repos — if this isn't one, return empty
141
+ const files = []
142
+ const finish = (truncated = false) => ({ entries: files, unlisted: tally.result(), truncated })
143
+
144
+ // Git listing when available; a non-git project falls through to the walk.
117
145
  let gitTop
118
146
  try {
119
147
  gitTop = (await new Promise((resolve, reject) => {
@@ -121,10 +149,17 @@ export async function listProjectFiles(dir, exts) {
121
149
  (err, stdout) => { if (err) reject(err); else resolve(stdout.trim()) })
122
150
  })).replace(/\\/g, "/")
123
151
  } catch {
124
- return [] // not a git repo → nothing to index
152
+ // Not a git repo → filesystem walk (FR15: the index must still be usable).
153
+ const walked = await walkProjectFiles(dir, exts, { knownExts: new Set([...code, ...doc]), ...(maxFiles !== undefined ? { maxFiles } : {}) })
154
+ files.push(...walked.files)
155
+ // The walk's cap must stay visible end-to-end (file-walk.mjs: "never silently
156
+ // dropped") — a capped listing says so in the log, not only in a discarded flag.
157
+ if (walked.truncated) {
158
+ logEvent("index:truncated", { dir, files: walked.files.length, maxFiles: maxFiles ?? MAX_WALK_FILES })
159
+ }
160
+ return { entries: files, unlisted: walked.unlisted, truncated: walked.truncated }
125
161
  }
126
162
 
127
- const files = []
128
163
  try {
129
164
  const raw = await new Promise((resolve, reject) => {
130
165
  _execFile("git", ["ls-files", "--cached", "--others", "--exclude-standard"],
@@ -134,16 +169,15 @@ export async function listProjectFiles(dir, exts) {
134
169
  for (const line of raw.trim().split("\n")) {
135
170
  const p = line.trim()
136
171
  if (!p) continue
137
- const ext = p.slice(p.lastIndexOf(".")).toLowerCase()
138
- if (!exts.has(ext)) continue
139
- const abs = joinPath(dir, p)
140
172
  const rel = p.replace(/\\/g, "/")
141
- if (rel.split("/").some((seg) => SKIP_DIRS.has(seg) || seg.startsWith("."))) continue
142
- files.push({ abs, rel })
173
+ if (isSkippedRelPath(rel)) continue
174
+ const ext = extensionOf(rel)
175
+ if (!ext || !exts.has(ext)) { tally.note(rel); continue }
176
+ files.push({ abs: joinPath(dir, rel), rel })
143
177
  }
144
178
  } catch { /* ls-files failed */ }
145
179
 
146
- return files
180
+ return finish()
147
181
  }
148
182
 
149
183
 
@@ -152,7 +186,8 @@ export async function listProjectFiles(dir, exts) {
152
186
  * Incremental by mtime — only rebuilds chunks for files that have changed.
153
187
  */
154
188
  export async function codeSync(memory, dir, { onProgress } = {}) {
155
- const entries = await listProjectFiles(dir, CODE_EXTS)
189
+ const { code: exts } = indexExtensions(dir)
190
+ const { entries, unlisted } = await listProjectFiles(dir, exts)
156
191
  const files = [] // { abs, rel, mtimeMs }
157
192
  let overSizeSkipped = 0
158
193
  for (const { abs, rel } of entries) {
@@ -206,7 +241,10 @@ export async function codeSync(memory, dir, { onProgress } = {}) {
206
241
 
207
242
  onProgress?.({ phase: "done", total: files.length, updated, removed, skipped, failed, overSizeSkipped })
208
243
  markIndexedCommit(memory, dir)
209
- return { updated, removed, skipped, failed, errors, total: files.length, overSizeSkipped }
244
+ if (unlisted.count > 0) {
245
+ logEvent("index:unlisted", { dir, kind: "code", count: unlisted.count, exts: unlisted.exts.map((e) => e.ext) })
246
+ }
247
+ return { updated, removed, skipped, failed, errors, total: files.length, overSizeSkipped, unlistedExts: unlisted }
210
248
  }
211
249
 
212
250
  /** Record current HEAD as the index anchor (gitSync incremental diff baseline); silently skip non-git repos */
@@ -338,32 +376,33 @@ export function codeSearchTool(memory) {
338
376
  * Single-file incremental reindex: called after write/edit/delete, only rebuilds this one path.
339
377
  */
340
378
  export async function reindexFile(memory, cwd, absPath) {
341
- const ext = absPath.slice(absPath.lastIndexOf(".")).toLowerCase()
379
+ const ext = extensionOf(absPath)
342
380
  const rel = relative(cwd, absPath).replaceAll("\\", "/")
343
381
  if (rel === ".." || rel.startsWith("../")) return
344
- const dirs = rel.split("/").slice(0, -1)
345
- if (dirs.some((d) => SKIP_DIRS.has(d) || d.startsWith("."))) return
382
+ if (isSkippedRelPath(rel)) return
383
+ // Declared extensions count for the single-file path too (union with built-ins).
384
+ const { code: codeExts, doc: docExts } = indexExtensions(cwd)
346
385
 
347
386
  // skip oversized files (minified bundles, test fixtures, generated code)
348
- const maxBytes = CODE_EXTS.has(ext) ? MAX_CODE_FILE_BYTES : DOC_EXTS.has(ext) ? MAX_DOC_FILE_BYTES : 0
387
+ const maxBytes = codeExts.has(ext) ? MAX_CODE_FILE_BYTES : docExts.has(ext) ? MAX_DOC_FILE_BYTES : 0
349
388
  if (maxBytes > 0) {
350
389
  try { const st = await stat(absPath); if (st.size > maxBytes) return } catch { /* can't stat, proceed */ }
351
390
  }
352
391
 
353
392
  let text
354
393
  try { text = await readFile(absPath, "utf8") } catch {
355
- if (CODE_EXTS.has(ext)) memory.db.prepare(`DELETE FROM code_chunks WHERE origin = ? AND path = ?`).run(cwd, rel)
356
- else if (DOC_EXTS.has(ext)) memory.db.prepare(`DELETE FROM doc_chunks WHERE origin = ? AND path = ?`).run(cwd, rel)
394
+ if (codeExts.has(ext)) memory.db.prepare(`DELETE FROM code_chunks WHERE origin = ? AND path = ?`).run(cwd, rel)
395
+ else if (docExts.has(ext)) memory.db.prepare(`DELETE FROM doc_chunks WHERE origin = ? AND path = ?`).run(cwd, rel)
357
396
  return
358
397
  }
359
398
  const lines = text.split("\n")
360
399
 
361
- if (CODE_EXTS.has(ext)) {
400
+ if (codeExts.has(ext)) {
362
401
  const lang = detectLanguage(absPath)
363
402
  let mtimeMs = 0
364
403
  try { mtimeMs = Math.floor((await stat(absPath)).mtimeMs) } catch { /* new file */ }
365
404
  _upsertCodeFile(memory, cwd, rel, lines, lang, mtimeMs)
366
- } else if (DOC_EXTS.has(ext)) {
405
+ } else if (docExts.has(ext)) {
367
406
  let mtimeMs = 0
368
407
  try { mtimeMs = Math.floor((await stat(absPath)).mtimeMs) } catch { /* new file */ }
369
408
  _upsertDocFile(memory, cwd, rel, lines, mtimeMs)
@@ -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,15 @@
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
8
  import { commitAndPush } from "../git/gitmem.mjs"
9
- import { DOC_EXTS, SKIP_DIRS, MAX_DOC_FILE_BYTES } from "./schema.mjs"
9
+ import { MAX_DOC_FILE_BYTES } from "./schema.mjs"
10
10
  import { buildFtsQuery, put, search, putMarkdown, clearPersonal, EMBED_TEXT_MAX_LEN } from "./core.mjs"
11
11
  import { deleteByUid, matchMemoryRows, deleteWhere } from "./delete.mjs"
12
12
  import { _upsertDocFile, yieldTick } from "./code-index.mjs"
13
- import { markIndexedCommit, listProjectFiles } from "./code-sync.mjs"
13
+ import { markIndexedCommit, listProjectFiles, indexExtensions } from "./code-sync.mjs"
14
+ import { logEvent } from "../log.mjs"
14
15
 
15
16
  const DOC_EMBED_BATCH = 64
16
17
 
@@ -19,7 +20,7 @@ const DOC_EMBED_BATCH = 64
19
20
  * Incremental by mtime.
20
21
  */
21
22
  export async function docSync(memory, dir, { onProgress } = {}) {
22
- const entries = await listProjectFiles(dir, DOC_EXTS)
23
+ const { entries, unlisted } = await listProjectFiles(dir, indexExtensions(dir).doc)
23
24
  const files = [] // { abs, rel, mtimeMs }
24
25
  let overSizeSkipped = 0
25
26
  for (const { abs, rel } of entries) {
@@ -72,7 +73,10 @@ export async function docSync(memory, dir, { onProgress } = {}) {
72
73
 
73
74
  onProgress?.({ phase: "done", total: files.length, updated, removed, skipped, failed, overSizeSkipped })
74
75
  markIndexedCommit(memory, dir)
75
- return { updated, removed, skipped, failed, errors, total: files.length, overSizeSkipped }
76
+ if (unlisted.count > 0) {
77
+ logEvent("index:unlisted", { dir, kind: "doc", count: unlisted.count, exts: unlisted.exts.map((e) => e.ext) })
78
+ }
79
+ return { updated, removed, skipped, failed, errors, total: files.length, overSizeSkipped, unlistedExts: unlisted }
76
80
  }
77
81
 
78
82
  /**
@@ -233,7 +237,7 @@ const listRowLine = (r) => `[${r.layer}] ${r.id} [${r.type}] ${r.title}(${fmtD
233
237
  * opts: { cwd, projectDir, author, team: { dir, name } | null }
234
238
  */
235
239
  export function memoryTools(memory, opts = {}) {
236
- const projectDir = opts.projectDir ? join(opts.cwd ?? process.cwd(), opts.projectDir) : null
240
+ const projectDir = opts.projectDir ? (isAbsolute(opts.projectDir) ? opts.projectDir : join(opts.cwd ?? process.cwd(), opts.projectDir)) : null
237
241
  const dirs = { project: projectDir, team: opts.team?.dir ?? null }
238
242
  return [
239
243
  {
@@ -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
+ }
@@ -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
  *