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.
- package/CHANGELOG.md +62 -4
- package/README.md +10 -8
- package/bin/thincoder.mjs +99 -133
- package/package.json +6 -4
- package/src/abort-provenance.mjs +116 -0
- package/src/acp/bridge.mjs +45 -21
- package/src/acp.mjs +6 -1
- package/src/advisor/citations.mjs +83 -21
- package/src/advisor/compaction.mjs +174 -0
- package/src/advisor/loop.mjs +293 -0
- package/src/advisor/messages.mjs +59 -137
- package/src/advisor/project-context.mjs +194 -0
- package/src/advisor/repos.mjs +17 -40
- package/src/advisor/run.mjs +156 -359
- package/src/advisor/truncate.mjs +57 -0
- package/src/advisor.mjs +27 -7
- package/src/agent/completion.mjs +17 -11
- package/src/agent/dispatch.mjs +145 -27
- package/src/agent/helpers.mjs +107 -13
- package/src/agent/record-results.mjs +55 -11
- package/src/agent/relay-prefix.mjs +39 -0
- package/src/agent/run-stages.mjs +242 -0
- package/src/agent/setup-reminders.mjs +69 -0
- package/src/agent/setup.mjs +107 -127
- package/src/agent/spawn-child.mjs +55 -13
- package/src/agent-tools/advisor-async.mjs +346 -0
- package/src/agent-tools/advisor-settle.mjs +231 -0
- package/src/agent-tools/advisor.mjs +167 -116
- package/src/agent-tools/async-settle.mjs +191 -0
- package/src/agent-tools/batch-segment.mjs +195 -0
- package/src/agent-tools/consult.mjs +139 -107
- package/src/agent-tools/design-token.mjs +117 -0
- package/src/agent-tools/digest-budget.mjs +76 -0
- package/src/agent-tools/eng.mjs +24 -29
- package/src/agent-tools/escalate-async.mjs +289 -0
- package/src/agent-tools/read-history.mjs +166 -32
- package/src/agent-tools/recent-changes.mjs +2 -1
- package/src/agent-tools/review-streak.mjs +93 -0
- package/src/agent-tools/settings.mjs +137 -34
- package/src/agent-tools/subagent-actions.mjs +180 -133
- package/src/agent-tools/subagent-async.mjs +184 -177
- package/src/agent-tools/subagent-panel.mjs +160 -0
- package/src/agent-tools/subagent-run.mjs +205 -0
- package/src/agent-tools/subagent-scheduler.mjs +100 -27
- package/src/agent-tools/subagent-spawn.mjs +453 -0
- package/src/agent-tools/subagent.mjs +256 -578
- package/src/agent-tools/verify.mjs +119 -292
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +89 -205
- package/src/cli/distill-command.mjs +12 -6
- package/src/cli/make-agent.mjs +26 -8
- package/src/cli/memory-command.mjs +4 -3
- package/src/cli/permission.mjs +2 -2
- package/src/cli/setup-wizard.mjs +42 -17
- package/src/completions.mjs +114 -0
- package/src/config-migrate.mjs +70 -0
- package/src/config.mjs +180 -63
- package/src/context.mjs +5 -147
- package/src/conventions.mjs +223 -0
- package/src/crash-reports.mjs +128 -0
- package/src/distill.mjs +11 -11
- package/src/expand-home.mjs +16 -0
- package/src/explore-distill.mjs +155 -0
- package/src/generate-title.mjs +1 -1
- package/src/hooks.mjs +7 -3
- package/src/memory/code-index.mjs +9 -3
- package/src/memory/code-sync.mjs +72 -32
- package/src/memory/core.mjs +6 -193
- package/src/memory/delete.mjs +236 -0
- package/src/memory/docs.mjs +68 -54
- package/src/memory/file-walk.mjs +109 -0
- package/src/memory/schema.mjs +15 -3
- package/src/memory.mjs +3 -1
- package/src/model-ref.mjs +66 -0
- package/src/model-specs.mjs +42 -8
- package/src/peer-domains.mjs +265 -0
- package/src/peer-instances.mjs +231 -0
- package/src/prompt-overlays.mjs +82 -0
- package/src/prompts/advisor-design.md +18 -75
- package/src/prompts/advisor-round1.md +14 -67
- package/src/prompts/advisor-round2.md +15 -51
- package/src/prompts/advisor-round3.md +15 -51
- package/src/prompts/common.md +115 -0
- package/src/prompts/consult-base.md +5 -23
- package/src/prompts/discipline-engineering.md +217 -0
- package/src/prompts/discipline-normal.md +179 -0
- package/src/prompts/persona-coder.md +21 -0
- package/src/prompts/persona-eng-coder.md +37 -0
- package/src/prompts/persona-eng-designer.md +55 -0
- package/src/prompts/persona-engineering.md +54 -0
- package/src/prompts/persona-explore.md +15 -0
- package/src/prompts/persona-normal.md +27 -0
- package/src/prompts/persona-plan.md +26 -0
- package/src/provider/anthropic.mjs +4 -4
- package/src/provider/core.mjs +18 -98
- package/src/provider/errors.mjs +101 -0
- package/src/provider/google.mjs +5 -6
- package/src/provider/index.mjs +2 -1
- package/src/provider/list-models.mjs +93 -0
- package/src/provider/rate.mjs +2 -1
- package/src/provider/responses.mjs +5 -3
- package/src/provider/retry.mjs +8 -45
- package/src/provider/sse.mjs +3 -4
- package/src/proxy.mjs +9 -14
- package/src/session-gc.mjs +214 -0
- package/src/session-guard.mjs +47 -0
- package/src/session-rename.mjs +38 -0
- package/src/session-slots.mjs +188 -60
- package/src/session.mjs +104 -124
- package/src/token-ttl.mjs +274 -0
- package/src/tools/{system.mjs → bash.mjs} +19 -221
- package/src/tools/checklist-sync.mjs +181 -0
- package/src/tools/checklist.mjs +52 -39
- package/src/tools/edit-batch.mjs +109 -10
- package/src/tools/edit-diff.mjs +110 -27
- package/src/tools/edit.md +17 -12
- package/src/tools/execute.mjs +31 -4
- package/src/tools/file.mjs +41 -16
- package/src/tools/git.md +1 -1
- package/src/tools/git.mjs +23 -34
- package/src/tools/glob-dialect.mjs +130 -0
- package/src/tools/glob.md +3 -3
- package/src/tools/grep.md +1 -1
- package/src/tools/index.mjs +9 -8
- package/src/tools/ops.mjs +188 -3
- package/src/tools/patch.mjs +3 -3
- package/src/tools/question.md +4 -0
- package/src/tools/question.mjs +26 -0
- package/src/tools/read.md +1 -2
- package/src/tools/read_image.md +1 -1
- package/src/tools/search.mjs +236 -0
- package/src/tools/shared.mjs +14 -13
- package/src/tools/wait_for.md +22 -0
- package/src/tui/agent-turn.mjs +36 -228
- package/src/tui/ansi.mjs +2 -0
- package/src/tui/clipboard.mjs +7 -1
- package/src/tui/cmd-advisor.mjs +3 -2
- package/src/tui/cmd-config.mjs +142 -30
- package/src/tui/cmd-eng.mjs +28 -40
- package/src/tui/cmd-exit.mjs +6 -8
- package/src/tui/cmd-mcp.mjs +8 -2
- package/src/tui/cmd-model.mjs +14 -12
- package/src/tui/cmd-new.mjs +3 -2
- package/src/tui/cmd-reindex.mjs +7 -0
- package/src/tui/cmd-session.mjs +19 -4
- package/src/tui/cmd-submodel.mjs +8 -5
- package/src/tui/cmd-think.mjs +10 -10
- package/src/tui/cmd-undo.mjs +4 -3
- package/src/tui/cmd-upgrade.mjs +19 -4
- package/src/tui/config-helpers.mjs +28 -16
- package/src/tui/distill-cmd.mjs +1 -1
- package/src/tui/index.mjs +40 -38
- package/src/tui/interaction.mjs +3 -3
- package/src/tui/key-handler.mjs +61 -17
- package/src/tui/key-modes.mjs +86 -8
- package/src/tui/layout.mjs +18 -10
- package/src/tui/model-catalog.mjs +89 -0
- package/src/tui/model-picker.mjs +498 -0
- package/src/tui/mouse.mjs +52 -9
- package/src/tui/pickers.mjs +28 -392
- package/src/tui/render-frame.mjs +32 -16
- package/src/tui/render-loop.mjs +2 -0
- package/src/tui/render-segments.mjs +12 -9
- package/src/tui/render.mjs +37 -5
- package/src/tui/slash-commands.mjs +2 -2
- package/src/tui/startup.mjs +4 -0
- package/src/tui/subagent-blocks.mjs +106 -295
- package/src/tui/subagent-children.mjs +162 -0
- package/src/tui/subagent-freeze.mjs +169 -0
- package/src/tui/subagent-panel.mjs +24 -31
- package/src/tui/suspension-drive.mjs +297 -0
- package/src/tui/tool-args.mjs +7 -5
- package/src/tui/tool-display.mjs +143 -0
- package/src/tui/tool-events.mjs +56 -185
- package/src/tui/tui-lifecycle.mjs +46 -4
- package/src/tui/update-notice.mjs +4 -0
- package/src/tui/wizard.mjs +61 -21
- package/src/tui/wrapped-spawn.mjs +38 -0
- package/src/prompts/coder.md +0 -56
- package/src/prompts/discipline.md +0 -102
- package/src/prompts/eng-coder.md +0 -44
- package/src/prompts/engineering-sub.md +0 -34
- package/src/prompts/engineering.md +0 -340
- package/src/prompts/explore.md +0 -21
- package/src/prompts/main.md +0 -56
- package/src/prompts/methodology-template.md +0 -58
- package/src/prompts/plan.md +0 -16
- package/src/prompts/system.md +0 -63
- package/src/tools/pdf-parse-text.mjs +0 -497
- package/src/tools/pdf-parse-xref.mjs +0 -499
- package/src/tools/pdf.mjs +0 -155
- package/src/tools/read_pdf.md +0 -21
package/src/memory/docs.mjs
CHANGED
|
@@ -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 {
|
|
10
|
-
import { buildFtsQuery, put, search, putMarkdown,
|
|
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,
|
|
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
|
-
|
|
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);
|
|
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
|
|
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
|
|
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
|
|
199
|
-
"- list — inventory what memory holds
|
|
200
|
-
"- delete — SINGLE: {id,
|
|
201
|
-
"- clear — {
|
|
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 layer — layer 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) =>
|
|
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 +
|
|
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
|
-
|
|
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
|
|
276
|
-
if (
|
|
277
|
-
throw new Error(`memory search: invalid
|
|
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 (!
|
|
291
|
+
if (!layer) {
|
|
284
292
|
results = await search(memory, query, { limit })
|
|
285
293
|
} else {
|
|
286
|
-
//
|
|
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(
|
|
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
|
|
298
|
-
if (!
|
|
299
|
-
if (
|
|
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 (
|
|
304
|
-
if (!dirs.project) throw new Error("project
|
|
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
|
|
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):
|
|
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
|
|
335
|
-
if (
|
|
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
|
-
|
|
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,
|
|
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
|
|
357
|
-
if (!
|
|
358
|
-
if (!
|
|
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
|
|
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 (
|
|
365
|
-
if (
|
|
366
|
-
const filters = {
|
|
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
|
|
386
|
+
return `Deleted ${n} entries in layer ${layer}`
|
|
378
387
|
}
|
|
379
388
|
|
|
380
|
-
/** Single-entry delete — §
|
|
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
|
|
386
|
-
if (!
|
|
387
|
-
|
|
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 (
|
|
406
|
+
/** action clear — personal-only full wipe (layer + confirm:true gates; project/team refused). */
|
|
393
407
|
function execClear(memory, args) {
|
|
394
|
-
const
|
|
395
|
-
if (!
|
|
396
|
-
if (String(
|
|
397
|
-
if (!
|
|
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
|
+
}
|
package/src/memory/schema.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/model-specs.mjs
CHANGED
|
@@ -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
|
-
|
|
31
|
-
|
|
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 —
|
|
99
|
-
// Pre-sorted once at module scope —
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
*
|