thincoder 0.12.60 → 0.12.62
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 +49 -2
- package/README.md +8 -6
- package/bin/thincoder.mjs +38 -124
- package/package.json +3 -2
- package/src/abort-provenance.mjs +116 -0
- package/src/acp/bridge.mjs +38 -17
- package/src/acp.mjs +19 -6
- 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 +24 -7
- package/src/agent/setup-reminders.mjs +16 -9
- package/src/agent/setup.mjs +92 -128
- package/src/agent/spawn-child.mjs +43 -11
- 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/async-settle.mjs +13 -0
- package/src/agent-tools/batch-segment.mjs +195 -0
- package/src/agent-tools/consult.mjs +28 -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 +22 -13
- package/src/agent-tools/read-history.mjs +31 -6
- package/src/agent-tools/review-streak.mjs +93 -0
- package/src/agent-tools/settings.mjs +130 -17
- package/src/agent-tools/subagent-actions.mjs +18 -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 +10 -7
- package/src/agent-tools/subagent-scheduler.mjs +57 -8
- package/src/agent-tools/subagent-spawn.mjs +69 -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 +42 -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/context.mjs +12 -1
- package/src/conventions.mjs +223 -0
- package/src/crash-reports.mjs +35 -8
- package/src/expand-home.mjs +16 -0
- package/src/generate-title.mjs +9 -4
- package/src/heap-watch.mjs +88 -0
- package/src/hooks.mjs +7 -3
- package/src/ledger.mjs +227 -0
- package/src/memory/code-index.mjs +9 -3
- package/src/memory/code-sync.mjs +77 -36
- package/src/memory/core.mjs +8 -9
- package/src/memory/delete.mjs +2 -0
- package/src/memory/docs.mjs +17 -11
- package/src/memory/file-walk.mjs +109 -0
- package/src/memory/scan.mjs +95 -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 +19 -9
- package/src/prompts/advisor-round1.md +8 -2
- package/src/prompts/advisor-round2.md +14 -3
- package/src/prompts/advisor-round3.md +14 -3
- package/src/prompts/common.md +115 -0
- package/src/prompts/consult-base.md +2 -0
- package/src/prompts/discipline-engineering.md +258 -0
- package/src/prompts/discipline-normal.md +185 -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 +60 -0
- package/src/prompts/persona-engineering.md +55 -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-gc.mjs +9 -2
- package/src/session-guard.mjs +12 -0
- package/src/session-segments.mjs +100 -0
- package/src/session-slots.mjs +10 -2
- package/src/session-store.mjs +441 -0
- package/src/session.mjs +137 -99
- package/src/text-budget.mjs +46 -0
- 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/traces/trace-store.mjs +195 -64
- 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-clear.mjs +2 -0
- 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-new.mjs +5 -1
- package/src/tui/cmd-reindex.mjs +7 -0
- package/src/tui/cmd-session.mjs +8 -4
- package/src/tui/cmd-submodel.mjs +8 -5
- package/src/tui/cmd-undo.mjs +4 -3
- package/src/tui/display-budget.mjs +184 -0
- package/src/tui/index.mjs +69 -40
- package/src/tui/key-handler-search.mjs +9 -1
- 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/ledger-surface.mjs +69 -0
- 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 +38 -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/startup.mjs +45 -13
- package/src/tui/subagent-blocks.mjs +70 -90
- package/src/tui/subagent-children.mjs +125 -67
- package/src/tui/subagent-freeze.mjs +48 -45
- package/src/tui/subagent-panel.mjs +21 -66
- package/src/tui/suspension-drive.mjs +31 -83
- package/src/tui/tool-args.mjs +9 -4
- package/src/tui/tool-display.mjs +20 -5
- package/src/tui/tool-events.mjs +76 -30
- package/src/tui/tui-lifecycle.mjs +27 -7
- package/src/tui/wizard.mjs +52 -18
- package/src/tui/wrapped-spawn.mjs +54 -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
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* expand-home.mjs — 配置路径字段家目录展开器(第 29 批 HOME-EXPANSION,2026-09-11)。
|
|
3
|
+
* `loadConfig()` 单一规范化点(设计 MEMORY.md §9.3a):纯函数、零依赖、home 可注入。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { homedir } from "node:os"
|
|
7
|
+
import { join } from "node:path"
|
|
8
|
+
|
|
9
|
+
/** 展开配置路径字段的前缀 `~`(`~` / `~/` / `~\`)为主目录绝对路径;不识别形态原样返回。
|
|
10
|
+
* home 第二参 = 测试注入缝(生产缺省 homedir())。 */
|
|
11
|
+
export function expandHome(p, home = homedir()) {
|
|
12
|
+
if (typeof p !== "string" || !p.startsWith("~")) return p // 非字符串 / 非 ~ 前缀 → 原样
|
|
13
|
+
if (p === "~") return home // 裸 ~ = 主目录
|
|
14
|
+
if (p[1] !== "/" && p[1] !== "\\") return p // ~user 等非分隔符 → 原样(不猜用户)
|
|
15
|
+
return join(home, p.slice(2).replaceAll("\\", "/")) // 余段分隔符归一(跨端统一)
|
|
16
|
+
}
|
package/src/generate-title.mjs
CHANGED
|
@@ -46,7 +46,7 @@ export async function generateTitle(userContent, provider) {
|
|
|
46
46
|
const url = `${provider.baseURL.replace(/\/+$/, "")}${chatPath}`
|
|
47
47
|
const opts = {
|
|
48
48
|
method: "POST",
|
|
49
|
-
headers: { "Content-Type": "application/json", Authorization: `Bearer ${provider.apiKey}` },
|
|
49
|
+
headers: { ...(provider.headers ?? {}), "Content-Type": "application/json", Authorization: `Bearer ${provider.apiKey}` }, // 定制头展开(PROVIDER.md §21)——定制头在前、内置头在后:内置头胜出
|
|
50
50
|
body,
|
|
51
51
|
signal: AbortSignal.timeout(10000),
|
|
52
52
|
}
|
|
@@ -69,9 +69,14 @@ export async function generateTitle(userContent, provider) {
|
|
|
69
69
|
export async function ensureSessionTitle(agent) {
|
|
70
70
|
if (agent.title) return agent.title
|
|
71
71
|
try {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
)
|
|
72
|
+
// TUI-OOM-ROOTCAUSE 批(SESSION.md §14.3.3):绑定态首条 user 消息在记录存储(段 1)——
|
|
73
|
+
// 内存窗口可能已滑过它(store.firstUserMessage 首扫一次并缓存);未绑定回退内存查找。
|
|
74
|
+
let firstUser = agent._recordStore?.firstUserMessage?.() ?? null
|
|
75
|
+
if (!firstUser) {
|
|
76
|
+
firstUser = (agent._fullHistory ?? agent.history).find(
|
|
77
|
+
(m) => m.role === "user" && typeof m.content === "string" && !m.content.startsWith("[System reminder:"),
|
|
78
|
+
)
|
|
79
|
+
}
|
|
75
80
|
if (firstUser) {
|
|
76
81
|
const title = await generateTitle(firstUser.content, agent.provider)
|
|
77
82
|
if (title) agent.title = title
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* heap-watch.mjs — 堆遥测/看门狗(TUI-OOM-ROOTCAUSE 批——CRASH-REPORTS.md §8)。
|
|
3
|
+
*
|
|
4
|
+
* 事故会话堆爬升 ~19 分钟无任何事前信号(「炸了才知道」);本模块补「事前预警」:
|
|
5
|
+
* 60s 采样 + 堆上限比例双档(70% / 85%)边缘触发 → stderr 行 + TUI 行(订阅制)+
|
|
6
|
+
* 事件日志。**只看不治**(不强制 GC、不阻断、不触发快照——D-HW2)。
|
|
7
|
+
*
|
|
8
|
+
* 关口(§8.3):阈值 = `heapUsed / v8.getHeapStatistics().heap_size_limit`(比例——可移植,
|
|
9
|
+
* 无绝对 MB 常量);双档边缘触发(每档一次/进程——不重复刷屏);零常态开销
|
|
10
|
+
* (60s 一次 memoryUsage(),无输出——D-HW5)。
|
|
11
|
+
*
|
|
12
|
+
* 开关单点:`THINCODER_HEAP_WATCH`——关值集合 {`0`,`false`,`off`,`no`}(trim + 大小写
|
|
13
|
+
* 不敏感)→ 不启动;未设/空串/其他值 → 启动(默认开——与 F3② 同约定)。
|
|
14
|
+
*
|
|
15
|
+
* 注入缝(N5 可测):`sample` / `heapLimit` / `timer`——测试以假实现 + `checkNow()`
|
|
16
|
+
* 直驱(零等待);失败面全吞(采样抛错不阻断——样本失败静默跳过本次)。
|
|
17
|
+
*/
|
|
18
|
+
import { getHeapStatistics } from "node:v8"
|
|
19
|
+
import { logEvent } from "./log.mjs"
|
|
20
|
+
|
|
21
|
+
/** 关值集合(单点判定——§3.2 同约定复用)。 */
|
|
22
|
+
export const HEAP_WATCH_OFF = new Set(["0", "false", "off", "no"])
|
|
23
|
+
|
|
24
|
+
/** 开关判定(单点——grep 面:仅本模块读 env.THINCODER_HEAP_WATCH)。 */
|
|
25
|
+
export function heapWatchEnabled(env = process.env) {
|
|
26
|
+
const v = String(env.THINCODER_HEAP_WATCH ?? "").trim().toLowerCase()
|
|
27
|
+
return !HEAP_WATCH_OFF.has(v)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 订阅表(TUI 行接线——§8.3 订阅制;模块级:武装在 bin、订阅在 startTUI)
|
|
31
|
+
const _subscribers = new Set()
|
|
32
|
+
|
|
33
|
+
/** 订阅预警行(返回退订函数)。 */
|
|
34
|
+
export function onHeapWarn(cb) {
|
|
35
|
+
if (typeof cb !== "function") return () => {}
|
|
36
|
+
_subscribers.add(cb)
|
|
37
|
+
return () => _subscribers.delete(cb)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 预警行格式化(逐字——§8.3;U/L 一位小数、P 取整)。 */
|
|
41
|
+
export function heapWarnLine(used, limit, ratio) {
|
|
42
|
+
const gb = (n) => (n / 1024 / 1024 / 1024).toFixed(1)
|
|
43
|
+
return `[heap] warning: heapUsed ${gb(used)} GB / ${gb(limit)} GB heap limit (${Math.round(ratio * 100)}%) — long session; consider /new to reset context`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 启动看门狗(返回 `{ stop(), checkNow() }`——checkNow 直驱一次采样并返回本轮新发预警行)。
|
|
48
|
+
* 关值(§8.3)→ 不注册定时器(返回惰性句柄)。
|
|
49
|
+
*/
|
|
50
|
+
export function startHeapWatch({
|
|
51
|
+
intervalMs = 60_000,
|
|
52
|
+
ratios = [0.7, 0.85],
|
|
53
|
+
sample = process.memoryUsage,
|
|
54
|
+
heapLimit = () => getHeapStatistics().heap_size_limit,
|
|
55
|
+
env = process.env,
|
|
56
|
+
timer = setInterval,
|
|
57
|
+
} = {}) {
|
|
58
|
+
const warned = new Set()
|
|
59
|
+
const checkNow = () => {
|
|
60
|
+
const out = []
|
|
61
|
+
try {
|
|
62
|
+
const used = sample()?.heapUsed ?? 0
|
|
63
|
+
const limit = heapLimit()
|
|
64
|
+
if (!(limit > 0)) return out
|
|
65
|
+
const ratio = used / limit
|
|
66
|
+
for (const r of ratios) {
|
|
67
|
+
if (ratio >= r && !warned.has(r)) {
|
|
68
|
+
warned.add(r) // 每档一次/进程
|
|
69
|
+
const line = heapWarnLine(used, limit, ratio)
|
|
70
|
+
out.push(line)
|
|
71
|
+
try { console.error(line) } catch { /* 输出失败不阻断 */ } // stderr(headless 主面——§8.3)
|
|
72
|
+
for (const cb of _subscribers) { try { cb(line) } catch { /* 订阅者失败不阻断 */ } }
|
|
73
|
+
try { logEvent("heap-warn", { kind: "heap-warn", used, limit, ratio: Number(ratio.toFixed(3)) }) } catch { /* 日志失败不阻断(D-HW4) */ }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} catch { /* 失败面全吞(N5——采样/上限抛错不阻断、状态保持) */ }
|
|
77
|
+
return out
|
|
78
|
+
}
|
|
79
|
+
if (!heapWatchEnabled(env)) return { stop() {}, checkNow }
|
|
80
|
+
const handle = timer(checkNow, intervalMs)
|
|
81
|
+
try { handle?.unref?.() } catch { /* unref 失败不阻断(一次性命令自然退出) */ }
|
|
82
|
+
return {
|
|
83
|
+
stop() {
|
|
84
|
+
try { clearInterval(handle) } catch { /* 已停/不可清——静默(尽力面) */ }
|
|
85
|
+
},
|
|
86
|
+
checkNow,
|
|
87
|
+
}
|
|
88
|
+
}
|
package/src/hooks.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* PreToolUse — before each tool call (can block execution)
|
|
9
9
|
* PostToolUse — after successful tool execution
|
|
10
10
|
* PostToolUseFailure — after failed tool execution
|
|
11
|
-
*
|
|
11
|
+
* Stop — main-session run end (no tool name; block is meaningless)
|
|
12
12
|
*
|
|
13
13
|
* Each hook: { matcher?, command, args?, timeout?, action? }
|
|
14
14
|
* matcher: regex against tool name (default: match all)
|
|
@@ -29,9 +29,11 @@ export async function runHooks(event, ctx) {
|
|
|
29
29
|
if (!hooks?.length) return true
|
|
30
30
|
|
|
31
31
|
for (const hook of hooks) {
|
|
32
|
-
|
|
32
|
+
// Matcher = tool-name regex: tool events always carry a toolName; no-tool-name
|
|
33
|
+
// events (Stop) ignore it — a configured matcher must not silently never fire.
|
|
34
|
+
if (hook.matcher && ctx.toolName != null) {
|
|
33
35
|
try {
|
|
34
|
-
if (!new RegExp(hook.matcher).test(ctx.toolName
|
|
36
|
+
if (!new RegExp(hook.matcher).test(ctx.toolName)) continue
|
|
35
37
|
} catch { /* invalid regex → skip */ }
|
|
36
38
|
}
|
|
37
39
|
|
|
@@ -48,6 +50,8 @@ async function runOneHook(event, hook, ctx) {
|
|
|
48
50
|
toolArgs: ctx.toolArgs ?? null,
|
|
49
51
|
result: ctx.result ?? null,
|
|
50
52
|
error: ctx.error?.message ?? null,
|
|
53
|
+
// Event-specific fields (Stop: turn / reason) — existing callers pass no extra ⇒ payload unchanged.
|
|
54
|
+
...(ctx.extra ?? {}),
|
|
51
55
|
timestamp: new Date().toISOString(),
|
|
52
56
|
})
|
|
53
57
|
|
package/src/ledger.mjs
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ledger.mjs — 台账(需求池 / 技术待办)单源读取面(LEDGER-SURFACE 批——设计档 §2.30.3)。
|
|
3
|
+
*
|
|
4
|
+
* 数字单源(F7):解析(`scanGroups`)/ 计数 / 老化 / 阈值一处实现——机检器
|
|
5
|
+
* (`scripts/check-ledger.mjs`)与显示面(CLI TUI `src/tui/ledger-surface.mjs`)共用本口径;
|
|
6
|
+
* VSC 端为独立实现、语义同源(不跨仓 import)。
|
|
7
|
+
* 口径(需求档 §1.18):计数 = `##` 组内 `- [ ]` 未决条目;老化 = 技术组无 `触发=` 且行龄
|
|
8
|
+
* > 30 天(行龄未知不计);阈值 = 池 ≥ 3 或任一板块 ≥ 2;可动作 = 老化 > 0 或阈值达成。
|
|
9
|
+
* 行文本逐字契约 = 设计档 §2.30.3.3(本模块只产出文本,色 / 态归各端显示面)。
|
|
10
|
+
*/
|
|
11
|
+
import { mkdirSync, opendirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"
|
|
12
|
+
import { basename, dirname, join, resolve } from "node:path"
|
|
13
|
+
import { execFileSync } from "node:child_process"
|
|
14
|
+
import { configDir } from "./config.mjs"
|
|
15
|
+
|
|
16
|
+
/** 老化阈值(天——口径 = 需求档 §1.18;`days` 参数可覆盖)。 */
|
|
17
|
+
export const AGING_DAYS = 30
|
|
18
|
+
/** 「可开批」阈值:同一板块(池条目指针所指需求档文档名)未决 ≥ 2。 */
|
|
19
|
+
export const THRESHOLD_BOARD = 2
|
|
20
|
+
/** 「可开批」阈值:需求池未决 ≥ 3。 */
|
|
21
|
+
export const THRESHOLD_POOL = 3
|
|
22
|
+
/** 刷新周期(N2 实现常量;VSC 端另有换项目事件触发)。 */
|
|
23
|
+
export const REFRESH_MS = 120000
|
|
24
|
+
/** 变化行去重档(跨会话、跨端共享——需求档 §1.18 F5;configDir 先例 = crash-reports 同区)。 */
|
|
25
|
+
export const NOTIFY_FILE = join(configDir, "ledger-notify.json")
|
|
26
|
+
/** 空族提示(仅命令面——运行时面静默,N1 / 设计档 §2.30.3.3)。 */
|
|
27
|
+
export const EMPTY_FAMILY_LINE = "台账:未发现台账(docs/TODO.md)。"
|
|
28
|
+
|
|
29
|
+
const LEDGER_REL = join("docs", "TODO.md")
|
|
30
|
+
const H2_RE = /^##\s+(.*)$/
|
|
31
|
+
const DECL_RE = /((\d+)\s*条)/
|
|
32
|
+
const OPEN_ENTRY_RE = /^- \[ \]\s+/
|
|
33
|
+
const TRIGGER_RE = /触发\s*=\s*([^·\n]*)/
|
|
34
|
+
const BOARD_REF_RE = /requirements\/([A-Za-z0-9_.-]+\.md)/
|
|
35
|
+
|
|
36
|
+
/** `##` 组扫描(与机检器 L2/L3 同源——解析唯一实现):[{name,line,declared,entries:[{line,text}]}]。 */
|
|
37
|
+
export function scanGroups(lines) {
|
|
38
|
+
const groups = []
|
|
39
|
+
lines.forEach((l, i) => {
|
|
40
|
+
const h = H2_RE.exec(l)
|
|
41
|
+
if (h) groups.push({ name: h[1].trim(), line: i + 1, declared: Number((DECL_RE.exec(h[1]) ?? [])[1] ?? NaN), entries: [] })
|
|
42
|
+
else if (groups.length && OPEN_ENTRY_RE.test(l)) groups[groups.length - 1].entries.push({ line: i + 1, text: l })
|
|
43
|
+
})
|
|
44
|
+
return groups
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 条目归一化文本 = 条目键(去 `- [ ] ` 前缀 + 去首尾空白 + 连续空白折叠单空格)。
|
|
48
|
+
* 位置无关(位移 / 他条编辑稳定);自身文本变更 = 键变(最坏一次重报)。§2.30.3.2。 */
|
|
49
|
+
export function normalizeEntry(text) {
|
|
50
|
+
return text.replace(/^- \[[ x]\]\s+/, "").trim().replace(/\s+/g, " ")
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 条目标题:首个 `**…**` 段;无粗体段 → 归一化文本前 20 字(超出加 `…`)。§2.30.3.3 L3。 */
|
|
54
|
+
export function entryTitle(text) {
|
|
55
|
+
const m = /\*\*(.+?)\*\*/.exec(text)
|
|
56
|
+
if (m) return m[1]
|
|
57
|
+
const norm = normalizeEntry(text)
|
|
58
|
+
return norm.length > 20 ? norm.slice(0, 20) + "…" : norm
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const isFile = (p) => { try { return statSync(p).isFile() } catch { return false } }
|
|
62
|
+
|
|
63
|
+
/** 向上(含自身)最近的含台账目录 → {root, ledger} / null(F4——CLI 锚 = cwd)。 */
|
|
64
|
+
export function findProject(anchor) {
|
|
65
|
+
let dir = resolve(anchor)
|
|
66
|
+
for (;;) {
|
|
67
|
+
const ledger = join(dir, LEDGER_REL)
|
|
68
|
+
if (isFile(ledger)) return { root: dir, ledger }
|
|
69
|
+
const parent = dirname(dir)
|
|
70
|
+
if (parent === dir) return null
|
|
71
|
+
dir = parent
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 同级枚举上限(N2 成本有界:父目录超此规模 = 缓存 / 临时等非仓族形态 → 退化空集,只显当前项目)。 */
|
|
76
|
+
export const MAX_SIBLING_SCAN = 100
|
|
77
|
+
|
|
78
|
+
/** 目录下含台账的直接子目录(目录名升序;不可读 → [];**惰性枚举 + 上限**——大目录零全量扫描)。 */
|
|
79
|
+
function ledgerChildren(dir) {
|
|
80
|
+
const out = []
|
|
81
|
+
let handle
|
|
82
|
+
try { handle = opendirSync(dir) } catch { return out }
|
|
83
|
+
try {
|
|
84
|
+
let n = 0
|
|
85
|
+
for (;;) {
|
|
86
|
+
const ent = handle.readSync()
|
|
87
|
+
if (!ent) break
|
|
88
|
+
if (++n > MAX_SIBLING_SCAN) return []
|
|
89
|
+
if (!ent.isDirectory()) continue
|
|
90
|
+
const root = join(dir, ent.name)
|
|
91
|
+
const ledger = join(root, LEDGER_REL)
|
|
92
|
+
if (isFile(ledger)) out.push({ root, ledger })
|
|
93
|
+
}
|
|
94
|
+
} catch { /* 读中断 → 已得子集(尽力而为) */ }
|
|
95
|
+
finally { try { handle.closeSync() } catch { /* 已关 */ } }
|
|
96
|
+
return out.sort((a, b) => (a.root < b.root ? -1 : a.root > b.root ? 1 : 0))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 项目族发现:{current, projects}——projects = current + 其同级含台账目录(**发现序:current 在前**,
|
|
100
|
+
* 其余按目录名升序);current 缺(容器目录,K6)→ 上下文目录向上最近「含台账子目录」者取其子目录。 */
|
|
101
|
+
export function discoverFamily(anchor) {
|
|
102
|
+
const current = findProject(anchor)
|
|
103
|
+
if (current) {
|
|
104
|
+
const siblings = ledgerChildren(dirname(current.root)).filter((p) => p.root !== current.root)
|
|
105
|
+
return { current, projects: [current, ...siblings] }
|
|
106
|
+
}
|
|
107
|
+
let dir = resolve(anchor)
|
|
108
|
+
for (;;) {
|
|
109
|
+
const kids = ledgerChildren(dir)
|
|
110
|
+
if (kids.length) return { current: null, projects: kids }
|
|
111
|
+
const parent = dirname(dir)
|
|
112
|
+
if (parent === dir) return { current: null, projects: [] }
|
|
113
|
+
dir = parent
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 行龄 Map:全档一次 `git blame --porcelain` → Map<行号, 天>;非 git / 不可判定 → null(零假阳降级)。 */
|
|
118
|
+
export function blameAges(abs) {
|
|
119
|
+
let out
|
|
120
|
+
try {
|
|
121
|
+
out = execFileSync("git", ["blame", "--porcelain", "--", abs], {
|
|
122
|
+
cwd: dirname(abs), encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 32 * 1024 * 1024,
|
|
123
|
+
})
|
|
124
|
+
} catch { return null }
|
|
125
|
+
const times = new Map(), ages = new Map()
|
|
126
|
+
const now = Date.now() / 1000
|
|
127
|
+
let sha = null, line = 0
|
|
128
|
+
for (const l of out.split("\n")) {
|
|
129
|
+
const h = /^([0-9a-f^]{7,40})\s+\d+\s+(\d+)(?:\s+\d+)?$/.exec(l)
|
|
130
|
+
if (h) { sha = h[1]; line = Number(h[2]); continue }
|
|
131
|
+
if (l.startsWith("author-time ")) { times.set(sha, Number(l.slice(12))); continue }
|
|
132
|
+
if (l.startsWith("\t")) {
|
|
133
|
+
const t = times.get(sha)
|
|
134
|
+
ages.set(line, t != null ? (now - t) / 86400 : null)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return ages
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 单项目汇总 → {name,root,ledger,pool,tech,aged,agedKeys,agedTitles,thresholdReached,boards,actionable}。
|
|
141
|
+
* `ageOf(abs)` → Map<行号,天>|null(注入面——确定性用例);**无候选条目不调 ageOf**(N2)。 */
|
|
142
|
+
export function summarizeLedger(project, { days = AGING_DAYS, ageOf = blameAges } = {}) {
|
|
143
|
+
const groups = scanGroups(readFileSync(project.ledger, "utf8").split("\n"))
|
|
144
|
+
const poolEntries = groups.filter((g) => g.name.includes("需求池")).flatMap((g) => g.entries)
|
|
145
|
+
const techEntries = groups.filter((g) => g.name.includes("技术")).flatMap((g) => g.entries)
|
|
146
|
+
const candidates = techEntries.filter((e) => !TRIGGER_RE.test(e.text))
|
|
147
|
+
const ages = candidates.length ? ageOf(project.ledger) : null
|
|
148
|
+
const aged = candidates.filter((e) => { const a = ages?.get(e.line); return a != null && a > days })
|
|
149
|
+
const boards = new Map()
|
|
150
|
+
for (const e of poolEntries) {
|
|
151
|
+
const m = BOARD_REF_RE.exec(e.text)
|
|
152
|
+
if (m) boards.set(m[1], (boards.get(m[1]) ?? 0) + 1)
|
|
153
|
+
}
|
|
154
|
+
const thresholdReached = poolEntries.length >= THRESHOLD_POOL || [...boards.values()].some((n) => n >= THRESHOLD_BOARD)
|
|
155
|
+
return {
|
|
156
|
+
name: basename(project.root), root: project.root, ledger: project.ledger,
|
|
157
|
+
pool: poolEntries.length, tech: techEntries.length, aged: aged.length,
|
|
158
|
+
agedKeys: aged.map((e) => normalizeEntry(e.text)), agedTitles: aged.map((e) => entryTitle(e.text)),
|
|
159
|
+
thresholdReached, boards, actionable: aged.length > 0 || thresholdReached,
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** L1 状态标记(逐字——§2.30.3.3)。 */
|
|
164
|
+
export function formatMarker(scan) {
|
|
165
|
+
return scan ? `台账 ${scan.pool}·${scan.tech}` : null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** L2 明细行(逐字——`(老化 <n>)` 恒显;阈值达成加 ` — 可开批`)。 */
|
|
169
|
+
export function formatDetailLine(scan) {
|
|
170
|
+
return `台账 ${scan.name}:需求池 ${scan.pool} · 技术待办 ${scan.tech}(老化 ${scan.aged})${scan.thresholdReached ? " — 可开批" : ""}`
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** L3 变化行·老化(titles = 新增老化条目标题;> 3 条时第三项后接 `;…`)。 */
|
|
174
|
+
export function formatAgingLine(scan, titles) {
|
|
175
|
+
return `台账变化:${scan.name} 老化首次越线 ${titles.length} 条(超 30 天未处置):${titles.slice(0, 3).join(";")}${titles.length > 3 ? ";…" : ""}`
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** L4 变化行·阈值。 */
|
|
179
|
+
export function formatThresholdLine(scan) {
|
|
180
|
+
return `台账变化:${scan.name} 需求池达阈值(${scan.pool} 条)— 可开批`
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 明细行集 = {current} ∪ {可动作项目}(发现序——current 在前,其余按目录名升序;同项去重)。 */
|
|
184
|
+
export function detailScans(scans, current) {
|
|
185
|
+
const out = []
|
|
186
|
+
if (current) out.push(current)
|
|
187
|
+
for (const s of scans) if (s !== current && s.actionable) out.push(s)
|
|
188
|
+
return out
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 变化行规划(纯函数):{lines:[{text,warn}], next:{aged,threshold}}(去重口径 §2.30.3.2)。 */
|
|
192
|
+
export function planChangeLines(prev, scan) {
|
|
193
|
+
const p = prev && typeof prev === "object" ? prev : {}
|
|
194
|
+
const before = new Set(Array.isArray(p.aged) ? p.aged : [])
|
|
195
|
+
const freshTitles = scan.agedKeys.map((k, i) => (before.has(k) ? null : scan.agedTitles[i])).filter((t) => t != null)
|
|
196
|
+
const lines = []
|
|
197
|
+
if (freshTitles.length) lines.push({ text: formatAgingLine(scan, freshTitles), warn: true })
|
|
198
|
+
if (scan.thresholdReached && !p.threshold) lines.push({ text: formatThresholdLine(scan), warn: true })
|
|
199
|
+
return { lines, next: { aged: scan.agedKeys, threshold: scan.thresholdReached } }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** 去重档键 = 台账绝对路径·正斜杠 + **盘符大写**(跨端同规则——CLI 的 `process.cwd()` 盘符大写、
|
|
203
|
+
* VSC 的 `uri.fsPath` 小写(`session-slots.mjs` `normalizeCwd` 同一契约——session / checkpoint /
|
|
204
|
+
* trace 跨端共享即赖此);CLI 写的键 VSC 须逐字认得)。 */
|
|
205
|
+
export function notifyKey(ledger) {
|
|
206
|
+
return resolve(ledger).replace(/\\/g, "/").replace(/^([a-z]):/, (_, d) => `${d.toUpperCase()}:`)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** 去重档读(缺失 / 坏 JSON → 空态——N1 降级不崩)。 */
|
|
210
|
+
export function loadNotifyState(file = NOTIFY_FILE) {
|
|
211
|
+
try {
|
|
212
|
+
const j = JSON.parse(readFileSync(file, "utf8"))
|
|
213
|
+
if (j && typeof j === "object" && j.ledgers && typeof j.ledgers === "object") return { version: 1, ledgers: j.ledgers }
|
|
214
|
+
} catch { /* 缺失 / 坏档 → 空态 */ }
|
|
215
|
+
return { version: 1, ledgers: {} }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** 去重档写(temp + rename 防撕裂;写失败静默返回 false——唯一写面,仅送达后调)。 */
|
|
219
|
+
export function saveNotifyState(file, state) {
|
|
220
|
+
try {
|
|
221
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
222
|
+
const tmp = `${file}.tmp-${process.pid}`
|
|
223
|
+
writeFileSync(tmp, JSON.stringify(state), "utf8")
|
|
224
|
+
renameSync(tmp, file)
|
|
225
|
+
return true
|
|
226
|
+
} catch { return false }
|
|
227
|
+
}
|
|
@@ -8,14 +8,20 @@ import { segmentCJK, CODE_EXTS, DOC_EXTS, SKIP_DIRS, BIG_FILE_LINES } from "./sc
|
|
|
8
8
|
export function detectLanguage(filename) {
|
|
9
9
|
const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase()
|
|
10
10
|
const map = {
|
|
11
|
-
".mjs": "javascript", ".js": "javascript", ".
|
|
11
|
+
".mjs": "javascript", ".js": "javascript", ".cjs": "javascript", ".jsx": "jsx",
|
|
12
|
+
".ts": "typescript", ".tsx": "tsx", ".mts": "typescript", ".cts": "typescript",
|
|
12
13
|
".py": "python", ".rs": "rust", ".go": "go", ".java": "java",
|
|
13
14
|
".c": "c", ".h": "c", ".cpp": "cpp", ".hpp": "cpp",
|
|
14
|
-
".rb": "ruby", ".swift": "swift", ".kt": "kotlin",
|
|
15
|
+
".rb": "ruby", ".swift": "swift", ".kt": "kotlin", ".dart": "dart", ".lua": "lua",
|
|
16
|
+
".cs": "csharp", ".fs": "fsharp", ".fsx": "fsharp",
|
|
17
|
+
".clj": "clojure", ".cljs": "clojure", ".ex": "elixir", ".exs": "elixir",
|
|
18
|
+
".erl": "erlang", ".hrl": "erlang", ".scala": "scala", ".groovy": "groovy",
|
|
19
|
+
".pl": "perl", ".pm": "perl", ".r": "r", ".jl": "julia", ".zig": "zig",
|
|
20
|
+
".ps1": "powershell", ".proto": "protobuf", ".graphql": "graphql", ".tf": "terraform", ".hcl": "hcl",
|
|
15
21
|
".sh": "bash", ".bash": "bash", ".sql": "sql",
|
|
16
22
|
".yaml": "yaml", ".yml": "yaml", ".toml": "toml", ".json": "json",
|
|
17
23
|
".css": "css", ".html": "html", ".vue": "vue", ".svelte": "svelte",
|
|
18
|
-
".md": "markdown", ".mdc": "markdown",
|
|
24
|
+
".md": "markdown", ".mdc": "markdown", ".mdx": "markdown", ".org": "org", ".wiki": "wiki", ".tex": "tex",
|
|
19
25
|
}
|
|
20
26
|
return map[ext] ?? ext.slice(1)
|
|
21
27
|
}
|
package/src/memory/code-sync.mjs
CHANGED
|
@@ -4,13 +4,31 @@
|
|
|
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 {
|
|
7
|
+
import { scanVectors, createTopK } from "./scan.mjs"
|
|
8
|
+
import { CODE_EXTS, DOC_EXTS, MAX_CODE_FILE_BYTES, MAX_DOC_FILE_BYTES } from "./schema.mjs"
|
|
8
9
|
import { buildFtsQuery, ensureEmbeddings, EMBED_TEXT_MAX_LEN } from "./core.mjs"
|
|
9
10
|
import { detectLanguage, _upsertCodeFile, _upsertDocFile, yieldTick } from "./code-index.mjs"
|
|
11
|
+
import { walkProjectFiles, isSkippedRelPath, extensionOf, createUnlistedTally, MAX_WALK_FILES } from "./file-walk.mjs"
|
|
12
|
+
import { loadConventions } from "../conventions.mjs"
|
|
13
|
+
import { logEvent } from "../log.mjs"
|
|
10
14
|
|
|
11
15
|
const DIFF_FULL_SYNC_THRESHOLD = 200
|
|
12
16
|
const CODE_EMBED_BATCH = 64
|
|
13
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Index extension sets = built-in tables ∪ project declaration
|
|
20
|
+
* (`.thincoder/conventions.json` → index.codeExtensions / index.docExtensions;
|
|
21
|
+
* PORTABILITY PO-9/§3.7 — a project may declare extensions this product does not
|
|
22
|
+
* ship a default for). Declaration only ADDS (union), never removes.
|
|
23
|
+
*/
|
|
24
|
+
export function indexExtensions(dir) {
|
|
25
|
+
const conv = loadConventions(dir)
|
|
26
|
+
return {
|
|
27
|
+
code: new Set([...CODE_EXTS, ...conv.index.codeExtensions]),
|
|
28
|
+
doc: new Set([...DOC_EXTS, ...conv.index.docExtensions]),
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
14
32
|
/**
|
|
15
33
|
* git-driven incremental indexing: use git diff to find files changed since
|
|
16
34
|
* the last index, and only rebuild FTS5 chunks for those files (vectors are untouched).
|
|
@@ -19,6 +37,7 @@ const CODE_EMBED_BATCH = 64
|
|
|
19
37
|
*/
|
|
20
38
|
export async function gitSync(memory, dir, { onProgress } = {}) {
|
|
21
39
|
const { execFile: _execFile } = await import("node:child_process")
|
|
40
|
+
const { code: codeExts, doc: docExts } = indexExtensions(dir)
|
|
22
41
|
const gitRun = (args) => new Promise((resolve, reject) => {
|
|
23
42
|
_execFile("git", args, { cwd: dir, encoding: "utf8", timeout: 10000, windowsHide: true }, (err, stdout) => {
|
|
24
43
|
if (err) reject(err); else resolve(stdout)
|
|
@@ -55,17 +74,16 @@ export async function gitSync(memory, dir, { onProgress } = {}) {
|
|
|
55
74
|
for (let i = 0; i < diffOut.length; i++) {
|
|
56
75
|
const rel = diffOut[i].replaceAll("\\", "/")
|
|
57
76
|
const abs = join(dir, rel)
|
|
58
|
-
const ext =
|
|
77
|
+
const ext = extensionOf(rel)
|
|
59
78
|
|
|
60
|
-
|
|
61
|
-
if (pathDirs.some((d) => SKIP_DIRS.has(d) || d.startsWith("."))) continue
|
|
79
|
+
if (isSkippedRelPath(rel)) continue
|
|
62
80
|
|
|
63
|
-
if (!
|
|
81
|
+
if (!codeExts.has(ext) && !docExts.has(ext)) { skipped++; continue }
|
|
64
82
|
|
|
65
83
|
try {
|
|
66
84
|
const text = await readFile(abs, "utf8")
|
|
67
85
|
const lines = text.split("\n")
|
|
68
|
-
if (
|
|
86
|
+
if (codeExts.has(ext)) {
|
|
69
87
|
const lang = detectLanguage(abs)
|
|
70
88
|
let mtimeMs = 0
|
|
71
89
|
try { mtimeMs = Math.floor((await stat(abs)).mtimeMs) } catch { /* new file */ }
|
|
@@ -79,7 +97,7 @@ export async function gitSync(memory, dir, { onProgress } = {}) {
|
|
|
79
97
|
} catch (e) {
|
|
80
98
|
const isDeleted = e.code === "ENOENT"
|
|
81
99
|
if (isDeleted) {
|
|
82
|
-
if (
|
|
100
|
+
if (codeExts.has(ext)) memory.db.prepare(`DELETE FROM code_chunks WHERE origin = ? AND path = ?`).run(dir, rel)
|
|
83
101
|
else memory.db.prepare(`DELETE FROM doc_chunks WHERE origin = ? AND path = ?`).run(dir, rel)
|
|
84
102
|
removed++
|
|
85
103
|
} else {
|
|
@@ -104,16 +122,27 @@ export async function gitSync(memory, dir, { onProgress } = {}) {
|
|
|
104
122
|
|
|
105
123
|
/**
|
|
106
124
|
* List project files matching the given extensions.
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* (
|
|
110
|
-
*
|
|
125
|
+
* Preferred source = git (`git ls-files --cached --others --exclude-standard`:
|
|
126
|
+
* tracked + untracked-not-ignored, .gitignore respected). Projects WITHOUT git
|
|
127
|
+
* fall back to a filesystem walk (PORTABILITY FR15/P8) — before, the index was
|
|
128
|
+
* silently empty there. The walk cannot honour .gitignore (no git → no such
|
|
129
|
+
* concept) and skips the shared SKIP_DIRS/dot-directory set instead.
|
|
130
|
+
* Returns { entries, unlisted }: `entries` = { abs, rel } pairs (rel relative to
|
|
131
|
+
* dir); `unlisted` = files whose extension is in NO index list (count + sample),
|
|
132
|
+
* the visible signal behind "my .xyz files are not searchable" (PORTABILITY PO-9).
|
|
133
|
+
* `truncated` = the non-git walk hit its file cap (capped trees are logged, never
|
|
134
|
+
* silently indexed-partial). opts.maxFiles = the walk cap (test seam).
|
|
111
135
|
*/
|
|
112
|
-
export async function listProjectFiles(dir, exts) {
|
|
136
|
+
export async function listProjectFiles(dir, exts, { maxFiles } = {}) {
|
|
113
137
|
const { execFile: _execFile } = await import("node:child_process")
|
|
114
138
|
const { join: joinPath } = await import("node:path")
|
|
139
|
+
const { code, doc } = indexExtensions(dir)
|
|
140
|
+
const tally = createUnlistedTally(new Set([...code, ...doc]))
|
|
115
141
|
|
|
116
|
-
|
|
142
|
+
const files = []
|
|
143
|
+
const finish = (truncated = false) => ({ entries: files, unlisted: tally.result(), truncated })
|
|
144
|
+
|
|
145
|
+
// Git listing when available; a non-git project falls through to the walk.
|
|
117
146
|
let gitTop
|
|
118
147
|
try {
|
|
119
148
|
gitTop = (await new Promise((resolve, reject) => {
|
|
@@ -121,10 +150,17 @@ export async function listProjectFiles(dir, exts) {
|
|
|
121
150
|
(err, stdout) => { if (err) reject(err); else resolve(stdout.trim()) })
|
|
122
151
|
})).replace(/\\/g, "/")
|
|
123
152
|
} catch {
|
|
124
|
-
|
|
153
|
+
// Not a git repo → filesystem walk (FR15: the index must still be usable).
|
|
154
|
+
const walked = await walkProjectFiles(dir, exts, { knownExts: new Set([...code, ...doc]), ...(maxFiles !== undefined ? { maxFiles } : {}) })
|
|
155
|
+
files.push(...walked.files)
|
|
156
|
+
// The walk's cap must stay visible end-to-end (file-walk.mjs: "never silently
|
|
157
|
+
// dropped") — a capped listing says so in the log, not only in a discarded flag.
|
|
158
|
+
if (walked.truncated) {
|
|
159
|
+
logEvent("index:truncated", { dir, files: walked.files.length, maxFiles: maxFiles ?? MAX_WALK_FILES })
|
|
160
|
+
}
|
|
161
|
+
return { entries: files, unlisted: walked.unlisted, truncated: walked.truncated }
|
|
125
162
|
}
|
|
126
163
|
|
|
127
|
-
const files = []
|
|
128
164
|
try {
|
|
129
165
|
const raw = await new Promise((resolve, reject) => {
|
|
130
166
|
_execFile("git", ["ls-files", "--cached", "--others", "--exclude-standard"],
|
|
@@ -134,16 +170,15 @@ export async function listProjectFiles(dir, exts) {
|
|
|
134
170
|
for (const line of raw.trim().split("\n")) {
|
|
135
171
|
const p = line.trim()
|
|
136
172
|
if (!p) continue
|
|
137
|
-
const ext = p.slice(p.lastIndexOf(".")).toLowerCase()
|
|
138
|
-
if (!exts.has(ext)) continue
|
|
139
|
-
const abs = joinPath(dir, p)
|
|
140
173
|
const rel = p.replace(/\\/g, "/")
|
|
141
|
-
if (rel
|
|
142
|
-
|
|
174
|
+
if (isSkippedRelPath(rel)) continue
|
|
175
|
+
const ext = extensionOf(rel)
|
|
176
|
+
if (!ext || !exts.has(ext)) { tally.note(rel); continue }
|
|
177
|
+
files.push({ abs: joinPath(dir, rel), rel })
|
|
143
178
|
}
|
|
144
179
|
} catch { /* ls-files failed */ }
|
|
145
180
|
|
|
146
|
-
return
|
|
181
|
+
return finish()
|
|
147
182
|
}
|
|
148
183
|
|
|
149
184
|
|
|
@@ -152,7 +187,8 @@ export async function listProjectFiles(dir, exts) {
|
|
|
152
187
|
* Incremental by mtime — only rebuilds chunks for files that have changed.
|
|
153
188
|
*/
|
|
154
189
|
export async function codeSync(memory, dir, { onProgress } = {}) {
|
|
155
|
-
const
|
|
190
|
+
const { code: exts } = indexExtensions(dir)
|
|
191
|
+
const { entries, unlisted } = await listProjectFiles(dir, exts)
|
|
156
192
|
const files = [] // { abs, rel, mtimeMs }
|
|
157
193
|
let overSizeSkipped = 0
|
|
158
194
|
for (const { abs, rel } of entries) {
|
|
@@ -206,7 +242,10 @@ export async function codeSync(memory, dir, { onProgress } = {}) {
|
|
|
206
242
|
|
|
207
243
|
onProgress?.({ phase: "done", total: files.length, updated, removed, skipped, failed, overSizeSkipped })
|
|
208
244
|
markIndexedCommit(memory, dir)
|
|
209
|
-
|
|
245
|
+
if (unlisted.count > 0) {
|
|
246
|
+
logEvent("index:unlisted", { dir, kind: "code", count: unlisted.count, exts: unlisted.exts.map((e) => e.ext) })
|
|
247
|
+
}
|
|
248
|
+
return { updated, removed, skipped, failed, errors, total: files.length, overSizeSkipped, unlistedExts: unlisted }
|
|
210
249
|
}
|
|
211
250
|
|
|
212
251
|
/** Record current HEAD as the index anchor (gitSync incremental diff baseline); silently skip non-git repos */
|
|
@@ -253,11 +292,12 @@ export async function codeSearch(memory, query, { limit = 5 } = {}) {
|
|
|
253
292
|
console.error(`[code] query embedding failed, falling back to FTS-only: ${e.message}`)
|
|
254
293
|
return ftsList.slice(0, limit)
|
|
255
294
|
}
|
|
256
|
-
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
.
|
|
260
|
-
|
|
295
|
+
// TUI-OOM-ROOTCAUSE(MEMORY.md §10.3):分块扫描 + 有界 top-K(原全表 .all()——峰值 = 块 + K)
|
|
296
|
+
const top = createTopK(Math.max(limit * 4, 20))
|
|
297
|
+
scanVectors(memory.db, `SELECT rowid, embedding FROM code_chunks WHERE embedding IS NOT NULL ${vecOriginFilter}`, originParams, {
|
|
298
|
+
onRow: (r) => top.push({ id: r.rowid, rowid: r.rowid, score: cosine(qvec, fromBlob(r.embedding)) }),
|
|
299
|
+
})
|
|
300
|
+
const vecList = top.list().map((c) => ({ rowid: c.id, score: c.score }))
|
|
261
301
|
|
|
262
302
|
const K = 60
|
|
263
303
|
const scores = new Map()
|
|
@@ -338,32 +378,33 @@ export function codeSearchTool(memory) {
|
|
|
338
378
|
* Single-file incremental reindex: called after write/edit/delete, only rebuilds this one path.
|
|
339
379
|
*/
|
|
340
380
|
export async function reindexFile(memory, cwd, absPath) {
|
|
341
|
-
const ext =
|
|
381
|
+
const ext = extensionOf(absPath)
|
|
342
382
|
const rel = relative(cwd, absPath).replaceAll("\\", "/")
|
|
343
383
|
if (rel === ".." || rel.startsWith("../")) return
|
|
344
|
-
|
|
345
|
-
|
|
384
|
+
if (isSkippedRelPath(rel)) return
|
|
385
|
+
// Declared extensions count for the single-file path too (union with built-ins).
|
|
386
|
+
const { code: codeExts, doc: docExts } = indexExtensions(cwd)
|
|
346
387
|
|
|
347
388
|
// skip oversized files (minified bundles, test fixtures, generated code)
|
|
348
|
-
const maxBytes =
|
|
389
|
+
const maxBytes = codeExts.has(ext) ? MAX_CODE_FILE_BYTES : docExts.has(ext) ? MAX_DOC_FILE_BYTES : 0
|
|
349
390
|
if (maxBytes > 0) {
|
|
350
391
|
try { const st = await stat(absPath); if (st.size > maxBytes) return } catch { /* can't stat, proceed */ }
|
|
351
392
|
}
|
|
352
393
|
|
|
353
394
|
let text
|
|
354
395
|
try { text = await readFile(absPath, "utf8") } catch {
|
|
355
|
-
if (
|
|
356
|
-
else if (
|
|
396
|
+
if (codeExts.has(ext)) memory.db.prepare(`DELETE FROM code_chunks WHERE origin = ? AND path = ?`).run(cwd, rel)
|
|
397
|
+
else if (docExts.has(ext)) memory.db.prepare(`DELETE FROM doc_chunks WHERE origin = ? AND path = ?`).run(cwd, rel)
|
|
357
398
|
return
|
|
358
399
|
}
|
|
359
400
|
const lines = text.split("\n")
|
|
360
401
|
|
|
361
|
-
if (
|
|
402
|
+
if (codeExts.has(ext)) {
|
|
362
403
|
const lang = detectLanguage(absPath)
|
|
363
404
|
let mtimeMs = 0
|
|
364
405
|
try { mtimeMs = Math.floor((await stat(absPath)).mtimeMs) } catch { /* new file */ }
|
|
365
406
|
_upsertCodeFile(memory, cwd, rel, lines, lang, mtimeMs)
|
|
366
|
-
} else if (
|
|
407
|
+
} else if (docExts.has(ext)) {
|
|
367
408
|
let mtimeMs = 0
|
|
368
409
|
try { mtimeMs = Math.floor((await stat(absPath)).mtimeMs) } catch { /* new file */ }
|
|
369
410
|
_upsertDocFile(memory, cwd, rel, lines, mtimeMs)
|