thincoder 0.12.59 → 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 (192) hide show
  1. package/CHANGELOG.md +62 -4
  2. package/README.md +10 -8
  3. package/bin/thincoder.mjs +99 -133
  4. package/package.json +6 -4
  5. package/src/abort-provenance.mjs +116 -0
  6. package/src/acp/bridge.mjs +45 -21
  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 +59 -137
  12. package/src/advisor/project-context.mjs +194 -0
  13. package/src/advisor/repos.mjs +17 -40
  14. package/src/advisor/run.mjs +156 -359
  15. package/src/advisor/truncate.mjs +57 -0
  16. package/src/advisor.mjs +27 -7
  17. package/src/agent/completion.mjs +17 -11
  18. package/src/agent/dispatch.mjs +145 -27
  19. package/src/agent/helpers.mjs +107 -13
  20. package/src/agent/record-results.mjs +55 -11
  21. package/src/agent/relay-prefix.mjs +39 -0
  22. package/src/agent/run-stages.mjs +242 -0
  23. package/src/agent/setup-reminders.mjs +69 -0
  24. package/src/agent/setup.mjs +107 -127
  25. package/src/agent/spawn-child.mjs +55 -13
  26. package/src/agent-tools/advisor-async.mjs +346 -0
  27. package/src/agent-tools/advisor-settle.mjs +231 -0
  28. package/src/agent-tools/advisor.mjs +167 -116
  29. package/src/agent-tools/async-settle.mjs +191 -0
  30. package/src/agent-tools/batch-segment.mjs +195 -0
  31. package/src/agent-tools/consult.mjs +139 -107
  32. package/src/agent-tools/design-token.mjs +117 -0
  33. package/src/agent-tools/digest-budget.mjs +76 -0
  34. package/src/agent-tools/eng.mjs +24 -29
  35. package/src/agent-tools/escalate-async.mjs +289 -0
  36. package/src/agent-tools/read-history.mjs +166 -32
  37. package/src/agent-tools/recent-changes.mjs +2 -1
  38. package/src/agent-tools/review-streak.mjs +93 -0
  39. package/src/agent-tools/settings.mjs +137 -34
  40. package/src/agent-tools/subagent-actions.mjs +180 -133
  41. package/src/agent-tools/subagent-async.mjs +184 -177
  42. package/src/agent-tools/subagent-panel.mjs +160 -0
  43. package/src/agent-tools/subagent-run.mjs +205 -0
  44. package/src/agent-tools/subagent-scheduler.mjs +100 -27
  45. package/src/agent-tools/subagent-spawn.mjs +453 -0
  46. package/src/agent-tools/subagent.mjs +256 -578
  47. package/src/agent-tools/verify.mjs +119 -292
  48. package/src/agent-tools.mjs +1 -0
  49. package/src/agent.mjs +89 -205
  50. package/src/cli/distill-command.mjs +12 -6
  51. package/src/cli/make-agent.mjs +26 -8
  52. package/src/cli/memory-command.mjs +4 -3
  53. package/src/cli/permission.mjs +2 -2
  54. package/src/cli/setup-wizard.mjs +42 -17
  55. package/src/completions.mjs +114 -0
  56. package/src/config-migrate.mjs +70 -0
  57. package/src/config.mjs +180 -63
  58. package/src/context.mjs +5 -147
  59. package/src/conventions.mjs +223 -0
  60. package/src/crash-reports.mjs +128 -0
  61. package/src/distill.mjs +11 -11
  62. package/src/expand-home.mjs +16 -0
  63. package/src/explore-distill.mjs +155 -0
  64. package/src/generate-title.mjs +1 -1
  65. package/src/hooks.mjs +7 -3
  66. package/src/memory/code-index.mjs +9 -3
  67. package/src/memory/code-sync.mjs +72 -32
  68. package/src/memory/core.mjs +6 -193
  69. package/src/memory/delete.mjs +236 -0
  70. package/src/memory/docs.mjs +68 -54
  71. package/src/memory/file-walk.mjs +109 -0
  72. package/src/memory/schema.mjs +15 -3
  73. package/src/memory.mjs +3 -1
  74. package/src/model-ref.mjs +66 -0
  75. package/src/model-specs.mjs +42 -8
  76. package/src/peer-domains.mjs +265 -0
  77. package/src/peer-instances.mjs +231 -0
  78. package/src/prompt-overlays.mjs +82 -0
  79. package/src/prompts/advisor-design.md +18 -75
  80. package/src/prompts/advisor-round1.md +14 -67
  81. package/src/prompts/advisor-round2.md +15 -51
  82. package/src/prompts/advisor-round3.md +15 -51
  83. package/src/prompts/common.md +115 -0
  84. package/src/prompts/consult-base.md +5 -23
  85. package/src/prompts/discipline-engineering.md +217 -0
  86. package/src/prompts/discipline-normal.md +179 -0
  87. package/src/prompts/persona-coder.md +21 -0
  88. package/src/prompts/persona-eng-coder.md +37 -0
  89. package/src/prompts/persona-eng-designer.md +55 -0
  90. package/src/prompts/persona-engineering.md +54 -0
  91. package/src/prompts/persona-explore.md +15 -0
  92. package/src/prompts/persona-normal.md +27 -0
  93. package/src/prompts/persona-plan.md +26 -0
  94. package/src/provider/anthropic.mjs +4 -4
  95. package/src/provider/core.mjs +18 -98
  96. package/src/provider/errors.mjs +101 -0
  97. package/src/provider/google.mjs +5 -6
  98. package/src/provider/index.mjs +2 -1
  99. package/src/provider/list-models.mjs +93 -0
  100. package/src/provider/rate.mjs +2 -1
  101. package/src/provider/responses.mjs +5 -3
  102. package/src/provider/retry.mjs +8 -45
  103. package/src/provider/sse.mjs +3 -4
  104. package/src/proxy.mjs +9 -14
  105. package/src/session-gc.mjs +214 -0
  106. package/src/session-guard.mjs +47 -0
  107. package/src/session-rename.mjs +38 -0
  108. package/src/session-slots.mjs +188 -60
  109. package/src/session.mjs +104 -124
  110. package/src/token-ttl.mjs +274 -0
  111. package/src/tools/{system.mjs → bash.mjs} +19 -221
  112. package/src/tools/checklist-sync.mjs +181 -0
  113. package/src/tools/checklist.mjs +52 -39
  114. package/src/tools/edit-batch.mjs +109 -10
  115. package/src/tools/edit-diff.mjs +110 -27
  116. package/src/tools/edit.md +17 -12
  117. package/src/tools/execute.mjs +31 -4
  118. package/src/tools/file.mjs +41 -16
  119. package/src/tools/git.md +1 -1
  120. package/src/tools/git.mjs +23 -34
  121. package/src/tools/glob-dialect.mjs +130 -0
  122. package/src/tools/glob.md +3 -3
  123. package/src/tools/grep.md +1 -1
  124. package/src/tools/index.mjs +9 -8
  125. package/src/tools/ops.mjs +188 -3
  126. package/src/tools/patch.mjs +3 -3
  127. package/src/tools/question.md +4 -0
  128. package/src/tools/question.mjs +26 -0
  129. package/src/tools/read.md +1 -2
  130. package/src/tools/read_image.md +1 -1
  131. package/src/tools/search.mjs +236 -0
  132. package/src/tools/shared.mjs +14 -13
  133. package/src/tools/wait_for.md +22 -0
  134. package/src/tui/agent-turn.mjs +36 -228
  135. package/src/tui/ansi.mjs +2 -0
  136. package/src/tui/clipboard.mjs +7 -1
  137. package/src/tui/cmd-advisor.mjs +3 -2
  138. package/src/tui/cmd-config.mjs +142 -30
  139. package/src/tui/cmd-eng.mjs +28 -40
  140. package/src/tui/cmd-exit.mjs +6 -8
  141. package/src/tui/cmd-mcp.mjs +8 -2
  142. package/src/tui/cmd-model.mjs +14 -12
  143. package/src/tui/cmd-new.mjs +3 -2
  144. package/src/tui/cmd-reindex.mjs +7 -0
  145. package/src/tui/cmd-session.mjs +19 -4
  146. package/src/tui/cmd-submodel.mjs +8 -5
  147. package/src/tui/cmd-think.mjs +10 -10
  148. package/src/tui/cmd-undo.mjs +4 -3
  149. package/src/tui/cmd-upgrade.mjs +19 -4
  150. package/src/tui/config-helpers.mjs +28 -16
  151. package/src/tui/distill-cmd.mjs +1 -1
  152. package/src/tui/index.mjs +40 -38
  153. package/src/tui/interaction.mjs +3 -3
  154. package/src/tui/key-handler.mjs +61 -17
  155. package/src/tui/key-modes.mjs +86 -8
  156. package/src/tui/layout.mjs +18 -10
  157. package/src/tui/model-catalog.mjs +89 -0
  158. package/src/tui/model-picker.mjs +498 -0
  159. package/src/tui/mouse.mjs +52 -9
  160. package/src/tui/pickers.mjs +28 -392
  161. package/src/tui/render-frame.mjs +32 -16
  162. package/src/tui/render-loop.mjs +2 -0
  163. package/src/tui/render-segments.mjs +12 -9
  164. package/src/tui/render.mjs +37 -5
  165. package/src/tui/slash-commands.mjs +2 -2
  166. package/src/tui/startup.mjs +4 -0
  167. package/src/tui/subagent-blocks.mjs +106 -295
  168. package/src/tui/subagent-children.mjs +162 -0
  169. package/src/tui/subagent-freeze.mjs +169 -0
  170. package/src/tui/subagent-panel.mjs +24 -31
  171. package/src/tui/suspension-drive.mjs +297 -0
  172. package/src/tui/tool-args.mjs +7 -5
  173. package/src/tui/tool-display.mjs +143 -0
  174. package/src/tui/tool-events.mjs +56 -185
  175. package/src/tui/tui-lifecycle.mjs +46 -4
  176. package/src/tui/update-notice.mjs +4 -0
  177. package/src/tui/wizard.mjs +61 -21
  178. package/src/tui/wrapped-spawn.mjs +38 -0
  179. package/src/prompts/coder.md +0 -56
  180. package/src/prompts/discipline.md +0 -102
  181. package/src/prompts/eng-coder.md +0 -44
  182. package/src/prompts/engineering-sub.md +0 -34
  183. package/src/prompts/engineering.md +0 -340
  184. package/src/prompts/explore.md +0 -21
  185. package/src/prompts/main.md +0 -56
  186. package/src/prompts/methodology-template.md +0 -58
  187. package/src/prompts/plan.md +0 -16
  188. package/src/prompts/system.md +0 -63
  189. package/src/tools/pdf-parse-text.mjs +0 -497
  190. package/src/tools/pdf-parse-xref.mjs +0 -499
  191. package/src/tools/pdf.mjs +0 -155
  192. package/src/tools/read_pdf.md +0 -21
@@ -3,13 +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"
10
- import { buildFtsQuery, put, search, putMarkdown, deleteByUid, matchMemoryRows, deleteWhere, clearPersonal, EMBED_TEXT_MAX_LEN } from "./core.mjs"
9
+ import { MAX_DOC_FILE_BYTES } from "./schema.mjs"
10
+ import { buildFtsQuery, put, search, putMarkdown, clearPersonal, EMBED_TEXT_MAX_LEN } from "./core.mjs"
11
+ import { deleteByUid, matchMemoryRows, deleteWhere } from "./delete.mjs"
11
12
  import { _upsertDocFile, yieldTick } from "./code-index.mjs"
12
- import { markIndexedCommit, listProjectFiles } from "./code-sync.mjs"
13
+ import { markIndexedCommit, listProjectFiles, indexExtensions } from "./code-sync.mjs"
14
+ import { logEvent } from "../log.mjs"
13
15
 
14
16
  const DOC_EMBED_BATCH = 64
15
17
 
@@ -18,7 +20,7 @@ const DOC_EMBED_BATCH = 64
18
20
  * Incremental by mtime.
19
21
  */
20
22
  export async function docSync(memory, dir, { onProgress } = {}) {
21
- const entries = await listProjectFiles(dir, DOC_EXTS)
23
+ const { entries, unlisted } = await listProjectFiles(dir, indexExtensions(dir).doc)
22
24
  const files = [] // { abs, rel, mtimeMs }
23
25
  let overSizeSkipped = 0
24
26
  for (const { abs, rel } of entries) {
@@ -71,7 +73,10 @@ export async function docSync(memory, dir, { onProgress } = {}) {
71
73
 
72
74
  onProgress?.({ phase: "done", total: files.length, updated, removed, skipped, failed, overSizeSkipped })
73
75
  markIndexedCommit(memory, dir)
74
- 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 }
75
80
  }
76
81
 
77
82
  /**
@@ -165,7 +170,8 @@ export function docSearchTool(memory) {
165
170
  name: "doc_search",
166
171
  description:
167
172
  "Search the project's documentation (README, design docs, guides, markdown files) for relevant information. Use this to find design decisions, coding conventions, architecture docs, or project rules. Prefer this over code_search when you need to understand the project's intended design rather than existing implementation. " +
168
- "Returns matching doc chunks: path, heading, line range, relevance score, content excerpt.",
173
+ "Returns matching doc chunks: path, heading, line range, relevance score, content excerpt. " +
174
+ "For what was said in sessions (conversation/chat history — decisions, rulings), use read_history.",
169
175
  parameters: {
170
176
  type: "object",
171
177
  properties: {
@@ -188,19 +194,21 @@ export function docSearchTool(memory) {
188
194
  // ---------------------------------------------------------------- agent tools
189
195
 
190
196
  /** §6 shared tool surface — action enum / parameter shapes / descriptions byte-identical
191
- * with thincoder-vscode/src/memory.mjs (MEMORY.md §6 D-M1/F-M6); scope VALUES per end
197
+ * with thincoder-vscode/src/memory.mjs (MEMORY.md §6 D-M1/F-M6); layer VALUES per end
192
198
  * (VS Code has no team layer and rejects it with CLI guidance). */
193
199
  const MEMORY_ACTIONS = ["search", "put", "list", "delete", "clear"]
194
- const MEMORY_SCOPES = ["personal", "project", "team"]
200
+ const MEMORY_LAYERS = ["personal", "project", "team"]
195
201
  const MEMORY_TOOL_DESCRIPTION =
196
202
  "Manage long-term memory in ONE tool — the action parameter picks the operation:\n" +
197
- "- search — find knowledge saved in previous sessions (query, optional scope/limit); results include every entry's id\n" +
198
- "- put — save a piece of knowledge for future sessions (type: rule = coding standards, knowledge = project facts, decision = architecture decisions, pattern = debugging/workflow patterns; title/content/tags/scope)\n" +
199
- "- list — inventory what memory holds: optional scope/type/keyword filters, limit default 50; one row per entry: id [type] title (date); a truncated list notes the full count\n" +
200
- "- delete — SINGLE: {id, scope} deletes one entry by the id shown in put/search/list output. BATCH: {scope + type and/or keyword} deletes every matching entry in that scopea call without confirm:true is refused and returns the count plus a preview (re-send with confirm:true to execute); scope-wide wipes without filters are refused on every layer\n" +
201
- "- clear — {scope: \"personal\", confirm: true} wipes ALL personal memory entries. clear is personal-only: a missing scope or a project/team scope is refused (use delete batch filters on shared layers)\n" +
203
+ "- search — find knowledge saved in previous sessions (query, optional layer/limit); result rows start with a [layer] tag and carry the entry id (id prefix = the layer)\n" +
204
+ "- put — save a piece of knowledge for future sessions (type: rule = coding standards, knowledge = project facts, decision = architecture decisions, pattern = debugging/workflow patterns; title/content/tags; layer defaults to personal)\n" +
205
+ "- list — inventory what memory holds (optional layer/type/keyword filters, limit default 50); one row per entry: [layer] id [type] title (date); a truncated list notes the full count\n" +
206
+ "- delete — SINGLE: {id, layer} removes the entry shown in put/search/list output — layer is optional: when passed it is validated against the id prefix (a mismatch is refused — guards against deleting the wrong entry); when omitted the id prefix routes the delete, so any id search/list returned is directly deletable. BATCH (no id): {layer, type and/or keyword} removes every matching entry in that layerlayer and at least one of type/keyword are required, plus confirm:true (without confirm it returns the count plus a preview); layer-wide wipes without filters are refused on every layer\n" +
207
+ "- clear — {layer: \"personal\", confirm: true} wipes ALL personal memory entries. clear is personal-only: a missing layer or a project/team layer is refused (use delete batch filters on shared layers)\n" +
208
+ "Layer is the memory tier: personal (private), project (shared via this repo's .thincoder/memory/), team (CLI only, git-synced). The [layer] tag on search/list result rows, the row's id prefix, and the layer parameter are the same concept — pass a result row's [layer] as layer, or omit it on a single delete to auto-route by the id prefix.\n" +
202
209
  "Deleting project/team (CLI) entries removes the local markdown file and its index row — team deletion is local only and a later team sync may resurrect the file while the remote still has it.\n" +
203
- "Save bugs, conventions, and preferences here — they persist across sessions."
210
+ "Save bugs, conventions, and preferences here — they persist across sessions.\n" +
211
+ "Session message history (what was said in this or past sessions) is NOT in memory — search session messages with read_history."
204
212
 
205
213
  function validateTypeFilter(type) {
206
214
  if (type === undefined || type === null || type === "") return null
@@ -218,18 +226,18 @@ function fmtDate(ts) {
218
226
  return ts ? new Date(ts).toISOString().slice(0, 10) : "?"
219
227
  }
220
228
 
221
- const listRowLine = (r) => `${r.id} [${r.type}] ${r.title}(${fmtDate(r.ts)})`
229
+ const listRowLine = (r) => `[${r.layer}] ${r.id} [${r.type}] ${r.title}(${fmtDate(r.ts)})`
222
230
 
223
231
  /**
224
232
  * Generate the memory agent tool — ONE `memory` tool with five actions (MEMORY.md §6 D-M1).
225
233
  * search/list are read-only actions (planMode pass / no permission ask — dispatch classifies
226
234
  * them action-level, same as subagent check/status); put keeps its side-effect permission
227
- * gate; batch delete/clear gate on confirm:true + scope inside the tool (direct-delete
235
+ * gate; batch delete/clear gate on confirm:true + layer inside the tool (direct-delete
228
236
  * ruling — the confirm parameter IS the gate) and stay non-readonly like the retired tools.
229
237
  * opts: { cwd, projectDir, author, team: { dir, name } | null }
230
238
  */
231
239
  export function memoryTools(memory, opts = {}) {
232
- 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
233
241
  const dirs = { project: projectDir, team: opts.team?.dir ?? null }
234
242
  return [
235
243
  {
@@ -239,7 +247,7 @@ export function memoryTools(memory, opts = {}) {
239
247
  type: "object",
240
248
  properties: {
241
249
  action: { type: "string", enum: MEMORY_ACTIONS, description: "Operation to run (required)" },
242
- scope: { type: "string", enum: MEMORY_SCOPES, description: "Where the memory lives: personal (private), project (shared via this repo's .thincoder/memory/), team (CLI only). put defaults to personal; search/list search every layer when omitted; delete/clear require it" },
250
+ layer: { type: "string", enum: MEMORY_LAYERS, description: "The memory layer: personal (private), project (shared via this repo's .thincoder/memory/), team (CLI only). Same concept as the [layer] tag and the id prefix on search/list result rows. put/search/list: optional (put defaults to personal; search/list omit = all layers). single delete: optional (omit = route by id prefix). batch delete/clear: required" },
243
251
  type: { type: "string", enum: ["rule", "knowledge", "decision", "pattern"], description: "Entry type: put = what to save; list/delete batch = filter by type" },
244
252
  title: { type: "string", description: "put: short title" },
245
253
  content: { type: "string", description: "put: full content to remember" },
@@ -272,21 +280,21 @@ export function memoryTools(memory, opts = {}) {
272
280
 
273
281
  /** action search — the retired search tool surface (read-only, same output contract). */
274
282
  async function execSearch(memory, args) {
275
- const scope = args.scope
276
- if (scope !== undefined && scope !== null && !MEMORY_SCOPES.includes(String(scope))) {
277
- throw new Error(`memory search: invalid scope "${scope}"`)
283
+ const layer = args.layer
284
+ if (layer !== undefined && layer !== null && !MEMORY_LAYERS.includes(String(layer))) {
285
+ throw new Error(`memory search: invalid layer "${layer}"`)
278
286
  }
279
287
  const query = String(args.query ?? "").trim()
280
288
  if (!query) return "(no matching memories)" // 空 query 短路——两端同语义(评审 code review #4)
281
289
  const limit = normalizeLimit(args.limit, 5)
282
290
  let results
283
- if (!scope) {
291
+ if (!layer) {
284
292
  results = await search(memory, query, { limit })
285
293
  } else {
286
- // scope filter: oversample then slice the requested layer (results keep global rank order).
294
+ // layer filter: oversample then slice the requested layer (results keep global rank order).
287
295
  // 窗口 = max(limit*4, 20) 是召回上限——大库 + 高 limit 时该层结果可能不足 limit(接受的取舍——评审 code review #3)
288
296
  const wide = await search(memory, query, { limit: Math.max(limit * 4, 20) })
289
- results = wide.filter((r) => r.layer === String(scope)).slice(0, limit)
297
+ results = wide.filter((r) => r.layer === String(layer)).slice(0, limit)
290
298
  }
291
299
  if (results.length === 0) return "(no matching memories)"
292
300
  return results.map((r) => `[${r.layer}][${r.type}] ${r.title} (id=${r.id})\n${r.content}`).join("\n\n")
@@ -294,14 +302,14 @@ async function execSearch(memory, args) {
294
302
 
295
303
  /** action put — the retired put tool surface (side-effect gate, unchanged semantics). */
296
304
  async function execPut(memory, args, opts, dirs) {
297
- const scope = String(args.scope ?? "personal")
298
- if (!MEMORY_SCOPES.includes(scope)) throw new Error(`memory put: invalid scope "${scope}"`)
299
- if (scope === "personal") {
305
+ const layer = String(args.layer ?? "personal")
306
+ if (!MEMORY_LAYERS.includes(layer)) throw new Error(`memory put: invalid layer "${layer}"`)
307
+ if (layer === "personal") {
300
308
  const id = await put(memory, { type: args.type, title: args.title, content: args.content, tags: args.tags ?? "" })
301
309
  return `Saved to personal memory (id=personal:${id}): [${args.type}] ${args.title}`
302
310
  }
303
- if (scope === "project") {
304
- if (!dirs.project) throw new Error("project scope unavailable: no project directory configured")
311
+ if (layer === "project") {
312
+ if (!dirs.project) throw new Error("project layer unavailable: no project directory configured")
305
313
  const filename = await putMarkdown(memory, {
306
314
  layer: "project",
307
315
  dir: dirs.project,
@@ -314,7 +322,7 @@ async function execPut(memory, args, opts, dirs) {
314
322
  return `Saved to project memory (id=project:${dirs.project}:${filename}): [${args.type}] ${args.title}`
315
323
  }
316
324
  if (!dirs.team) {
317
- throw new Error("team scope not configured: set memory.team in ~/.thincoder/config.json")
325
+ throw new Error("team layer not configured: set memory.team in ~/.thincoder/config.json")
318
326
  }
319
327
  const filename = await putMarkdown(memory, {
320
328
  layer: "team",
@@ -329,12 +337,12 @@ async function execPut(memory, args, opts, dirs) {
329
337
  return `Saved to team memory and pushed (id=team:${dirs.team}:${filename}): [${args.type}] ${args.title}`
330
338
  }
331
339
 
332
- /** action list — new inventory action (read-only): scope/type/keyword filters + limit truncation note. */
340
+ /** action list — new inventory action (read-only): layer/type/keyword filters + limit truncation note. */
333
341
  async function execList(memory, args, dirs) {
334
- const scope = args.scope ?? null
335
- if (scope && !MEMORY_SCOPES.includes(String(scope))) throw new Error(`memory list: invalid scope "${scope}"`)
342
+ const layer = args.layer ?? null
343
+ if (layer && !MEMORY_LAYERS.includes(String(layer))) throw new Error(`memory list: invalid layer "${layer}"`)
336
344
  const rows = await matchMemoryRows(memory, {
337
- scope: scope ? String(scope) : null,
345
+ layer: layer ? String(layer) : null,
338
346
  type: validateTypeFilter(args.type),
339
347
  keyword: args.keyword ? String(args.keyword).trim() : null,
340
348
  projectDir: dirs.project,
@@ -348,22 +356,23 @@ async function execList(memory, args, dirs) {
348
356
  return lines.join("\n")
349
357
  }
350
358
 
351
- /** action delete — single ({ id, scope } — §0.1-era single-delete semantics) + batch (scope + type/keyword + confirm). */
359
+ /** action delete — single ({ id, layer? } — MEMORY.md §6.2: layer OPTIONAL, validated when
360
+ * passed, else the id prefix routes the delete) + batch (layer + type/keyword + confirm). */
352
361
  async function execDelete(memory, args, dirs) {
353
362
  const hasId = args.id !== undefined && args.id !== null && String(args.id) !== ""
354
363
  if (hasId) return execDeleteSingle(memory, args, dirs)
355
364
  // batch form
356
- const scope = args.scope
357
- if (!scope) throw new Error("batch delete requires scope plus type and/or keyword filter")
358
- if (!MEMORY_SCOPES.includes(String(scope))) throw new Error(`memory delete: invalid scope "${scope}"`)
365
+ const layer = args.layer
366
+ if (!layer) throw new Error("batch delete requires layer plus type and/or keyword filter")
367
+ if (!MEMORY_LAYERS.includes(String(layer))) throw new Error(`memory delete: invalid layer "${layer}"`)
359
368
  const type = validateTypeFilter(args.type)
360
369
  const keyword = args.keyword ? String(args.keyword).trim() : null
361
370
  if (!type && !keyword) {
362
- throw new Error("batch delete requires type and/or keyword filter — a scope-wide wipe without filters is refused (personal full wipe is the clear action)")
371
+ throw new Error("batch delete requires type and/or keyword filter — a layer-wide wipe without filters is refused (personal full wipe is the clear action)")
363
372
  }
364
- if (scope === "project" && !dirs.project) throw new Error("project scope unavailable: no project directory configured")
365
- if (scope === "team" && !dirs.team) throw new Error("team scope not configured: set memory.team in ~/.thincoder/config.json")
366
- const filters = { scope: String(scope), type, keyword }
373
+ if (layer === "project" && !dirs.project) throw new Error("project layer unavailable: no project directory configured")
374
+ if (layer === "team" && !dirs.team) throw new Error("team layer not configured: set memory.team in ~/.thincoder/config.json")
375
+ const filters = { layer: String(layer), type, keyword }
367
376
  const rows = await matchMemoryRows(memory, { ...filters, projectDir: dirs.project, teamDir: dirs.team })
368
377
  if (rows.length === 0) return "0 条匹配"
369
378
  if (args.confirm !== true) {
@@ -374,27 +383,32 @@ async function execDelete(memory, args, dirs) {
374
383
  return lines.join("\n")
375
384
  }
376
385
  const n = await deleteWhere(memory, filters, { dirs })
377
- return `Deleted ${n} entries in scope ${scope}`
386
+ return `Deleted ${n} entries in layer ${layer}`
378
387
  }
379
388
 
380
- /** Single-entry delete — §0.1-era delete semantics (id + scope, NF2/NF3, direct-delete ruling). */
389
+ /** Single-entry delete — MEMORY.md §6.2: layer is OPTIONAL. When passed it is validated
390
+ * against the id prefix (mismatch refused — guards against deleting the wrong entry); when
391
+ * omitted the delete routes by the id prefix alone, so any id search/list returned is
392
+ * directly deletable (deleteByUid already resolves the layer from the uid prefix). */
381
393
  async function execDeleteSingle(memory, args, dirs) {
382
- if (!args.scope) throw new Error("delete requires id + scope")
383
394
  const uid = String(args.id)
384
395
  const prefix = uid.split(":")[0]
385
- const uidScope = prefix === "personal" || prefix === "project" || prefix === "team" ? prefix : /^\d+$/.test(prefix) ? "personal" : null
386
- if (!uidScope) throw new Error(`invalid memory id: ${uid}`)
387
- if (uidScope !== args.scope) throw new Error(`id prefix ${prefix}: 与 scope ${args.scope} 不匹配`)
396
+ const uidLayer = prefix === "personal" || prefix === "project" || prefix === "team" ? prefix : /^\d+$/.test(prefix) ? "personal" : null
397
+ if (!uidLayer) throw new Error(`invalid memory id: ${uid}`)
398
+ const layer = args.layer
399
+ if (layer !== undefined && layer !== null && String(layer) !== uidLayer) {
400
+ throw new Error(`id prefix ${prefix}: 与 layer ${layer} 不匹配`)
401
+ }
388
402
  const entry = await deleteByUid(memory, uid, { dirs })
389
403
  return `Deleted ${entry.id}: ${entry.title}\n${(entry.content ?? "").slice(0, 500)}`
390
404
  }
391
405
 
392
- /** action clear — personal-only full wipe (scope + confirm:true gates; project/team refused). */
406
+ /** action clear — personal-only full wipe (layer + confirm:true gates; project/team refused). */
393
407
  function execClear(memory, args) {
394
- const scope = args.scope
395
- if (!scope) throw new Error('clear requires scope "personal" — pass scope: "personal" plus confirm: true')
396
- if (String(scope) !== "personal") {
397
- if (!MEMORY_SCOPES.includes(String(scope))) throw new Error(`memory clear: invalid scope "${scope}"`)
408
+ const layer = args.layer
409
+ if (!layer) throw new Error('clear requires layer "personal" — pass layer: "personal" plus confirm: true')
410
+ if (String(layer) !== "personal") {
411
+ if (!MEMORY_LAYERS.includes(String(layer))) throw new Error(`memory clear: invalid layer "${layer}"`)
398
412
  throw new Error("shared layers don't support clear — use delete with type/keyword batch filters instead")
399
413
  }
400
414
  if (args.confirm !== true) throw new Error("clear requires confirm:true — this wipes ALL personal memory")
@@ -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([
package/src/memory.mjs CHANGED
@@ -7,7 +7,9 @@
7
7
  export { createMemory, migrate, segmentCJK, VALID_TYPES, SCHEMA_VERSION, CODE_EXTS, DOC_EXTS, SKIP_DIRS, BIG_FILE_LINES } from "./memory/schema.mjs"
8
8
 
9
9
  // CRUD + search + ensureEmbeddings
10
- export { put, search, ftsSearch, fetchEntry, ensureEmbeddings, putMarkdown, syncDir, indexMarkdownFile, list, remove, deleteByUid, matchMemoryRows, deleteWhere, clearPersonal, buildFtsQuery } from "./memory/core.mjs"
10
+ // (delete.mjs owns the disk-truth row match + deletion family see its header)
11
+ export { put, search, ftsSearch, fetchEntry, ensureEmbeddings, putMarkdown, syncDir, indexMarkdownFile, list, clearPersonal, buildFtsQuery } from "./memory/core.mjs"
12
+ export { matchMemoryRows, deleteWhere, deleteByUid, remove } from "./memory/delete.mjs"
11
13
 
12
14
  // code chunking + markdown chunking
13
15
  export { detectLanguage, extractSymbols, extractPySymbols, chunkCode, extractLeadingDoc, yieldTick, _upsertCodeFile, chunkMarkdown, _upsertDocFile } from "./memory/code-index.mjs"
@@ -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
  *