thincoder 0.12.60 → 0.12.61
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -1
- package/README.md +8 -6
- package/bin/thincoder.mjs +27 -122
- package/package.json +3 -2
- package/src/abort-provenance.mjs +116 -0
- package/src/acp/bridge.mjs +38 -17
- 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 +36 -134
- package/src/advisor/project-context.mjs +194 -0
- package/src/advisor/repos.mjs +17 -40
- package/src/advisor/run.mjs +124 -329
- package/src/advisor/truncate.mjs +57 -0
- package/src/advisor.mjs +3 -2
- package/src/agent/completion.mjs +1 -1
- package/src/agent/dispatch.mjs +47 -12
- package/src/agent/helpers.mjs +71 -13
- package/src/agent/record-results.mjs +13 -5
- package/src/agent/relay-prefix.mjs +39 -0
- package/src/agent/run-stages.mjs +21 -6
- package/src/agent/setup-reminders.mjs +16 -9
- package/src/agent/setup.mjs +92 -128
- package/src/agent/spawn-child.mjs +26 -9
- package/src/agent-tools/advisor-async.mjs +70 -180
- package/src/agent-tools/advisor-settle.mjs +231 -0
- package/src/agent-tools/advisor.mjs +69 -20
- package/src/agent-tools/batch-segment.mjs +195 -0
- package/src/agent-tools/consult.mjs +23 -10
- package/src/agent-tools/design-token.mjs +14 -1
- package/src/agent-tools/digest-budget.mjs +76 -0
- package/src/agent-tools/eng.mjs +3 -3
- package/src/agent-tools/escalate-async.mjs +16 -13
- package/src/agent-tools/read-history.mjs +13 -3
- package/src/agent-tools/review-streak.mjs +93 -0
- package/src/agent-tools/settings.mjs +130 -17
- package/src/agent-tools/subagent-actions.mjs +15 -6
- package/src/agent-tools/subagent-async.mjs +66 -14
- package/src/agent-tools/subagent-panel.mjs +22 -15
- package/src/agent-tools/subagent-run.mjs +9 -6
- package/src/agent-tools/subagent-scheduler.mjs +57 -8
- package/src/agent-tools/subagent-spawn.mjs +63 -16
- package/src/agent-tools/subagent.mjs +175 -49
- package/src/agent-tools/verify.mjs +13 -34
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +38 -21
- package/src/cli/distill-command.mjs +2 -2
- package/src/cli/make-agent.mjs +23 -7
- package/src/cli/memory-command.mjs +2 -2
- package/src/cli/setup-wizard.mjs +29 -9
- package/src/completions.mjs +114 -0
- package/src/config-migrate.mjs +70 -0
- package/src/config.mjs +132 -63
- package/src/conventions.mjs +223 -0
- package/src/crash-reports.mjs +7 -2
- package/src/expand-home.mjs +16 -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 +70 -31
- package/src/memory/delete.mjs +2 -0
- package/src/memory/docs.mjs +10 -6
- package/src/memory/file-walk.mjs +109 -0
- package/src/memory/schema.mjs +15 -3
- package/src/model-ref.mjs +66 -0
- package/src/model-specs.mjs +42 -8
- package/src/prompt-overlays.mjs +73 -16
- package/src/prompts/advisor-design.md +18 -8
- package/src/prompts/advisor-round1.md +7 -1
- package/src/prompts/advisor-round2.md +13 -2
- package/src/prompts/advisor-round3.md +13 -2
- package/src/prompts/common.md +115 -0
- package/src/prompts/consult-base.md +2 -0
- 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 +13 -32
- package/src/provider/errors.mjs +26 -1
- 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/sse.mjs +3 -4
- package/src/proxy.mjs +9 -14
- package/src/session-slots.mjs +7 -2
- package/src/session.mjs +59 -38
- package/src/token-ttl.mjs +2 -1
- package/src/tools/{system.mjs → bash.mjs} +6 -243
- package/src/tools/file.mjs +30 -10
- package/src/tools/git.md +1 -1
- package/src/tools/git.mjs +15 -34
- package/src/tools/index.mjs +4 -2
- package/src/tools/ops.mjs +20 -7
- package/src/tools/question.md +1 -0
- package/src/tools/question.mjs +26 -0
- package/src/tools/read.md +1 -1
- package/src/tools/read_image.md +1 -1
- package/src/tools/search.mjs +236 -0
- package/src/tui/agent-turn.mjs +32 -13
- 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 +108 -37
- package/src/tui/cmd-eng.mjs +11 -27
- package/src/tui/cmd-exit.mjs +6 -8
- package/src/tui/cmd-model.mjs +14 -12
- package/src/tui/cmd-reindex.mjs +7 -0
- package/src/tui/cmd-submodel.mjs +8 -5
- package/src/tui/cmd-undo.mjs +4 -3
- package/src/tui/index.mjs +38 -37
- 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 +47 -10
- package/src/tui/pickers.mjs +28 -410
- package/src/tui/render-frame.mjs +32 -16
- package/src/tui/render-loop.mjs +2 -0
- package/src/tui/render-segments.mjs +5 -19
- package/src/tui/render.mjs +37 -5
- package/src/tui/slash-commands.mjs +2 -2
- package/src/tui/subagent-blocks.mjs +69 -90
- package/src/tui/subagent-children.mjs +50 -64
- package/src/tui/subagent-freeze.mjs +40 -43
- package/src/tui/subagent-panel.mjs +21 -66
- package/src/tui/suspension-drive.mjs +28 -82
- package/src/tui/tool-args.mjs +4 -2
- package/src/tui/tool-display.mjs +4 -3
- package/src/tui/tool-events.mjs +23 -16
- package/src/tui/tui-lifecycle.mjs +18 -5
- package/src/tui/wizard.mjs +52 -18
- package/src/tui/wrapped-spawn.mjs +38 -0
- package/src/prompts/coder.md +0 -13
- package/src/prompts/discipline.md +0 -84
- package/src/prompts/eng-coder.md +0 -19
- package/src/prompts/engineering-sub.md +0 -14
- package/src/prompts/engineering.md +0 -87
- package/src/prompts/explore.md +0 -12
- package/src/prompts/main.md +0 -34
- package/src/prompts/methodology-template.md +0 -38
- package/src/prompts/plan.md +0 -9
- package/src/prompts/system.md +0 -44
package/src/memory/code-sync.mjs
CHANGED
|
@@ -4,13 +4,30 @@
|
|
|
4
4
|
import { readFile, stat } from "node:fs/promises"
|
|
5
5
|
import { join, relative } from "node:path"
|
|
6
6
|
import { embed, cosine, toBlob, fromBlob } from "../embedding.mjs"
|
|
7
|
-
import { CODE_EXTS, DOC_EXTS,
|
|
7
|
+
import { CODE_EXTS, DOC_EXTS, MAX_CODE_FILE_BYTES, MAX_DOC_FILE_BYTES } from "./schema.mjs"
|
|
8
8
|
import { buildFtsQuery, ensureEmbeddings, EMBED_TEXT_MAX_LEN } from "./core.mjs"
|
|
9
9
|
import { detectLanguage, _upsertCodeFile, _upsertDocFile, yieldTick } from "./code-index.mjs"
|
|
10
|
+
import { walkProjectFiles, isSkippedRelPath, extensionOf, createUnlistedTally, MAX_WALK_FILES } from "./file-walk.mjs"
|
|
11
|
+
import { loadConventions } from "../conventions.mjs"
|
|
12
|
+
import { logEvent } from "../log.mjs"
|
|
10
13
|
|
|
11
14
|
const DIFF_FULL_SYNC_THRESHOLD = 200
|
|
12
15
|
const CODE_EMBED_BATCH = 64
|
|
13
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Index extension sets = built-in tables ∪ project declaration
|
|
19
|
+
* (`.thincoder/conventions.json` → index.codeExtensions / index.docExtensions;
|
|
20
|
+
* PORTABILITY PO-9/§3.7 — a project may declare extensions this product does not
|
|
21
|
+
* ship a default for). Declaration only ADDS (union), never removes.
|
|
22
|
+
*/
|
|
23
|
+
export function indexExtensions(dir) {
|
|
24
|
+
const conv = loadConventions(dir)
|
|
25
|
+
return {
|
|
26
|
+
code: new Set([...CODE_EXTS, ...conv.index.codeExtensions]),
|
|
27
|
+
doc: new Set([...DOC_EXTS, ...conv.index.docExtensions]),
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
14
31
|
/**
|
|
15
32
|
* git-driven incremental indexing: use git diff to find files changed since
|
|
16
33
|
* the last index, and only rebuild FTS5 chunks for those files (vectors are untouched).
|
|
@@ -19,6 +36,7 @@ const CODE_EMBED_BATCH = 64
|
|
|
19
36
|
*/
|
|
20
37
|
export async function gitSync(memory, dir, { onProgress } = {}) {
|
|
21
38
|
const { execFile: _execFile } = await import("node:child_process")
|
|
39
|
+
const { code: codeExts, doc: docExts } = indexExtensions(dir)
|
|
22
40
|
const gitRun = (args) => new Promise((resolve, reject) => {
|
|
23
41
|
_execFile("git", args, { cwd: dir, encoding: "utf8", timeout: 10000, windowsHide: true }, (err, stdout) => {
|
|
24
42
|
if (err) reject(err); else resolve(stdout)
|
|
@@ -55,17 +73,16 @@ export async function gitSync(memory, dir, { onProgress } = {}) {
|
|
|
55
73
|
for (let i = 0; i < diffOut.length; i++) {
|
|
56
74
|
const rel = diffOut[i].replaceAll("\\", "/")
|
|
57
75
|
const abs = join(dir, rel)
|
|
58
|
-
const ext =
|
|
76
|
+
const ext = extensionOf(rel)
|
|
59
77
|
|
|
60
|
-
|
|
61
|
-
if (pathDirs.some((d) => SKIP_DIRS.has(d) || d.startsWith("."))) continue
|
|
78
|
+
if (isSkippedRelPath(rel)) continue
|
|
62
79
|
|
|
63
|
-
if (!
|
|
80
|
+
if (!codeExts.has(ext) && !docExts.has(ext)) { skipped++; continue }
|
|
64
81
|
|
|
65
82
|
try {
|
|
66
83
|
const text = await readFile(abs, "utf8")
|
|
67
84
|
const lines = text.split("\n")
|
|
68
|
-
if (
|
|
85
|
+
if (codeExts.has(ext)) {
|
|
69
86
|
const lang = detectLanguage(abs)
|
|
70
87
|
let mtimeMs = 0
|
|
71
88
|
try { mtimeMs = Math.floor((await stat(abs)).mtimeMs) } catch { /* new file */ }
|
|
@@ -79,7 +96,7 @@ export async function gitSync(memory, dir, { onProgress } = {}) {
|
|
|
79
96
|
} catch (e) {
|
|
80
97
|
const isDeleted = e.code === "ENOENT"
|
|
81
98
|
if (isDeleted) {
|
|
82
|
-
if (
|
|
99
|
+
if (codeExts.has(ext)) memory.db.prepare(`DELETE FROM code_chunks WHERE origin = ? AND path = ?`).run(dir, rel)
|
|
83
100
|
else memory.db.prepare(`DELETE FROM doc_chunks WHERE origin = ? AND path = ?`).run(dir, rel)
|
|
84
101
|
removed++
|
|
85
102
|
} else {
|
|
@@ -104,16 +121,27 @@ export async function gitSync(memory, dir, { onProgress } = {}) {
|
|
|
104
121
|
|
|
105
122
|
/**
|
|
106
123
|
* List project files matching the given extensions.
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* (
|
|
110
|
-
*
|
|
124
|
+
* Preferred source = git (`git ls-files --cached --others --exclude-standard`:
|
|
125
|
+
* tracked + untracked-not-ignored, .gitignore respected). Projects WITHOUT git
|
|
126
|
+
* fall back to a filesystem walk (PORTABILITY FR15/P8) — before, the index was
|
|
127
|
+
* silently empty there. The walk cannot honour .gitignore (no git → no such
|
|
128
|
+
* concept) and skips the shared SKIP_DIRS/dot-directory set instead.
|
|
129
|
+
* Returns { entries, unlisted }: `entries` = { abs, rel } pairs (rel relative to
|
|
130
|
+
* dir); `unlisted` = files whose extension is in NO index list (count + sample),
|
|
131
|
+
* the visible signal behind "my .xyz files are not searchable" (PORTABILITY PO-9).
|
|
132
|
+
* `truncated` = the non-git walk hit its file cap (capped trees are logged, never
|
|
133
|
+
* silently indexed-partial). opts.maxFiles = the walk cap (test seam).
|
|
111
134
|
*/
|
|
112
|
-
export async function listProjectFiles(dir, exts) {
|
|
135
|
+
export async function listProjectFiles(dir, exts, { maxFiles } = {}) {
|
|
113
136
|
const { execFile: _execFile } = await import("node:child_process")
|
|
114
137
|
const { join: joinPath } = await import("node:path")
|
|
138
|
+
const { code, doc } = indexExtensions(dir)
|
|
139
|
+
const tally = createUnlistedTally(new Set([...code, ...doc]))
|
|
115
140
|
|
|
116
|
-
|
|
141
|
+
const files = []
|
|
142
|
+
const finish = (truncated = false) => ({ entries: files, unlisted: tally.result(), truncated })
|
|
143
|
+
|
|
144
|
+
// Git listing when available; a non-git project falls through to the walk.
|
|
117
145
|
let gitTop
|
|
118
146
|
try {
|
|
119
147
|
gitTop = (await new Promise((resolve, reject) => {
|
|
@@ -121,10 +149,17 @@ export async function listProjectFiles(dir, exts) {
|
|
|
121
149
|
(err, stdout) => { if (err) reject(err); else resolve(stdout.trim()) })
|
|
122
150
|
})).replace(/\\/g, "/")
|
|
123
151
|
} catch {
|
|
124
|
-
|
|
152
|
+
// Not a git repo → filesystem walk (FR15: the index must still be usable).
|
|
153
|
+
const walked = await walkProjectFiles(dir, exts, { knownExts: new Set([...code, ...doc]), ...(maxFiles !== undefined ? { maxFiles } : {}) })
|
|
154
|
+
files.push(...walked.files)
|
|
155
|
+
// The walk's cap must stay visible end-to-end (file-walk.mjs: "never silently
|
|
156
|
+
// dropped") — a capped listing says so in the log, not only in a discarded flag.
|
|
157
|
+
if (walked.truncated) {
|
|
158
|
+
logEvent("index:truncated", { dir, files: walked.files.length, maxFiles: maxFiles ?? MAX_WALK_FILES })
|
|
159
|
+
}
|
|
160
|
+
return { entries: files, unlisted: walked.unlisted, truncated: walked.truncated }
|
|
125
161
|
}
|
|
126
162
|
|
|
127
|
-
const files = []
|
|
128
163
|
try {
|
|
129
164
|
const raw = await new Promise((resolve, reject) => {
|
|
130
165
|
_execFile("git", ["ls-files", "--cached", "--others", "--exclude-standard"],
|
|
@@ -134,16 +169,15 @@ export async function listProjectFiles(dir, exts) {
|
|
|
134
169
|
for (const line of raw.trim().split("\n")) {
|
|
135
170
|
const p = line.trim()
|
|
136
171
|
if (!p) continue
|
|
137
|
-
const ext = p.slice(p.lastIndexOf(".")).toLowerCase()
|
|
138
|
-
if (!exts.has(ext)) continue
|
|
139
|
-
const abs = joinPath(dir, p)
|
|
140
172
|
const rel = p.replace(/\\/g, "/")
|
|
141
|
-
if (rel
|
|
142
|
-
|
|
173
|
+
if (isSkippedRelPath(rel)) continue
|
|
174
|
+
const ext = extensionOf(rel)
|
|
175
|
+
if (!ext || !exts.has(ext)) { tally.note(rel); continue }
|
|
176
|
+
files.push({ abs: joinPath(dir, rel), rel })
|
|
143
177
|
}
|
|
144
178
|
} catch { /* ls-files failed */ }
|
|
145
179
|
|
|
146
|
-
return
|
|
180
|
+
return finish()
|
|
147
181
|
}
|
|
148
182
|
|
|
149
183
|
|
|
@@ -152,7 +186,8 @@ export async function listProjectFiles(dir, exts) {
|
|
|
152
186
|
* Incremental by mtime — only rebuilds chunks for files that have changed.
|
|
153
187
|
*/
|
|
154
188
|
export async function codeSync(memory, dir, { onProgress } = {}) {
|
|
155
|
-
const
|
|
189
|
+
const { code: exts } = indexExtensions(dir)
|
|
190
|
+
const { entries, unlisted } = await listProjectFiles(dir, exts)
|
|
156
191
|
const files = [] // { abs, rel, mtimeMs }
|
|
157
192
|
let overSizeSkipped = 0
|
|
158
193
|
for (const { abs, rel } of entries) {
|
|
@@ -206,7 +241,10 @@ export async function codeSync(memory, dir, { onProgress } = {}) {
|
|
|
206
241
|
|
|
207
242
|
onProgress?.({ phase: "done", total: files.length, updated, removed, skipped, failed, overSizeSkipped })
|
|
208
243
|
markIndexedCommit(memory, dir)
|
|
209
|
-
|
|
244
|
+
if (unlisted.count > 0) {
|
|
245
|
+
logEvent("index:unlisted", { dir, kind: "code", count: unlisted.count, exts: unlisted.exts.map((e) => e.ext) })
|
|
246
|
+
}
|
|
247
|
+
return { updated, removed, skipped, failed, errors, total: files.length, overSizeSkipped, unlistedExts: unlisted }
|
|
210
248
|
}
|
|
211
249
|
|
|
212
250
|
/** Record current HEAD as the index anchor (gitSync incremental diff baseline); silently skip non-git repos */
|
|
@@ -338,32 +376,33 @@ export function codeSearchTool(memory) {
|
|
|
338
376
|
* Single-file incremental reindex: called after write/edit/delete, only rebuilds this one path.
|
|
339
377
|
*/
|
|
340
378
|
export async function reindexFile(memory, cwd, absPath) {
|
|
341
|
-
const ext =
|
|
379
|
+
const ext = extensionOf(absPath)
|
|
342
380
|
const rel = relative(cwd, absPath).replaceAll("\\", "/")
|
|
343
381
|
if (rel === ".." || rel.startsWith("../")) return
|
|
344
|
-
|
|
345
|
-
|
|
382
|
+
if (isSkippedRelPath(rel)) return
|
|
383
|
+
// Declared extensions count for the single-file path too (union with built-ins).
|
|
384
|
+
const { code: codeExts, doc: docExts } = indexExtensions(cwd)
|
|
346
385
|
|
|
347
386
|
// skip oversized files (minified bundles, test fixtures, generated code)
|
|
348
|
-
const maxBytes =
|
|
387
|
+
const maxBytes = codeExts.has(ext) ? MAX_CODE_FILE_BYTES : docExts.has(ext) ? MAX_DOC_FILE_BYTES : 0
|
|
349
388
|
if (maxBytes > 0) {
|
|
350
389
|
try { const st = await stat(absPath); if (st.size > maxBytes) return } catch { /* can't stat, proceed */ }
|
|
351
390
|
}
|
|
352
391
|
|
|
353
392
|
let text
|
|
354
393
|
try { text = await readFile(absPath, "utf8") } catch {
|
|
355
|
-
if (
|
|
356
|
-
else if (
|
|
394
|
+
if (codeExts.has(ext)) memory.db.prepare(`DELETE FROM code_chunks WHERE origin = ? AND path = ?`).run(cwd, rel)
|
|
395
|
+
else if (docExts.has(ext)) memory.db.prepare(`DELETE FROM doc_chunks WHERE origin = ? AND path = ?`).run(cwd, rel)
|
|
357
396
|
return
|
|
358
397
|
}
|
|
359
398
|
const lines = text.split("\n")
|
|
360
399
|
|
|
361
|
-
if (
|
|
400
|
+
if (codeExts.has(ext)) {
|
|
362
401
|
const lang = detectLanguage(absPath)
|
|
363
402
|
let mtimeMs = 0
|
|
364
403
|
try { mtimeMs = Math.floor((await stat(absPath)).mtimeMs) } catch { /* new file */ }
|
|
365
404
|
_upsertCodeFile(memory, cwd, rel, lines, lang, mtimeMs)
|
|
366
|
-
} else if (
|
|
405
|
+
} else if (docExts.has(ext)) {
|
|
367
406
|
let mtimeMs = 0
|
|
368
407
|
try { mtimeMs = Math.floor((await stat(absPath)).mtimeMs) } catch { /* new file */ }
|
|
369
408
|
_upsertDocFile(memory, cwd, rel, lines, mtimeMs)
|
package/src/memory/delete.mjs
CHANGED
|
@@ -155,6 +155,8 @@ export async function deleteByUid(memory, uid, { dirs = {} } = {}) {
|
|
|
155
155
|
const norm = /^\d+$/.test(uid) ? `personal:${uid}` : String(uid)
|
|
156
156
|
const [layer, ...rest] = norm.split(":")
|
|
157
157
|
if (layer === "personal") {
|
|
158
|
+
// 畸形尾缀拒绝(2026-09-08 代码正确性批 2.2):personal:5:extra 不得静默删 id=5
|
|
159
|
+
if (norm.split(":").length > 2) throw new Error(`invalid memory id: ${norm}`)
|
|
158
160
|
const id = rest[0] ?? ""
|
|
159
161
|
if (!/^\d+$/.test(id)) throw new Error(`invalid memory id: ${norm}`)
|
|
160
162
|
const entry = fetchEntry(memory, norm)
|
package/src/memory/docs.mjs
CHANGED
|
@@ -3,14 +3,15 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { readFile, stat } from "node:fs/promises"
|
|
6
|
-
import { join } from "node:path"
|
|
6
|
+
import { isAbsolute, join } from "node:path"
|
|
7
7
|
import { embed, cosine, toBlob, fromBlob } from "../embedding.mjs"
|
|
8
8
|
import { commitAndPush } from "../git/gitmem.mjs"
|
|
9
|
-
import {
|
|
9
|
+
import { MAX_DOC_FILE_BYTES } from "./schema.mjs"
|
|
10
10
|
import { buildFtsQuery, put, search, putMarkdown, clearPersonal, EMBED_TEXT_MAX_LEN } from "./core.mjs"
|
|
11
11
|
import { deleteByUid, matchMemoryRows, deleteWhere } from "./delete.mjs"
|
|
12
12
|
import { _upsertDocFile, yieldTick } from "./code-index.mjs"
|
|
13
|
-
import { markIndexedCommit, listProjectFiles } from "./code-sync.mjs"
|
|
13
|
+
import { markIndexedCommit, listProjectFiles, indexExtensions } from "./code-sync.mjs"
|
|
14
|
+
import { logEvent } from "../log.mjs"
|
|
14
15
|
|
|
15
16
|
const DOC_EMBED_BATCH = 64
|
|
16
17
|
|
|
@@ -19,7 +20,7 @@ const DOC_EMBED_BATCH = 64
|
|
|
19
20
|
* Incremental by mtime.
|
|
20
21
|
*/
|
|
21
22
|
export async function docSync(memory, dir, { onProgress } = {}) {
|
|
22
|
-
const entries = await listProjectFiles(dir,
|
|
23
|
+
const { entries, unlisted } = await listProjectFiles(dir, indexExtensions(dir).doc)
|
|
23
24
|
const files = [] // { abs, rel, mtimeMs }
|
|
24
25
|
let overSizeSkipped = 0
|
|
25
26
|
for (const { abs, rel } of entries) {
|
|
@@ -72,7 +73,10 @@ export async function docSync(memory, dir, { onProgress } = {}) {
|
|
|
72
73
|
|
|
73
74
|
onProgress?.({ phase: "done", total: files.length, updated, removed, skipped, failed, overSizeSkipped })
|
|
74
75
|
markIndexedCommit(memory, dir)
|
|
75
|
-
|
|
76
|
+
if (unlisted.count > 0) {
|
|
77
|
+
logEvent("index:unlisted", { dir, kind: "doc", count: unlisted.count, exts: unlisted.exts.map((e) => e.ext) })
|
|
78
|
+
}
|
|
79
|
+
return { updated, removed, skipped, failed, errors, total: files.length, overSizeSkipped, unlistedExts: unlisted }
|
|
76
80
|
}
|
|
77
81
|
|
|
78
82
|
/**
|
|
@@ -233,7 +237,7 @@ const listRowLine = (r) => `[${r.layer}] ${r.id} [${r.type}] ${r.title}(${fmtD
|
|
|
233
237
|
* opts: { cwd, projectDir, author, team: { dir, name } | null }
|
|
234
238
|
*/
|
|
235
239
|
export function memoryTools(memory, opts = {}) {
|
|
236
|
-
const projectDir = opts.projectDir ? join(opts.cwd ?? process.cwd(), opts.projectDir) : null
|
|
240
|
+
const projectDir = opts.projectDir ? (isAbsolute(opts.projectDir) ? opts.projectDir : join(opts.cwd ?? process.cwd(), opts.projectDir)) : null
|
|
237
241
|
const dirs = { project: projectDir, team: opts.team?.dir ?? null }
|
|
238
242
|
return [
|
|
239
243
|
{
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory/file-walk.mjs — filesystem walk fallback + the shared file-listing predicates.
|
|
3
|
+
*
|
|
4
|
+
* Why this module (PORTABILITY FR15 / P8): the index was built from `git ls-files`
|
|
5
|
+
* only — on a project that is not a git repository the code and doc indexes came
|
|
6
|
+
* back EMPTY, silently (the reviewer/agent then "searched" an empty index). The
|
|
7
|
+
* fallback traverses the filesystem instead, and the skip predicate lives here so
|
|
8
|
+
* the git path, the walk and the single-file reindex cannot drift apart against
|
|
9
|
+
* each other (they were three copies before).
|
|
10
|
+
*/
|
|
11
|
+
import { readdir } from "node:fs/promises"
|
|
12
|
+
import { join } from "node:path"
|
|
13
|
+
import { SKIP_DIRS } from "./schema.mjs"
|
|
14
|
+
|
|
15
|
+
/** Upper guard for the walk: stop after this many matched files (runaway trees). */
|
|
16
|
+
export const MAX_WALK_FILES = 20000
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Shared skip predicate for project-relative paths: any SKIP_DIRS basename, or any
|
|
20
|
+
* dot-prefixed segment (`.git`, `.cache`, `.thincoder`…). Accepts both separators.
|
|
21
|
+
*/
|
|
22
|
+
export function isSkippedRelPath(rel) {
|
|
23
|
+
return String(rel ?? "")
|
|
24
|
+
.replace(/\\/g, "/")
|
|
25
|
+
.split("/")
|
|
26
|
+
.some((seg) => seg !== "" && (seg.startsWith(".") || SKIP_DIRS.has(seg)))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Lower-cased ".ext" of a path, or "" when it has no extension. */
|
|
30
|
+
export function extensionOf(p) {
|
|
31
|
+
const s = String(p ?? "")
|
|
32
|
+
const base = s.slice(Math.max(s.lastIndexOf("/"), s.lastIndexOf("\\")) + 1)
|
|
33
|
+
const i = base.lastIndexOf(".")
|
|
34
|
+
return i > 0 ? base.slice(i).toLowerCase() : ""
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Unlisted-extension tally (PORTABILITY PO-9 — visibility): counts files the index
|
|
39
|
+
* will NOT pick up because their extension is in no list, so "my .xyz files are not
|
|
40
|
+
* searchable" stops being invisible. `exts` is a capped sample (the count is the
|
|
41
|
+
* signal; the list is the hint).
|
|
42
|
+
* @param {Set<string>} knownExts — every extension that IS indexed (code ∪ doc ∪ declared)
|
|
43
|
+
*/
|
|
44
|
+
export function createUnlistedTally(knownExts) {
|
|
45
|
+
const counts = new Map()
|
|
46
|
+
let count = 0
|
|
47
|
+
return {
|
|
48
|
+
note(rel) {
|
|
49
|
+
const ext = extensionOf(rel)
|
|
50
|
+
if (!ext || knownExts.has(ext)) return
|
|
51
|
+
count++
|
|
52
|
+
counts.set(ext, (counts.get(ext) ?? 0) + 1)
|
|
53
|
+
},
|
|
54
|
+
result(limit = 12) {
|
|
55
|
+
const exts = [...counts.entries()]
|
|
56
|
+
.sort((a, b) => b[1] - a[1])
|
|
57
|
+
.slice(0, limit)
|
|
58
|
+
.map(([ext, n]) => ({ ext, count: n }))
|
|
59
|
+
return { count, exts }
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Recursive project walk — the no-git listing source. Symlinks are never followed
|
|
66
|
+
* (loop/escape guard); skipped directories are pruned; only files whose extension
|
|
67
|
+
* is in `exts` are returned. Hitting maxFiles is reported as `truncated`, never
|
|
68
|
+
* silently dropped.
|
|
69
|
+
* @param {string} dir — project root
|
|
70
|
+
* @param {Set<string>} exts — extensions to keep (lower-case, leading dot)
|
|
71
|
+
* @param {{maxFiles?: number, knownExts?: Set<string>|null}} [opts]
|
|
72
|
+
* knownExts → also tally files whose extension is in NO index list
|
|
73
|
+
* @returns {Promise<{files: {abs: string, rel: string}[], truncated: boolean,
|
|
74
|
+
* unlisted: {count: number, exts: {ext: string, count: number}[]}}>}
|
|
75
|
+
*/
|
|
76
|
+
export async function walkProjectFiles(dir, exts, { maxFiles = MAX_WALK_FILES, knownExts = null } = {}) {
|
|
77
|
+
const files = []
|
|
78
|
+
const tally = knownExts ? createUnlistedTally(knownExts) : null
|
|
79
|
+
let truncated = false
|
|
80
|
+
const stack = [{ abs: dir, rel: "" }]
|
|
81
|
+
while (stack.length > 0) {
|
|
82
|
+
const cur = stack.pop()
|
|
83
|
+
let entries
|
|
84
|
+
try {
|
|
85
|
+
entries = await readdir(cur.abs, { withFileTypes: true })
|
|
86
|
+
} catch { continue /* unreadable dir — skip, the walk must not fail the sync */ }
|
|
87
|
+
for (const ent of entries) {
|
|
88
|
+
const rel = cur.rel ? `${cur.rel}/${ent.name}` : ent.name
|
|
89
|
+
if (ent.isSymbolicLink()) continue // never follow symlinks
|
|
90
|
+
if (ent.isDirectory()) {
|
|
91
|
+
if (!isSkippedRelPath(rel)) stack.push({ abs: join(cur.abs, ent.name), rel })
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
if (!ent.isFile() || isSkippedRelPath(rel)) continue
|
|
95
|
+
const ext = extensionOf(ent.name)
|
|
96
|
+
if (!ext || !exts.has(ext)) {
|
|
97
|
+
tally?.note(rel)
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
if (files.length >= maxFiles) {
|
|
101
|
+
truncated = true
|
|
102
|
+
break
|
|
103
|
+
}
|
|
104
|
+
files.push({ abs: join(cur.abs, ent.name), rel })
|
|
105
|
+
}
|
|
106
|
+
if (truncated) break
|
|
107
|
+
}
|
|
108
|
+
return { files, truncated, unlisted: tally ? tally.result() : { count: 0, exts: [] } }
|
|
109
|
+
}
|
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([
|
|
@@ -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
|
*
|