thincoder 0.12.54 → 0.12.59
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 +98 -0
- package/README.md +1 -1
- package/bin/thincoder.mjs +25 -3
- package/package.json +3 -7
- package/src/acp/bridge.mjs +132 -26
- package/src/advisor/messages.mjs +38 -3
- package/src/advisor/run.mjs +91 -53
- package/src/advisor.mjs +15 -7
- package/src/agent/dispatch.mjs +156 -39
- package/src/agent/helpers.mjs +46 -4
- package/src/agent/setup.mjs +102 -19
- package/src/agent/spawn-child.mjs +28 -1
- package/src/agent-tools/advisor.mjs +43 -11
- package/src/agent-tools/consult.mjs +37 -6
- package/src/agent-tools/eng.mjs +4 -1
- package/src/agent-tools/goal.mjs +11 -1
- package/src/agent-tools/read-history.mjs +160 -0
- package/src/agent-tools/settings.mjs +162 -0
- package/src/agent-tools/skill.mjs +2 -1
- package/src/agent-tools/subagent-actions.mjs +432 -0
- package/src/agent-tools/subagent-async.mjs +427 -0
- package/src/agent-tools/subagent-scheduler.mjs +319 -0
- package/src/agent-tools/subagent.mjs +565 -128
- package/src/agent-tools/task.mjs +4 -3
- package/src/agent-tools/timer.mjs +9 -4
- package/src/agent-tools/verify.mjs +161 -49
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +182 -81
- package/src/auto-think.mjs +14 -0
- package/src/cli/make-agent.mjs +27 -1
- package/src/cli/memory-command.mjs +28 -7
- package/src/cli/permission.mjs +8 -1
- package/src/config.mjs +125 -8
- package/src/context.mjs +115 -34
- package/src/distill.mjs +19 -1
- package/src/escape.mjs +82 -27
- package/src/log.mjs +195 -0
- package/src/mcp/transport-http.mjs +13 -1
- package/src/mcp.mjs +52 -7
- package/src/memory/code-sync.mjs +1 -1
- package/src/memory/core.mjs +204 -10
- package/src/memory/docs.mjs +197 -62
- package/src/memory.mjs +1 -1
- package/src/model-specs.mjs +38 -1
- package/src/prompts/advisor-design.md +46 -0
- package/src/prompts/advisor-round1.md +49 -2
- package/src/prompts/advisor-round2.md +47 -0
- package/src/prompts/advisor-round3.md +47 -0
- package/src/prompts/coder.md +22 -0
- package/src/prompts/consult-base.md +13 -0
- package/src/prompts/discipline.md +25 -6
- package/src/prompts/eng-coder.md +2 -2
- package/src/prompts/engineering-sub.md +23 -1
- package/src/prompts/engineering.md +157 -50
- package/src/prompts/explore.md +1 -2
- package/src/prompts/main.md +11 -5
- package/src/prompts/methodology-template.md +14 -0
- package/src/prompts/system.md +5 -2
- package/src/provider/anthropic.mjs +7 -5
- package/src/provider/core.mjs +104 -28
- package/src/provider/google.mjs +57 -24
- package/src/provider/normalize.mjs +1 -1
- package/src/provider/rate.mjs +0 -2
- package/src/provider/responses.mjs +8 -13
- package/src/provider/sse.mjs +20 -0
- package/src/session.mjs +15 -0
- package/src/tools/apply_patch.md +5 -1
- package/src/tools/bash.md +3 -3
- package/src/tools/delete.md +1 -0
- package/src/tools/edit-batch.mjs +92 -0
- package/src/tools/edit-diff.mjs +265 -0
- package/src/tools/edit.md +11 -6
- package/src/tools/execute.md +8 -8
- package/src/tools/execute.mjs +31 -35
- package/src/tools/file.mjs +26 -114
- package/src/tools/file_ops.md +3 -2
- package/src/tools/get_current_time.md +3 -1
- package/src/tools/git.md +1 -1
- package/src/tools/git.mjs +8 -16
- package/src/tools/hashline_edit.md +2 -0
- package/src/tools/index.mjs +3 -2
- package/src/tools/insert_after.md +2 -1
- package/src/tools/lint.md +3 -1
- package/src/tools/linter.mjs +9 -37
- package/src/tools/lsp.md +4 -1
- package/src/tools/patch.mjs +84 -13
- package/src/tools/pdf-parse-text.mjs +497 -0
- package/src/tools/pdf-parse-xref.mjs +499 -0
- package/src/tools/pdf.mjs +155 -0
- package/src/tools/question.md +2 -1
- package/src/tools/read.md +1 -0
- package/src/tools/read_pdf.md +21 -0
- package/src/tools/repomap.mjs +1 -1
- package/src/tools/shared.mjs +11 -32
- package/src/tools/system.mjs +6 -21
- package/src/tools/tree.md +2 -1
- package/src/tools/web.mjs +5 -3
- package/src/tools/websearch.md +2 -1
- package/src/tools/write.md +2 -0
- package/src/traces/trace-store.mjs +224 -0
- package/src/tui/agent-turn.mjs +387 -24
- package/src/tui/clipboard.mjs +17 -6
- package/src/tui/cmd-config.mjs +29 -9
- package/src/tui/cmd-eng.mjs +1 -0
- package/src/tui/cmd-extract.mjs +1 -1
- package/src/tui/cmd-mcp-form.mjs +197 -0
- package/src/tui/cmd-mcp.mjs +264 -114
- package/src/tui/cmd-think.mjs +1 -1
- package/src/tui/index.mjs +49 -95
- package/src/tui/interaction.mjs +41 -3
- package/src/tui/key-handler.mjs +105 -143
- package/src/tui/key-modes.mjs +215 -0
- package/src/tui/layout.mjs +22 -1
- package/src/tui/mouse.mjs +41 -1
- package/src/tui/pickers.mjs +73 -7
- package/src/tui/render-conversation.mjs +13 -161
- package/src/tui/render-frame.mjs +45 -20
- package/src/tui/render-loop.mjs +4 -1
- package/src/tui/render-segments.mjs +165 -0
- package/src/tui/render.mjs +4 -4
- package/src/tui/startup.mjs +40 -2
- package/src/tui/subagent-blocks.mjs +404 -111
- package/src/tui/subagent-panel.mjs +88 -13
- package/src/tui/tool-args.mjs +10 -2
- package/src/tui/tool-events.mjs +172 -95
- package/src/tui/update-notice.mjs +72 -0
- package/src/tui/wizard.mjs +36 -6
- package/src/agent-tools/escalate.mjs +0 -179
- package/src/tools/exec-prelude.mjs +0 -84
package/src/memory/core.mjs
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
import { parseEntry, serializeEntry, entryFilename } from "../markdown.mjs"
|
|
6
6
|
import { embed, cosine, toBlob, fromBlob } from "../embedding.mjs"
|
|
7
|
-
import { readFile, stat, readdir, writeFile, mkdir } from "node:fs/promises"
|
|
8
|
-
import { join } from "node:path"
|
|
7
|
+
import { readFile, stat, readdir, writeFile, mkdir, unlink } from "node:fs/promises"
|
|
8
|
+
import { join, resolve } from "node:path"
|
|
9
9
|
import { segmentCJK, VALID_TYPES, SCHEMA_VERSION } from "./schema.mjs"
|
|
10
10
|
|
|
11
11
|
const EMBED_BATCH_SIZE = 256
|
|
@@ -92,11 +92,11 @@ export function ftsSearch(memory, ftsQuery, limit) {
|
|
|
92
92
|
const originFilter = memory.projectOrigin ? `AND (f.layer = 'team' OR f.origin = ?)` : ""
|
|
93
93
|
const originParams = memory.projectOrigin ? [ftsQuery, memory.projectOrigin, limit] : [ftsQuery, limit]
|
|
94
94
|
const files = memory.db.prepare(`
|
|
95
|
-
SELECT f.layer, f.path, f.type, f.title, f.content, f.tags, f.author, bm25(files_fts) AS rank
|
|
95
|
+
SELECT f.layer, f.origin, f.path, f.type, f.title, f.content, f.tags, f.author, bm25(files_fts) AS rank
|
|
96
96
|
FROM files_fts JOIN files f ON f.rowid = files_fts.rowid
|
|
97
97
|
WHERE files_fts MATCH ? ${originFilter}
|
|
98
98
|
ORDER BY rank LIMIT ?
|
|
99
|
-
`).all(...originParams).map((r) => ({ ...r, id: `${r.layer}:${r.origin
|
|
99
|
+
`).all(...originParams).map((r) => ({ ...r, id: `${r.layer}:${r.origin}:${r.path}` }))
|
|
100
100
|
|
|
101
101
|
return [...personal, ...files].sort((a, b) => a.rank - b.rank).slice(0, limit)
|
|
102
102
|
}
|
|
@@ -110,9 +110,12 @@ export function fetchEntry(memory, uid) {
|
|
|
110
110
|
const r = memory.db.prepare(`SELECT id, type, title, content, tags FROM entries WHERE id = ?`).get(Number(rest[0]))
|
|
111
111
|
return r ? { ...r, layer, id: uid } : null
|
|
112
112
|
}
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
113
|
+
// Files branch: origins may contain colons (Windows drive letters, e.g. project:C:\dir:file.md),
|
|
114
|
+
// so the LAST colon is always the origin/path separator — same parsing as deleteByUid.
|
|
115
|
+
// origin may be empty (compat with the old `project::file.md` format).
|
|
116
|
+
const lastColon = uid.lastIndexOf(":")
|
|
117
|
+
const origin = lastColon > layer.length ? uid.slice(layer.length + 1, lastColon) : ""
|
|
118
|
+
const path = lastColon > layer.length ? uid.slice(lastColon + 1) : uid.slice(layer.length + 1)
|
|
116
119
|
if (layer === "project" && memory.projectOrigin) {
|
|
117
120
|
const r = memory.db.prepare(`SELECT type, title, content, tags, author FROM files WHERE layer = ? AND origin = ? AND path = ?`).get(layer, origin || memory.projectOrigin, path)
|
|
118
121
|
if (r) return { ...r, layer, id: uid }
|
|
@@ -269,10 +272,201 @@ export async function list(memory, { type, limit = DEFAULT_LIST_LIMIT } = {}) {
|
|
|
269
272
|
.all(limit)
|
|
270
273
|
}
|
|
271
274
|
|
|
272
|
-
/**
|
|
275
|
+
/** LIKE pattern from a keyword (wildcards escaped — literal substring match, MEMORY.md §6 keyword filter). */
|
|
276
|
+
function likePattern(keyword) {
|
|
277
|
+
return `%${keyword.replace(/[\\%_]/g, (c) => `\\${c}`)}%`
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Shared row query for the §6 list action and the §6 batch delete (one match surface —
|
|
282
|
+
* rows carry { layer, id, type, title, ts }). Filters:
|
|
283
|
+
* scope: "personal" | "project" | "team" | null (null = all layers)
|
|
284
|
+
* type / keyword: optional (keyword matches title OR content, substring)
|
|
285
|
+
* Personal rows come from the entries table; project/team rows come from a DISK scan of
|
|
286
|
+
* the managed dir (2026-09-05 fix — disk is the truth): files present on disk but
|
|
287
|
+
* missing from the files index (orphans: external copies / gitmem pull / an earlier
|
|
288
|
+
* index failure) were invisible to list AND immune to batch delete — the old table-only
|
|
289
|
+
* match surface made a scope wipe need repeated delete rounds (deleteWhere→syncDir
|
|
290
|
+
* re-indexed the orphans one round later). Scanning disk keeps list and batch delete
|
|
291
|
+
* consistent with what the user can see and delete. Rows from other projects'/team
|
|
292
|
+
* repos' dirs stay out (the scan only covers the dirs this memory context manages).
|
|
293
|
+
* Malformed files are skipped (parseEntry failure — same semantics as syncDir). Sorted
|
|
294
|
+
* by ts (created/updated, ms) DESC.
|
|
295
|
+
*/
|
|
296
|
+
export async function matchMemoryRows(memory, { scope = null, type = null, keyword = null, projectDir = null, teamDir = null } = {}) {
|
|
297
|
+
const rows = []
|
|
298
|
+
const wantLayer = (l) => !scope || scope === l
|
|
299
|
+
if (wantLayer("personal")) {
|
|
300
|
+
let sql = `SELECT id, type, title, created_at AS ts FROM entries`
|
|
301
|
+
const cond = []
|
|
302
|
+
const params = []
|
|
303
|
+
if (type) { cond.push("type = ?"); params.push(type) }
|
|
304
|
+
if (keyword) { cond.push("(title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\')"); const p = likePattern(keyword); params.push(p, p) }
|
|
305
|
+
if (cond.length) sql += " WHERE " + cond.join(" AND ")
|
|
306
|
+
sql += " ORDER BY created_at DESC"
|
|
307
|
+
for (const r of memory.db.prepare(sql).all(...params)) {
|
|
308
|
+
rows.push({ layer: "personal", id: `personal:${r.id}`, uid: `personal:${r.id}`, type: r.type, title: r.title, ts: r.ts })
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (wantLayer("project") && projectDir) {
|
|
312
|
+
for (const r of await diskFileRows(projectDir, type, keyword)) rows.push({ ...r, layer: "project", id: `project:${projectDir}:${r.path}` })
|
|
313
|
+
}
|
|
314
|
+
if (wantLayer("team") && teamDir) {
|
|
315
|
+
for (const r of await diskFileRows(teamDir, type, keyword)) rows.push({ ...r, layer: "team", id: `team:${teamDir}:${r.path}` })
|
|
316
|
+
}
|
|
317
|
+
rows.sort((a, b) => (b.ts ?? 0) - (a.ts ?? 0))
|
|
318
|
+
return rows
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Disk-truth file scan for the project/team layer (2026-09-05 fix — see matchMemoryRows):
|
|
323
|
+
* readdir + parse every .md entry in dir, filter by type equality and keyword substring
|
|
324
|
+
* on title OR content (case-insensitive — SQLite LIKE parity). ts = file mtime (ms).
|
|
325
|
+
* Rows come back WITHOUT the layer field — the caller stamps layer and builds the uid.
|
|
326
|
+
*/
|
|
327
|
+
async function diskFileRows(dir, type, keyword) {
|
|
328
|
+
let names
|
|
329
|
+
try {
|
|
330
|
+
names = (await readdir(dir)).filter((n) => n.endsWith(".md"))
|
|
331
|
+
} catch {
|
|
332
|
+
return []
|
|
333
|
+
}
|
|
334
|
+
const kw = keyword ? keyword.toLowerCase() : null
|
|
335
|
+
const out = []
|
|
336
|
+
for (const name of names) {
|
|
337
|
+
try {
|
|
338
|
+
const abs = join(dir, name)
|
|
339
|
+
const { meta, content } = parseEntry(await readFile(abs, "utf8"))
|
|
340
|
+
if (type && meta.type !== type) continue
|
|
341
|
+
if (kw && !(meta.title.toLowerCase().includes(kw) || content.toLowerCase().includes(kw))) continue
|
|
342
|
+
const mtime = Math.floor((await stat(abs)).mtimeMs)
|
|
343
|
+
out.push({ path: name, type: meta.type, title: meta.title, ts: mtime })
|
|
344
|
+
} catch (e) {
|
|
345
|
+
console.error(`[memory] skip ${name}: ${e.message}`)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
out.sort((a, b) => b.ts - a.ts)
|
|
349
|
+
return out
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* §6 batch delete (action delete + type/keyword filter, confirm handled by the tool layer):
|
|
354
|
+
* deletes every row matchMemoryRows returns for the scope. Personal rows go straight to the
|
|
355
|
+
* DB (FTS + embedding cleanup via row triggers); project/team rows delete the markdown file
|
|
356
|
+
* (path containment enforced, ENOENT tolerated) then re-sync the layer dir once (index
|
|
357
|
+
* cleanup single source). Match surface = disk scan (2026-09-05 fix — orphans on disk
|
|
358
|
+
* with no index row are matched and deleted in the same pass; the trailing syncDir
|
|
359
|
+
* re-indexes the survivors). Team deletion never touches git — a later gitmem pull may
|
|
360
|
+
* resurrect the file while the remote still has it (same semantics as deleteByUid).
|
|
361
|
+
* Returns the number of deleted rows.
|
|
362
|
+
*/
|
|
363
|
+
export async function deleteWhere(memory, { scope, type = null, keyword = null } = {}, { dirs = {} } = {}) {
|
|
364
|
+
const rows = await matchMemoryRows(memory, { scope, type, keyword, projectDir: dirs.project ?? null, teamDir: dirs.team ?? null })
|
|
365
|
+
if (rows.length === 0) return 0
|
|
366
|
+
const personalIds = []
|
|
367
|
+
const byDir = new Map() // "layer\x00dir" → { layer, dir, paths: [] }
|
|
368
|
+
for (const r of rows) {
|
|
369
|
+
if (r.layer === "personal") {
|
|
370
|
+
const id = Number(String(r.uid).split(":")[1])
|
|
371
|
+
if (Number.isInteger(id)) personalIds.push(id)
|
|
372
|
+
continue
|
|
373
|
+
}
|
|
374
|
+
const dir = r.layer === "project" ? dirs.project : dirs.team
|
|
375
|
+
if (!dir) continue
|
|
376
|
+
const key = `${r.layer}\x00${dir}`
|
|
377
|
+
let group = byDir.get(key)
|
|
378
|
+
if (!group) { group = { layer: r.layer, dir, paths: [] }; byDir.set(key, group) }
|
|
379
|
+
group.paths.push(r.path)
|
|
380
|
+
}
|
|
381
|
+
const del = memory.db.prepare(`DELETE FROM entries WHERE id = ?`)
|
|
382
|
+
for (const id of personalIds) del.run(id)
|
|
383
|
+
for (const group of byDir.values()) {
|
|
384
|
+
for (const path of group.paths) {
|
|
385
|
+
assertPathInside(group.dir, path)
|
|
386
|
+
const abs = join(group.dir, path)
|
|
387
|
+
await unlink(abs).catch((e) => { if (e.code !== "ENOENT") throw e })
|
|
388
|
+
}
|
|
389
|
+
await syncDir(memory, { layer: group.layer, dir: group.dir })
|
|
390
|
+
}
|
|
391
|
+
return rows.length
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** §6 clear action: wipe ALL personal entries (pure DB rows — files are project/team only).
|
|
395
|
+
* FTS + embedding go with the row triggers. Returns the number of deleted rows. */
|
|
396
|
+
export function clearPersonal(memory) {
|
|
397
|
+
const { changes } = memory.db.prepare(`DELETE FROM entries`).run()
|
|
398
|
+
return changes
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Delete a memory entry by unified id. Returns the deleted entry (F3: { id, layer, type, title, content, tags }).
|
|
402
|
+
* - personal:<n> (or bare <n>) → DELETE the entries row; FTS syncs via the entries_ad trigger and the
|
|
403
|
+
* embedding BLOB column goes with the row.
|
|
404
|
+
* - project:<origin>:<path> / team:<origin>:<path> → delete the markdown file (path must resolve inside
|
|
405
|
+
* the layer dir — dirs[layer], passed by the caller — `..`/absolute variants (incl. `..\`) are rejected),
|
|
406
|
+
* then syncDir clears the files row (single source of index cleanup). ENOENT on the file is treated as
|
|
407
|
+
* already-deleted and continues. Team deletion never touches git (git propagation is gitmem's job; a
|
|
408
|
+
* later gitmem pull may resurrect the file while the remote still has it).
|
|
409
|
+
* Throws on invalid id / missing entry (NF2) / path escaping the layer dir. */
|
|
410
|
+
export async function deleteByUid(memory, uid, { dirs = {} } = {}) {
|
|
411
|
+
const norm = /^\d+$/.test(uid) ? `personal:${uid}` : String(uid)
|
|
412
|
+
const [layer, ...rest] = norm.split(":")
|
|
413
|
+
if (layer === "personal") {
|
|
414
|
+
const id = rest[0] ?? ""
|
|
415
|
+
if (!/^\d+$/.test(id)) throw new Error(`invalid memory id: ${norm}`)
|
|
416
|
+
const entry = fetchEntry(memory, norm)
|
|
417
|
+
if (!entry) throw new Error(`memory ${norm} not found in scope personal`)
|
|
418
|
+
memory.db.prepare(`DELETE FROM entries WHERE id = ?`).run(Number(id))
|
|
419
|
+
return entry
|
|
420
|
+
}
|
|
421
|
+
if (layer !== "project" && layer !== "team") throw new Error(`invalid memory id: ${norm}`)
|
|
422
|
+
const dir = dirs[layer]
|
|
423
|
+
if (!dir) throw new Error(`${layer} scope unavailable: no ${layer} directory configured`)
|
|
424
|
+
// path = segment after the LAST colon — origins may contain colons (Windows drive letters)
|
|
425
|
+
const lastColon = norm.lastIndexOf(":")
|
|
426
|
+
const path = lastColon > layer.length ? norm.slice(lastColon + 1) : norm.slice(layer.length + 1)
|
|
427
|
+
assertPathInside(dir, path)
|
|
428
|
+
let entry = fetchFileEntry(memory, layer, norm, path)
|
|
429
|
+
const abs = join(dir, path)
|
|
430
|
+
let fileExists = false
|
|
431
|
+
try { await stat(abs); fileExists = true } catch { /* ENOENT — treat as already deleted */ }
|
|
432
|
+
if (!entry && fileExists) {
|
|
433
|
+
try {
|
|
434
|
+
const { meta, content } = parseEntry(await readFile(abs, "utf8"))
|
|
435
|
+
entry = { layer, id: norm, type: meta.type, title: meta.title, content, tags: meta.tags.join(" ") }
|
|
436
|
+
} catch { /* malformed file — keep the DB row (or null → not found below) */ }
|
|
437
|
+
}
|
|
438
|
+
if (!entry) throw new Error(`memory ${norm} not found in scope ${layer}`)
|
|
439
|
+
if (fileExists) await unlink(abs).catch((e) => { if (e.code !== "ENOENT") throw e })
|
|
440
|
+
await syncDir(memory, { layer, dir })
|
|
441
|
+
return entry
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Legacy personal-only delete (bare numeric id) — kept as the compat surface over deleteByUid. */
|
|
273
445
|
export async function remove(memory, id) {
|
|
274
|
-
const
|
|
275
|
-
|
|
446
|
+
const uid = /^\d+$/.test(String(id)) ? `personal:${id}` : String(id)
|
|
447
|
+
if (!fetchEntry(memory, uid)) return false
|
|
448
|
+
await deleteByUid(memory, uid, {})
|
|
449
|
+
return true
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Fetch a project/team file row for deletion: fetchEntry first, then a path-only fallback
|
|
453
|
+
* (origins with Windows drive letters, e.g. project:C:\dir:file.md, break naive ":" splitting). */
|
|
454
|
+
function fetchFileEntry(memory, layer, uid, path) {
|
|
455
|
+
const entry = fetchEntry(memory, uid)
|
|
456
|
+
if (entry) return entry
|
|
457
|
+
const r = memory.db.prepare(`SELECT type, title, content, tags, author FROM files WHERE layer = ? AND path = ?`).get(layer, path)
|
|
458
|
+
return r ? { ...r, layer, id: uid } : null
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Separator-agnostic containment check: the resolved path must stay inside dir.
|
|
462
|
+
* Both / and \ count as separators, so Windows-style traversal (..\..\x) is caught on every platform. */
|
|
463
|
+
function assertPathInside(dir, path) {
|
|
464
|
+
if (!path) throw new Error(`invalid memory id: empty path`)
|
|
465
|
+
const base = resolve(dir).replaceAll("\\", "/")
|
|
466
|
+
const abs = resolve(dir, path.replaceAll("\\", "/")).replaceAll("\\", "/")
|
|
467
|
+
if (abs !== base && !abs.startsWith(base + "/")) {
|
|
468
|
+
throw new Error(`invalid memory path "${path}": must stay within ${dir}`)
|
|
469
|
+
}
|
|
276
470
|
}
|
|
277
471
|
|
|
278
472
|
/**
|
package/src/memory/docs.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { join } from "node:path"
|
|
|
7
7
|
import { embed, cosine, toBlob, fromBlob } from "../embedding.mjs"
|
|
8
8
|
import { commitAndPush } from "../git/gitmem.mjs"
|
|
9
9
|
import { DOC_EXTS, SKIP_DIRS, MAX_DOC_FILE_BYTES } from "./schema.mjs"
|
|
10
|
-
import { buildFtsQuery, put, search, putMarkdown, EMBED_TEXT_MAX_LEN } from "./core.mjs"
|
|
10
|
+
import { buildFtsQuery, put, search, putMarkdown, deleteByUid, matchMemoryRows, deleteWhere, clearPersonal, EMBED_TEXT_MAX_LEN } from "./core.mjs"
|
|
11
11
|
import { _upsertDocFile, yieldTick } from "./code-index.mjs"
|
|
12
12
|
import { markIndexedCommit, listProjectFiles } from "./code-sync.mjs"
|
|
13
13
|
|
|
@@ -164,7 +164,8 @@ export function docSearchTool(memory) {
|
|
|
164
164
|
return {
|
|
165
165
|
name: "doc_search",
|
|
166
166
|
description:
|
|
167
|
-
"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."
|
|
167
|
+
"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.",
|
|
168
169
|
parameters: {
|
|
169
170
|
type: "object",
|
|
170
171
|
properties: {
|
|
@@ -186,83 +187,217 @@ export function docSearchTool(memory) {
|
|
|
186
187
|
|
|
187
188
|
// ---------------------------------------------------------------- agent tools
|
|
188
189
|
|
|
190
|
+
/** §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
|
|
192
|
+
* (VS Code has no team layer and rejects it with CLI guidance). */
|
|
193
|
+
const MEMORY_ACTIONS = ["search", "put", "list", "delete", "clear"]
|
|
194
|
+
const MEMORY_SCOPES = ["personal", "project", "team"]
|
|
195
|
+
const MEMORY_TOOL_DESCRIPTION =
|
|
196
|
+
"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 scope — a 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" +
|
|
202
|
+
"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."
|
|
204
|
+
|
|
205
|
+
function validateTypeFilter(type) {
|
|
206
|
+
if (type === undefined || type === null || type === "") return null
|
|
207
|
+
const t = String(type)
|
|
208
|
+
if (!["rule", "knowledge", "decision", "pattern"].includes(t)) throw new Error(`Invalid memory type "${t}"; expected one of: rule, knowledge, decision, pattern`)
|
|
209
|
+
return t
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function normalizeLimit(limit, dflt) {
|
|
213
|
+
const n = Number(limit)
|
|
214
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : dflt
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function fmtDate(ts) {
|
|
218
|
+
return ts ? new Date(ts).toISOString().slice(0, 10) : "?"
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const listRowLine = (r) => `${r.id} [${r.type}] ${r.title}(${fmtDate(r.ts)})`
|
|
222
|
+
|
|
189
223
|
/**
|
|
190
|
-
* Generate the
|
|
191
|
-
*
|
|
224
|
+
* Generate the memory agent tool — ONE `memory` tool with five actions (MEMORY.md §6 D-M1).
|
|
225
|
+
* search/list are read-only actions (planMode pass / no permission ask — dispatch classifies
|
|
226
|
+
* 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
|
|
228
|
+
* ruling — the confirm parameter IS the gate) and stay non-readonly like the retired tools.
|
|
192
229
|
* opts: { cwd, projectDir, author, team: { dir, name } | null }
|
|
193
230
|
*/
|
|
194
231
|
export function memoryTools(memory, opts = {}) {
|
|
195
232
|
const projectDir = opts.projectDir ? join(opts.cwd ?? process.cwd(), opts.projectDir) : null
|
|
233
|
+
const dirs = { project: projectDir, team: opts.team?.dir ?? null }
|
|
196
234
|
return [
|
|
197
235
|
{
|
|
198
|
-
name: "
|
|
199
|
-
description:
|
|
200
|
-
"Save a piece of knowledge to long-term memory. Use when you learn something worth remembering across sessions: a project convention, a debugging insight, an architecture decision. Types: rule (coding standards), knowledge (project facts), decision (architecture decisions), pattern (debugging/workflow patterns). Scopes: personal (default, private to you), project (shared via this repo's .thincoder/memory/), team (org-wide team repo, if configured).",
|
|
236
|
+
name: "memory",
|
|
237
|
+
description: MEMORY_TOOL_DESCRIPTION,
|
|
201
238
|
parameters: {
|
|
202
239
|
type: "object",
|
|
203
240
|
properties: {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
241
|
+
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" },
|
|
243
|
+
type: { type: "string", enum: ["rule", "knowledge", "decision", "pattern"], description: "Entry type: put = what to save; list/delete batch = filter by type" },
|
|
244
|
+
title: { type: "string", description: "put: short title" },
|
|
245
|
+
content: { type: "string", description: "put: full content to remember" },
|
|
246
|
+
tags: { type: "string", description: "put: space-separated tags" },
|
|
247
|
+
query: { type: "string", description: "search: natural-language query" },
|
|
248
|
+
keyword: { type: "string", description: "list/delete batch: filter matching title/content" },
|
|
249
|
+
id: { type: "string", description: "delete single: the entry id from put/search/list output" },
|
|
250
|
+
limit: { type: "number", description: "Max rows: list 50 by default, search 5 by default" },
|
|
251
|
+
confirm: { type: "boolean", description: "delete batch/clear: must be true — without it the tool refuses" },
|
|
209
252
|
},
|
|
210
|
-
required: ["
|
|
253
|
+
required: ["action"],
|
|
211
254
|
},
|
|
212
255
|
readonly: false,
|
|
213
256
|
async execute(args) {
|
|
214
|
-
const
|
|
215
|
-
if (
|
|
216
|
-
|
|
217
|
-
return `Saved to personal memory (id=${id}): [${args.type}] ${args.title}`
|
|
257
|
+
const action = String(args?.action ?? "")
|
|
258
|
+
if (!MEMORY_ACTIONS.includes(action)) {
|
|
259
|
+
throw new Error(`memory: unknown action "${action}" — expected one of: ${MEMORY_ACTIONS.join("/")}`)
|
|
218
260
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
title: args.title,
|
|
226
|
-
content: args.content,
|
|
227
|
-
tags: (args.tags ?? "").split(/\s+/).filter(Boolean),
|
|
228
|
-
author: opts.author ?? "unknown",
|
|
229
|
-
})
|
|
230
|
-
return `Saved to project memory (${filename}): [${args.type}] ${args.title}`
|
|
261
|
+
switch (action) {
|
|
262
|
+
case "search": return execSearch(memory, args)
|
|
263
|
+
case "put": return execPut(memory, args, opts, dirs)
|
|
264
|
+
case "list": return execList(memory, args, dirs)
|
|
265
|
+
case "delete": return execDelete(memory, args, dirs)
|
|
266
|
+
case "clear": return execClear(memory, args)
|
|
231
267
|
}
|
|
232
|
-
if (!opts.team?.dir) {
|
|
233
|
-
throw new Error("team scope not configured: set memory.team in ~/.thincoder/config.json")
|
|
234
|
-
}
|
|
235
|
-
const filename = await putMarkdown(memory, {
|
|
236
|
-
layer: "team",
|
|
237
|
-
dir: opts.team.dir,
|
|
238
|
-
type: args.type,
|
|
239
|
-
title: args.title,
|
|
240
|
-
content: args.content,
|
|
241
|
-
tags: (args.tags ?? "").split(/\s+/).filter(Boolean),
|
|
242
|
-
author: opts.author ?? "unknown",
|
|
243
|
-
})
|
|
244
|
-
await commitAndPush(opts.team.dir, filename, `memory: [${args.type}] ${args.title}`)
|
|
245
|
-
return `Saved to team memory and pushed (${filename}): [${args.type}] ${args.title}`
|
|
246
|
-
},
|
|
247
|
-
},
|
|
248
|
-
{
|
|
249
|
-
name: "memory_search",
|
|
250
|
-
description:
|
|
251
|
-
"Search long-term memory across all layers (personal/project/team) for relevant knowledge saved in previous sessions. Use the same language as the memories being searched.",
|
|
252
|
-
parameters: {
|
|
253
|
-
type: "object",
|
|
254
|
-
properties: {
|
|
255
|
-
query: { type: "string", description: "Natural language search query" },
|
|
256
|
-
limit: { type: "number", description: "Max results (default 5)" },
|
|
257
|
-
},
|
|
258
|
-
required: ["query"],
|
|
259
|
-
},
|
|
260
|
-
readonly: true,
|
|
261
|
-
async execute(args) {
|
|
262
|
-
const results = await search(memory, args.query, { limit: args.limit ?? 5 })
|
|
263
|
-
if (results.length === 0) return "(no matching memories)"
|
|
264
|
-
return results.map((r) => `[${r.layer}][${r.type}] ${r.title}\n${r.content}`).join("\n\n")
|
|
265
268
|
},
|
|
266
269
|
},
|
|
267
270
|
]
|
|
268
271
|
}
|
|
272
|
+
|
|
273
|
+
/** action search — the retired search tool surface (read-only, same output contract). */
|
|
274
|
+
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}"`)
|
|
278
|
+
}
|
|
279
|
+
const query = String(args.query ?? "").trim()
|
|
280
|
+
if (!query) return "(no matching memories)" // 空 query 短路——两端同语义(评审 code review #4)
|
|
281
|
+
const limit = normalizeLimit(args.limit, 5)
|
|
282
|
+
let results
|
|
283
|
+
if (!scope) {
|
|
284
|
+
results = await search(memory, query, { limit })
|
|
285
|
+
} else {
|
|
286
|
+
// scope filter: oversample then slice the requested layer (results keep global rank order).
|
|
287
|
+
// 窗口 = max(limit*4, 20) 是召回上限——大库 + 高 limit 时该层结果可能不足 limit(接受的取舍——评审 code review #3)
|
|
288
|
+
const wide = await search(memory, query, { limit: Math.max(limit * 4, 20) })
|
|
289
|
+
results = wide.filter((r) => r.layer === String(scope)).slice(0, limit)
|
|
290
|
+
}
|
|
291
|
+
if (results.length === 0) return "(no matching memories)"
|
|
292
|
+
return results.map((r) => `[${r.layer}][${r.type}] ${r.title} (id=${r.id})\n${r.content}`).join("\n\n")
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** action put — the retired put tool surface (side-effect gate, unchanged semantics). */
|
|
296
|
+
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") {
|
|
300
|
+
const id = await put(memory, { type: args.type, title: args.title, content: args.content, tags: args.tags ?? "" })
|
|
301
|
+
return `Saved to personal memory (id=personal:${id}): [${args.type}] ${args.title}`
|
|
302
|
+
}
|
|
303
|
+
if (scope === "project") {
|
|
304
|
+
if (!dirs.project) throw new Error("project scope unavailable: no project directory configured")
|
|
305
|
+
const filename = await putMarkdown(memory, {
|
|
306
|
+
layer: "project",
|
|
307
|
+
dir: dirs.project,
|
|
308
|
+
type: args.type,
|
|
309
|
+
title: args.title,
|
|
310
|
+
content: args.content,
|
|
311
|
+
tags: (args.tags ?? "").split(/\s+/).filter(Boolean),
|
|
312
|
+
author: opts.author ?? "unknown",
|
|
313
|
+
})
|
|
314
|
+
return `Saved to project memory (id=project:${dirs.project}:${filename}): [${args.type}] ${args.title}`
|
|
315
|
+
}
|
|
316
|
+
if (!dirs.team) {
|
|
317
|
+
throw new Error("team scope not configured: set memory.team in ~/.thincoder/config.json")
|
|
318
|
+
}
|
|
319
|
+
const filename = await putMarkdown(memory, {
|
|
320
|
+
layer: "team",
|
|
321
|
+
dir: dirs.team,
|
|
322
|
+
type: args.type,
|
|
323
|
+
title: args.title,
|
|
324
|
+
content: args.content,
|
|
325
|
+
tags: (args.tags ?? "").split(/\s+/).filter(Boolean),
|
|
326
|
+
author: opts.author ?? "unknown",
|
|
327
|
+
})
|
|
328
|
+
await commitAndPush(dirs.team, filename, `memory: [${args.type}] ${args.title}`)
|
|
329
|
+
return `Saved to team memory and pushed (id=team:${dirs.team}:${filename}): [${args.type}] ${args.title}`
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** action list — new inventory action (read-only): scope/type/keyword filters + limit truncation note. */
|
|
333
|
+
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}"`)
|
|
336
|
+
const rows = await matchMemoryRows(memory, {
|
|
337
|
+
scope: scope ? String(scope) : null,
|
|
338
|
+
type: validateTypeFilter(args.type),
|
|
339
|
+
keyword: args.keyword ? String(args.keyword).trim() : null,
|
|
340
|
+
projectDir: dirs.project,
|
|
341
|
+
teamDir: dirs.team,
|
|
342
|
+
})
|
|
343
|
+
if (rows.length === 0) return "0 条匹配"
|
|
344
|
+
const limit = normalizeLimit(args.limit, 50)
|
|
345
|
+
const shown = rows.slice(0, limit)
|
|
346
|
+
const lines = shown.map(listRowLine)
|
|
347
|
+
if (rows.length > shown.length) lines.unshift(`${shown.length} 条——截断前 ${rows.length}`)
|
|
348
|
+
return lines.join("\n")
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** action delete — single ({ id, scope } — §0.1-era single-delete semantics) + batch (scope + type/keyword + confirm). */
|
|
352
|
+
async function execDelete(memory, args, dirs) {
|
|
353
|
+
const hasId = args.id !== undefined && args.id !== null && String(args.id) !== ""
|
|
354
|
+
if (hasId) return execDeleteSingle(memory, args, dirs)
|
|
355
|
+
// 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}"`)
|
|
359
|
+
const type = validateTypeFilter(args.type)
|
|
360
|
+
const keyword = args.keyword ? String(args.keyword).trim() : null
|
|
361
|
+
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)")
|
|
363
|
+
}
|
|
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 }
|
|
367
|
+
const rows = await matchMemoryRows(memory, { ...filters, projectDir: dirs.project, teamDir: dirs.team })
|
|
368
|
+
if (rows.length === 0) return "0 条匹配"
|
|
369
|
+
if (args.confirm !== true) {
|
|
370
|
+
const lines = [rows.length > 5 ? `将删 ${rows.length} 条:前 5 条预览` : `将删 ${rows.length} 条`]
|
|
371
|
+
lines.push(...rows.slice(0, 5).map(listRowLine))
|
|
372
|
+
if (rows.length > 5) lines.push(`5 条——截断前 ${rows.length}`)
|
|
373
|
+
lines.push("confirm:true required — re-send with it to execute the deletion")
|
|
374
|
+
return lines.join("\n")
|
|
375
|
+
}
|
|
376
|
+
const n = await deleteWhere(memory, filters, { dirs })
|
|
377
|
+
return `Deleted ${n} entries in scope ${scope}`
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Single-entry delete — §0.1-era delete semantics (id + scope, NF2/NF3, direct-delete ruling). */
|
|
381
|
+
async function execDeleteSingle(memory, args, dirs) {
|
|
382
|
+
if (!args.scope) throw new Error("delete requires id + scope")
|
|
383
|
+
const uid = String(args.id)
|
|
384
|
+
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} 不匹配`)
|
|
388
|
+
const entry = await deleteByUid(memory, uid, { dirs })
|
|
389
|
+
return `Deleted ${entry.id}: ${entry.title}\n${(entry.content ?? "").slice(0, 500)}`
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** action clear — personal-only full wipe (scope + confirm:true gates; project/team refused). */
|
|
393
|
+
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}"`)
|
|
398
|
+
throw new Error("shared layers don't support clear — use delete with type/keyword batch filters instead")
|
|
399
|
+
}
|
|
400
|
+
if (args.confirm !== true) throw new Error("clear requires confirm:true — this wipes ALL personal memory")
|
|
401
|
+
const n = clearPersonal(memory)
|
|
402
|
+
return `Cleared personal memory (${n} entries deleted)`
|
|
403
|
+
}
|
package/src/memory.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
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, buildFtsQuery } from "./memory/core.mjs"
|
|
10
|
+
export { put, search, ftsSearch, fetchEntry, ensureEmbeddings, putMarkdown, syncDir, indexMarkdownFile, list, remove, deleteByUid, matchMemoryRows, deleteWhere, clearPersonal, buildFtsQuery } from "./memory/core.mjs"
|
|
11
11
|
|
|
12
12
|
// code chunking + markdown chunking
|
|
13
13
|
export { detectLanguage, extractSymbols, extractPySymbols, chunkCode, extractLeadingDoc, yieldTick, _upsertCodeFile, chunkMarkdown, _upsertDocFile } from "./memory/code-index.mjs"
|
package/src/model-specs.mjs
CHANGED
|
@@ -88,7 +88,13 @@ const MODEL_SPECS = [
|
|
|
88
88
|
]
|
|
89
89
|
const DEFAULT_SPEC = { context: 128_000, maxOutput: 32_000, cacheMode: "none" }
|
|
90
90
|
|
|
91
|
-
/** Look up spec by model name prefix (case-insensitive), conservative default for unknown models
|
|
91
|
+
/** Look up spec by model name prefix (case-insensitive), conservative default for unknown models.
|
|
92
|
+
*
|
|
93
|
+
* Vendor-namespace prefix stripping (2026-09-04):第三方 token 市场(roapi/new-api/one-api/
|
|
94
|
+
* aiproxy 聚合网关)惯例在模型名前加厂商前缀(zhipu/glm-5.3、openai/gpt-4o)。完整名未命中
|
|
95
|
+
* 且含 "/" 时,剥掉第一个 "/" 前的 namespace 再按前缀匹配一次——ZHIPU/GLM-5.3 → glm-5.3 命中
|
|
96
|
+
* 真实规格,不再降级 128K 默认。kimi/kimi-k3 的显式 alias 行保留为文档锚(发送路径
|
|
97
|
+
* provider.core isRouter 依赖含 "/" 判定),通用机制已覆盖同类。 */
|
|
92
98
|
const warnedModels = new Set() // warn once per model name — specForModel is a hot path (every request)
|
|
93
99
|
// Pre-sorted once at module scope — specForModel runs on every request (agent, provider core,
|
|
94
100
|
// context, auto-think, TUI rendering); re-sorting per call was wasteful.
|
|
@@ -98,6 +104,14 @@ export function specForModel(model) {
|
|
|
98
104
|
for (const [prefix, spec] of SORTED_SPECS) {
|
|
99
105
|
if (m.startsWith(prefix.toLowerCase())) return spec
|
|
100
106
|
}
|
|
107
|
+
// Vendor-namespace strip: vendor/model — retry the prefix match on the bare model part.
|
|
108
|
+
const slash = m.indexOf("/")
|
|
109
|
+
if (slash > 0) {
|
|
110
|
+
const bare = m.slice(slash + 1)
|
|
111
|
+
for (const [prefix, spec] of SORTED_SPECS) {
|
|
112
|
+
if (bare.startsWith(prefix.toLowerCase())) return spec
|
|
113
|
+
}
|
|
114
|
+
}
|
|
101
115
|
// Unknown model: warn ONCE (not per request) so a typo'd ID or a missing alias surfaces
|
|
102
116
|
// instead of silently degrading to the 128K default (IK5VGJ).
|
|
103
117
|
if (m && !warnedModels.has(m)) {
|
|
@@ -106,3 +120,26 @@ export function specForModel(model) {
|
|
|
106
120
|
}
|
|
107
121
|
return DEFAULT_SPEC
|
|
108
122
|
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* providerSpec(provider) — spec with a provider-level context override (PROVIDER.md §15, 2026-09-02).
|
|
126
|
+
*
|
|
127
|
+
* providers[].context is configured in K units (128 = 128K = 131072 tokens) and overrides the
|
|
128
|
+
* MODEL_SPECS value for THIS provider only — the same model can have different real context
|
|
129
|
+
* windows on different endpoints (official vs local deployment). The ×1024 conversion happens
|
|
130
|
+
* HERE and nowhere else.
|
|
131
|
+
*
|
|
132
|
+
* Returns a COPY ({ ...spec, context }) — the shared spec object from the SORTED_SPECS lookup
|
|
133
|
+
* must never be mutated, or the override would leak across providers (T-C1).
|
|
134
|
+
*
|
|
135
|
+
* Validation is defensive (pure function): absent/invalid context falls back to the plain spec
|
|
136
|
+
* (config.mjs loadConfig already warns + strips invalid values; this guard covers direct callers
|
|
137
|
+
* and keeps the function total). specForModel stays a pure table lookup — callers without a
|
|
138
|
+
* provider keep using it.
|
|
139
|
+
*/
|
|
140
|
+
export function providerSpec(provider) {
|
|
141
|
+
const spec = specForModel(provider?.model ?? "")
|
|
142
|
+
const k = Number(provider?.context) // Number() 接受数字字符串("128")——两端语义统一(code review #1)
|
|
143
|
+
if (Number.isInteger(k) && k > 0) return { ...spec, context: k * 1024 }
|
|
144
|
+
return spec
|
|
145
|
+
}
|