@thincoder/core 0.9.5 → 0.10.2
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 -0
- package/README.md +1 -1
- package/abort-provenance.mjs +10 -10
- package/advisor/citations.mjs +5 -5
- package/advisor/compaction.mjs +16 -15
- package/advisor/loop.mjs +12 -12
- package/advisor/messages.mjs +6 -6
- package/advisor/project-context.mjs +5 -5
- package/advisor/repos.mjs +3 -3
- package/advisor/run.mjs +22 -12
- package/advisor.mjs +4 -4
- package/agent/assemble.mjs +111 -0
- package/agent/chat-call.mjs +68 -0
- package/agent/child-marks.mjs +2 -2
- package/agent/completion.mjs +9 -2
- package/agent/continue-decision.mjs +25 -0
- package/agent/dispatch-gates.mjs +140 -0
- package/agent/dispatch-run.mjs +167 -0
- package/agent/dispatch.mjs +54 -296
- package/agent/family-tools.mjs +5 -5
- package/agent/helpers.mjs +53 -3
- package/agent/live-beat.mjs +49 -0
- package/agent/post-turn.mjs +4 -8
- package/agent/record-results.mjs +23 -20
- package/agent/relay-prefix.mjs +2 -2
- package/agent/run-stages.mjs +15 -11
- package/agent/run-start.mjs +111 -0
- package/agent/setup-reminders.mjs +9 -9
- package/agent/setup.mjs +32 -4
- package/agent/spawn-child.mjs +9 -9
- package/agent/suspension.mjs +89 -29
- package/agent/timers.mjs +113 -0
- package/agent/turn-loop.mjs +244 -0
- package/agent/write-gate.mjs +103 -6
- package/agent-tools/advisor-async.mjs +9 -9
- package/agent-tools/advisor-settle.mjs +12 -12
- package/agent-tools/advisor.mjs +36 -41
- package/agent-tools/async-discard.mjs +44 -1
- package/agent-tools/async-settle.mjs +21 -18
- package/agent-tools/audit-block.mjs +7 -7
- package/agent-tools/bash-async.mjs +281 -0
- package/agent-tools/batch-lifecycle.mjs +57 -18
- package/agent-tools/batch-paths.mjs +52 -8
- package/agent-tools/batch-skeleton.mjs +19 -1
- package/agent-tools/batch.mjs +12 -65
- package/agent-tools/checkpoint.mjs +121 -0
- package/agent-tools/child-permission.mjs +5 -3
- package/agent-tools/consult.mjs +56 -56
- package/agent-tools/context.mjs +4 -20
- package/agent-tools/design-token.mjs +3 -3
- package/agent-tools/digest-budget.mjs +1 -1
- package/agent-tools/eng.mjs +5 -5
- package/agent-tools/escalate-async.mjs +16 -11
- package/agent-tools/goal.mjs +3 -10
- package/agent-tools/panel-blocks.mjs +2 -2
- package/agent-tools/parent-channel.mjs +3 -13
- package/agent-tools/plan.mjs +3 -4
- package/agent-tools/read-history.mjs +12 -28
- package/agent-tools/recent-changes.mjs +4 -5
- package/agent-tools/review-facts.mjs +1 -1
- package/agent-tools/settings.mjs +47 -20
- package/agent-tools/skill.mjs +3 -5
- package/agent-tools/spawn-gates.mjs +3 -3
- package/agent-tools/subagent-actions-query.mjs +270 -0
- package/agent-tools/subagent-actions.mjs +21 -265
- package/agent-tools/subagent-async.mjs +25 -20
- package/agent-tools/subagent-panel.mjs +33 -4
- package/agent-tools/subagent-run.mjs +36 -21
- package/agent-tools/subagent-scheduler.mjs +28 -27
- package/agent-tools/subagent-spawn.mjs +45 -14
- package/agent-tools/subagent.mjs +33 -53
- package/agent-tools/task.mjs +3 -9
- package/agent-tools/timer.mjs +12 -12
- package/agent-tools/verify.mjs +14 -13
- package/agent-tools.mjs +0 -1
- package/agent.mjs +18 -355
- package/attachments.mjs +125 -0
- package/auto-think.mjs +1 -1
- package/config-presets.mjs +11 -6
- package/config-watch.mjs +83 -0
- package/config.mjs +29 -12
- package/context-degrade.mjs +81 -0
- package/context-echo.mjs +88 -0
- package/context-push.mjs +51 -0
- package/context.mjs +8 -187
- package/conventions.mjs +110 -161
- package/declaration.mjs +194 -0
- package/embedding.mjs +3 -1
- package/env-ref.mjs +81 -0
- package/explore-distill.mjs +2 -3
- package/file-links.mjs +53 -0
- package/file-refs.mjs +103 -0
- package/generate-title.mjs +28 -4
- package/history-window.mjs +17 -2
- package/i18n.mjs +2 -0
- package/index-discover.mjs +7 -7
- package/ledger-cmd.mjs +34 -126
- package/ledger-db.mjs +10 -2
- package/ledger-tools.mjs +188 -0
- package/ledger.mjs +9 -4
- package/log.mjs +12 -7
- package/manifest-discovery.mjs +131 -0
- package/manifest-schema.mjs +166 -0
- package/manifest.mjs +18 -217
- package/mcp.mjs +4 -1
- package/memory/code-search.mjs +149 -0
- package/memory/code-sync.mjs +89 -230
- package/memory/core.mjs +47 -32
- package/memory/docs.mjs +63 -262
- package/memory/file-list.mjs +89 -0
- package/memory/file-walk.mjs +7 -4
- package/memory/memory-tool.mjs +229 -0
- package/memory/schema.mjs +18 -3
- package/memory/sweep.mjs +289 -0
- package/memory/sync-tail.mjs +81 -0
- package/memory-status.mjs +69 -0
- package/memory.mjs +3 -0
- package/model-ref.mjs +96 -3
- package/model-specs.mjs +16 -7
- package/notify-policy.mjs +51 -0
- package/package.json +1 -1
- package/peer-claims.mjs +2 -2
- package/peer-domains.mjs +7 -11
- package/peer-instances.mjs +9 -10
- package/process-probe-exec.mjs +187 -0
- package/process-probe.mjs +27 -175
- package/prompt-overlays.mjs +11 -10
- package/prompts/advisor-design.md +2 -2
- package/prompts/advisor-round2.md +1 -1
- package/prompts/advisor-round3.md +1 -1
- package/prompts/common.md +16 -8
- package/prompts/discipline-engineering.md +57 -16
- package/prompts/discipline-normal.md +6 -3
- package/prompts/persona-coder.md +0 -1
- package/prompts/persona-eng-coder.md +1 -5
- package/prompts/persona-eng-designer.md +2 -3
- package/prompts/persona-engineering.md +32 -11
- package/prompts/persona-explore.md +0 -1
- package/prompts/persona-normal.md +1 -1
- package/prompts/persona-plan.md +3 -4
- package/prompts/subagent-base.md +14 -0
- package/provider/core.mjs +11 -48
- package/provider/list-models.mjs +3 -0
- package/provider/responses-request.mjs +237 -0
- package/provider/responses.mjs +6 -228
- package/provider-flows.mjs +248 -0
- package/queued.mjs +90 -0
- package/rules.mjs +209 -2
- package/session-guard.mjs +27 -7
- package/session-lifecycle.mjs +108 -27
- package/session-rename.mjs +5 -1
- package/session-slot-claims.mjs +230 -0
- package/session-slot-scan.mjs +299 -0
- package/session-slot-verify.mjs +304 -0
- package/session-slot-write.mjs +74 -8
- package/session-slots-manifest.mjs +163 -241
- package/session-slots.mjs +99 -71
- package/session.mjs +30 -10
- package/shell-candidates.mjs +78 -0
- package/text-budget.mjs +2 -2
- package/think-off.mjs +49 -0
- package/token-ttl.mjs +15 -4
- package/tool-args.mjs +73 -0
- package/tool-docs/advisor.md +9 -0
- package/tool-docs/batch.md +1 -0
- package/tool-docs/code_search.md +1 -0
- package/tool-docs/consult_start.md +5 -0
- package/tool-docs/consult_stop.md +3 -0
- package/tool-docs/context.md +7 -0
- package/tool-docs/doc_search.md +1 -0
- package/tool-docs/edit.md +2 -13
- package/tool-docs/eng.md +1 -0
- package/tool-docs/execute.md +1 -9
- package/tool-docs/git.md +25 -51
- package/tool-docs/goal.md +6 -0
- package/tool-docs/ledger_add.md +1 -0
- package/tool-docs/ledger_close.md +1 -0
- package/tool-docs/ledger_count.md +1 -0
- package/tool-docs/ledger_query.md +1 -0
- package/tool-docs/ledger_update.md +1 -0
- package/tool-docs/main_history.md +1 -0
- package/tool-docs/memory.md +10 -0
- package/tool-docs/notify_parent.md +9 -0
- package/tool-docs/peer_instances.md +1 -0
- package/tool-docs/plan.md +5 -0
- package/tool-docs/process.md +7 -3
- package/tool-docs/read_history.md +7 -0
- package/tool-docs/recent_changes.md +1 -0
- package/tool-docs/repo_outline.md +1 -0
- package/tool-docs/settings.md +6 -0
- package/tool-docs/skill.md +5 -0
- package/tool-docs/subagent.md +31 -0
- package/tool-docs/task.md +7 -0
- package/tool-docs/timer.md +6 -0
- package/tool-docs/verify.md +1 -0
- package/tool-docs/wait_for.md +2 -1
- package/tools/bash.mjs +28 -4
- package/tools/edit-batch.mjs +4 -4
- package/tools/edit-diff.mjs +4 -23
- package/tools/exec-run.mjs +102 -4
- package/tools/execute.mjs +5 -2
- package/tools/file.mjs +4 -9
- package/tools/git-run.mjs +1 -1
- package/tools/git.mjs +0 -24
- package/tools/index.mjs +2 -1
- package/tools/linter.mjs +2 -1
- package/tools/node-child.mjs +21 -0
- package/tools/ops.mjs +50 -13
- package/tools/patch.mjs +4 -2
- package/tools/repomap.mjs +20 -9
- package/tools/shared.mjs +8 -6
- package/tools/web.mjs +5 -2
- package/tools/write-path.mjs +1 -1
- package/traces/trace-store.mjs +17 -14
- package/vision-reader.mjs +76 -0
- package/agent-tools/batch-segment.mjs +0 -18
package/memory/docs.mjs
CHANGED
|
@@ -1,29 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* memory/docs.mjs — doc index sync
|
|
2
|
+
* memory/docs.mjs — doc index sync (retrieval / tool generation → memory-tool.mjs; 2026-10-01 split)
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { readFile, stat } from "node:fs/promises"
|
|
6
|
-
import { isAbsolute, join } from "node:path"
|
|
7
6
|
import { embed, cosine, toBlob, fromBlob } from "../embedding.mjs"
|
|
8
|
-
import { scanVectors, createTopK } from "./scan.mjs"
|
|
7
|
+
import { scanVectors, createTopK, SCAN_YIELD_MS } from "./scan.mjs"
|
|
9
8
|
import { normalizeOrigin } from "./origin.mjs"
|
|
10
|
-
import { commitAndPush } from "../git/gitmem.mjs"
|
|
11
9
|
import { MAX_DOC_FILE_BYTES } from "./schema.mjs"
|
|
12
|
-
import { buildFtsQuery,
|
|
13
|
-
import { deleteByUid, matchMemoryRows, deleteWhere } from "./delete.mjs"
|
|
10
|
+
import { buildFtsQuery, invalidateStaleEmbeddings, EMBED_TEXT_MAX_LEN } from "./core.mjs"
|
|
14
11
|
import { _upsertDocFile, yieldTick } from "./code-index.mjs"
|
|
15
|
-
import { markIndexedCommit, listProjectFiles, indexExtensions } from "./code-sync.mjs"
|
|
12
|
+
import { markIndexedCommit, listProjectFiles, indexExtensions, declaredRootEntries } from "./code-sync.mjs"
|
|
13
|
+
import { searchOrigins } from "./code-search.mjs"
|
|
14
|
+
import { createRowBudget, rowCountOfPath, sweepStaleRows } from "./sync-tail.mjs"
|
|
15
|
+
import { loadProjectDeclaration } from "../conventions.mjs" // 排除谓词本档不直调(经 `decl` 传 `sweepStaleRows`)
|
|
16
16
|
import { logEvent } from "../log.mjs"
|
|
17
17
|
import { safeSliceUTF16 } from "../text-budget.mjs"
|
|
18
|
+
import { DESC } from "../tools/shared.mjs" // #15 描述外置:文本单点 = tool-docs/*.md(DESC 单解析面,缺档抛错语义不变)
|
|
18
19
|
|
|
19
20
|
const DOC_EMBED_BATCH = 64
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
23
|
+
* §6.15 同步展开(写侧入口):入口 dir + 声明公共仓逐根(一层不递归);回执 = 入口根(契约零改)。
|
|
24
|
+
* 单根内胆 = `syncDocRoot`:.md/.mdc/.txt/.rst/.adoc → chunk → doc_chunks(mtime 增量;opts seam =
|
|
25
|
+
* `yieldFn` ∕ `nowFn` ∕ `yieldMs`——stale-loop 让出可确定性断言,同 scan.mjs)。
|
|
24
26
|
*/
|
|
25
|
-
export async function docSync(memory, dir,
|
|
27
|
+
export async function docSync(memory, dir, opts = {}) {
|
|
28
|
+
const res = await syncDocRoot(memory, dir, opts)
|
|
29
|
+
for (const root of declaredRootEntries(dir)) await syncDocRoot(memory, root, opts)
|
|
30
|
+
return res
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 单根内胆(doc 全扫径):原 `docSync` 体——零语义改动;导出供 `gitSync` 两条回退支跨档调用(不展开声明根)。 */
|
|
34
|
+
export async function syncDocRoot(memory, dir, { onProgress, yieldFn = yieldTick, nowFn = Date.now, yieldMs = SCAN_YIELD_MS } = {}) {
|
|
26
35
|
const origin = normalizeOrigin(dir) // §6.11 写缝归一(目录/git I/O 用原样 dir;库面 origin 一律归一值)
|
|
36
|
+
const decl = loadProjectDeclaration(dir)
|
|
27
37
|
const { entries, unlisted } = await listProjectFiles(dir, indexExtensions(dir).doc)
|
|
28
38
|
const files = [] // { abs, rel, mtimeMs }
|
|
29
39
|
let overSizeSkipped = 0
|
|
@@ -33,6 +43,8 @@ export async function docSync(memory, dir, { onProgress } = {}) {
|
|
|
33
43
|
if (st.size > MAX_DOC_FILE_BYTES) { overSizeSkipped++; continue }
|
|
34
44
|
files.push({ abs, rel, mtimeMs: Math.floor(st.mtimeMs) })
|
|
35
45
|
}
|
|
46
|
+
// §6.14 P2:处理序钉死 = `rel` 字典序(现 walk ∕ git 列序不确定;预算跳过 = 列序后缀)
|
|
47
|
+
files.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0))
|
|
36
48
|
|
|
37
49
|
const indexed = new Map(
|
|
38
50
|
memory.db.prepare(`SELECT path, mtime_ms FROM doc_chunks WHERE origin = ?`).all(origin).map((r) => [r.path, r.mtime_ms])
|
|
@@ -41,6 +53,10 @@ export async function docSync(memory, dir, { onProgress } = {}) {
|
|
|
41
53
|
|
|
42
54
|
onProgress?.({ phase: "scan", total: files.length, overSizeSkipped })
|
|
43
55
|
|
|
56
|
+
// §6.14 P2 行预算(单源 = createRowBudget:WARN 一行可见 ∕ CAP 跳过后列——只停新增,存量行不失效)
|
|
57
|
+
const budget = createRowBudget(memory, origin, "doc", dir)
|
|
58
|
+
const budgetSkipped = []
|
|
59
|
+
|
|
44
60
|
let updated = 0, removed = 0, skipped = 0, failed = 0
|
|
45
61
|
const errors = []
|
|
46
62
|
for (let i = 0; i < files.length; i++) {
|
|
@@ -51,11 +67,15 @@ export async function docSync(memory, dir, { onProgress } = {}) {
|
|
|
51
67
|
skipped++
|
|
52
68
|
continue
|
|
53
69
|
}
|
|
70
|
+
budget.note()
|
|
71
|
+
if (budget.over()) { budgetSkipped.push(rel); continue }
|
|
54
72
|
|
|
55
73
|
try {
|
|
74
|
+
const before = rowCountOfPath(memory, "doc_chunks", origin, rel)
|
|
56
75
|
const text = await readFile(abs, "utf8")
|
|
57
76
|
const lines = text.split("\n")
|
|
58
77
|
_upsertDocFile(memory, origin, rel, lines, mtimeMs)
|
|
78
|
+
budget.add(rowCountOfPath(memory, "doc_chunks", origin, rel) - before) // 净增行数——与「库内行数」同刻度
|
|
59
79
|
updated++
|
|
60
80
|
} catch (e) {
|
|
61
81
|
failed++
|
|
@@ -68,19 +88,17 @@ export async function docSync(memory, dir, { onProgress } = {}) {
|
|
|
68
88
|
}
|
|
69
89
|
}
|
|
70
90
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
memory.db.prepare(`DELETE FROM doc_chunks WHERE origin = ? AND path = ?`).run(origin, stale)
|
|
74
|
-
removed++
|
|
75
|
-
}
|
|
76
|
-
}
|
|
91
|
+
// §6.14 B6 ∕ M3:收尾 stale 循环(同款让出 + 面① 收尾保护位)——机制单源 = `sync-tail.mjs`
|
|
92
|
+
removed = await sweepStaleRows(memory, { table: "doc_chunks", origin, indexed, seen, decl, base: dir, yieldFn, nowFn, yieldMs })
|
|
77
93
|
|
|
78
|
-
onProgress?.({ phase: "done", total: files.length, updated, removed, skipped, failed, overSizeSkipped })
|
|
79
|
-
|
|
94
|
+
onProgress?.({ phase: "done", total: files.length, updated, removed, skipped, failed, overSizeSkipped, budgetSkipped: budgetSkipped.length })
|
|
95
|
+
// §6.14 B7 维护口:模型键失配的失效**执行**(读面只判定降级——执行住同步尾)
|
|
96
|
+
if (memory.embedder) { try { invalidateStaleEmbeddings(memory) } catch { /* 非阻塞 */ } }
|
|
97
|
+
await markIndexedCommit(memory, dir) // 锚推进 = 同步完成条件(§6.14 M1——原 fire-and-forget 竞态)
|
|
80
98
|
if (unlisted.count > 0) {
|
|
81
99
|
logEvent("index:unlisted", { dir, kind: "doc", count: unlisted.count, exts: unlisted.exts.map((e) => e.ext) })
|
|
82
100
|
}
|
|
83
|
-
return { updated, removed, skipped, failed, errors, total: files.length, overSizeSkipped, unlistedExts: unlisted }
|
|
101
|
+
return { updated, removed, skipped, failed, errors, total: files.length, overSizeSkipped, budgetSkipped, unlistedExts: unlisted }
|
|
84
102
|
}
|
|
85
103
|
|
|
86
104
|
/**
|
|
@@ -91,11 +109,10 @@ export async function docSearch(memory, query, { limit = 5 } = {}) {
|
|
|
91
109
|
const ftsQuery = buildFtsQuery(query)
|
|
92
110
|
if (!ftsQuery && !memory.embedder) return []
|
|
93
111
|
|
|
94
|
-
// §6.
|
|
95
|
-
const
|
|
96
|
-
const ftsOriginFilter =
|
|
97
|
-
const
|
|
98
|
-
const originParams = codeOrigin ? [codeOrigin] : []
|
|
112
|
+
// §6.15 读面 origin 集(项目 ∪ 声明公共仓;单 origin ⇒ 逐字零行为——快径 SQL 形零改)
|
|
113
|
+
const origins = searchOrigins(memory)
|
|
114
|
+
const ftsOriginFilter = origins.length === 1 ? `AND d.origin = ?` : origins.length > 1 ? `AND d.origin IN (${origins.map(() => "?").join(", ")})` : ""
|
|
115
|
+
const originParams = origins
|
|
99
116
|
|
|
100
117
|
const ftsList = ftsQuery ? memory.db.prepare(`
|
|
101
118
|
SELECT d.rowid, d.path, d.language, d.heading, d.content, d.line_start, d.line_end, bm25(doc_chunks_fts) AS rank
|
|
@@ -106,10 +123,15 @@ export async function docSearch(memory, query, { limit = 5 } = {}) {
|
|
|
106
123
|
|
|
107
124
|
if (!memory.embedder) return ftsList.slice(0, limit)
|
|
108
125
|
|
|
109
|
-
|
|
126
|
+
let docEnsured
|
|
127
|
+
try { docEnsured = await ensureDocEmbeddings(memory) } catch (e) {
|
|
110
128
|
console.error(`[docs] embedding ensure failed, falling back to FTS-only: ${e.message}`)
|
|
111
129
|
return ftsList.slice(0, limit)
|
|
112
130
|
}
|
|
131
|
+
if (docEnsured?.mismatch) { // §6.14 B7 读面零写:模型键失配 ⇒ 本面向量通道降级 FTS-only(一行可见;失效执行归维护口)
|
|
132
|
+
console.warn("[docs] embedding model changed — vector channel degraded to FTS-only (rebuild runs at maintenance: sync tail / reindex)")
|
|
133
|
+
return ftsList.slice(0, limit)
|
|
134
|
+
}
|
|
113
135
|
let qvec
|
|
114
136
|
try { [qvec] = await embed(memory.embedder, [query]) } catch (e) {
|
|
115
137
|
console.error(`[docs] query embedding failed, falling back to FTS-only: ${e.message}`)
|
|
@@ -118,13 +140,17 @@ export async function docSearch(memory, query, { limit = 5 } = {}) {
|
|
|
118
140
|
// TUI-OOM-ROOTCAUSE(MEMORY.md §10.3):分块扫描 + 有界 top-K(原全表 .all()——峰值 = 块 + K)
|
|
119
141
|
// TUI 假死批(§6.10 修法 A1/A2):游标 = PK 去等值过滤前缀列(有 origin 过滤 ⇒ 2 元组;
|
|
120
142
|
// 无过滤 ⇒ 全 PK);scanVectors = async(让出)。SELECT 须携键列(游标值源)。
|
|
121
|
-
const cursorKey =
|
|
143
|
+
const cursorKey = origins.length > 0 ? ["path", "line_start"] : ["origin", "path", "line_start"]
|
|
122
144
|
const keyCols = cursorKey.join(", ")
|
|
123
145
|
const top = createTopK(Math.max(limit * 4, 20))
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
146
|
+
// §6.15 多 origin ⇒ 逐 origin 分趟(每趟单 origin 等值 + 游标键去等值前缀列——与单 origin 快径同形)
|
|
147
|
+
const vecSql = `SELECT rowid, ${keyCols}, embedding FROM doc_chunks WHERE embedding IS NOT NULL ${origins.length ? "AND origin = ?" : ""}`
|
|
148
|
+
for (const origin of origins.length ? origins : [null]) {
|
|
149
|
+
await scanVectors(memory.db, vecSql, origin ? [origin] : [], {
|
|
150
|
+
cursorKey,
|
|
151
|
+
onRow: (r) => top.push({ id: r.rowid, rowid: r.rowid, score: cosine(qvec, fromBlob(r.embedding)) }),
|
|
152
|
+
})
|
|
153
|
+
}
|
|
128
154
|
const vecList = top.list().map((c) => ({ rowid: c.id, score: c.score }))
|
|
129
155
|
|
|
130
156
|
const K = 60
|
|
@@ -161,9 +187,9 @@ async function _runEnsureDocEmbeddings(memory) {
|
|
|
161
187
|
const modelKey = memory.embedder.model
|
|
162
188
|
const stored = memory.db.prepare(`SELECT value FROM meta WHERE key = 'doc_embedding_model'`).get()?.value
|
|
163
189
|
if (stored !== modelKey) {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
190
|
+
// §6.14 B7 读面零写:只判定(返回降级信号);失效**执行**(清向量 + 落键)归维护口
|
|
191
|
+
// `invalidateStaleEmbeddings`(同步尾 ∕ /reindex ∕ 桌面 ∕ VSC)。
|
|
192
|
+
return { mismatch: true }
|
|
167
193
|
}
|
|
168
194
|
|
|
169
195
|
const pending = memory.db.prepare(`SELECT rowid, path, heading, content FROM doc_chunks WHERE embedding IS NULL LIMIT ${DOC_EMBED_BATCH}`).all()
|
|
@@ -180,10 +206,7 @@ async function _runEnsureDocEmbeddings(memory) {
|
|
|
180
206
|
export function docSearchTool(memory) {
|
|
181
207
|
return {
|
|
182
208
|
name: "doc_search",
|
|
183
|
-
description:
|
|
184
|
-
"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. " +
|
|
185
|
-
"Returns matching doc chunks: path, heading, line range, relevance score, content excerpt. " +
|
|
186
|
-
"For what was said in sessions (conversation/chat history — decisions, rulings), use read_history.",
|
|
209
|
+
description: DESC("doc_search"), // #15 外置:文本单点 = tool-docs/doc_search.md
|
|
187
210
|
parameters: {
|
|
188
211
|
type: "object",
|
|
189
212
|
properties: {
|
|
@@ -203,229 +226,7 @@ export function docSearchTool(memory) {
|
|
|
203
226
|
}
|
|
204
227
|
}
|
|
205
228
|
|
|
206
|
-
//
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
* with thincoder-vscode/src/memory.mjs (MEMORY.md §6 D-M1/F-M6); layer VALUES per end
|
|
210
|
-
* (VS Code has no team layer and rejects it with CLI guidance). */
|
|
211
|
-
const MEMORY_ACTIONS = ["search", "put", "list", "delete", "clear"]
|
|
212
|
-
const MEMORY_LAYERS = ["personal", "project", "team"]
|
|
213
|
-
const MEMORY_TOOL_DESCRIPTION =
|
|
214
|
-
"Manage long-term memory in ONE tool — the action parameter picks the operation:\n" +
|
|
215
|
-
"- search — find knowledge saved in previous sessions (query, optional layer/limit); result rows start with a [layer] tag and carry the entry id (id prefix = the layer)\n" +
|
|
216
|
-
"- put — save a piece of knowledge for future sessions (type: rule = coding standards, knowledge = project facts, decision = architecture decisions, pattern = debugging/workflow patterns; title/content/tags; layer defaults to personal)\n" +
|
|
217
|
-
"- list — inventory what memory holds (optional layer/type/keyword filters, limit default 50); one row per entry: [layer] id [type] title (date); a truncated list notes the full count\n" +
|
|
218
|
-
"- delete — SINGLE: {id, layer} removes the entry shown in put/search/list output — layer is optional: when passed it is validated against the id prefix (a mismatch is refused — guards against deleting the wrong entry); when omitted the id prefix routes the delete, so any id search/list returned is directly deletable. BATCH (no id): {layer, type and/or keyword} removes every matching entry in that layer — layer and at least one of type/keyword are required, plus confirm:true (without confirm it returns the count plus a preview); layer-wide wipes without filters are refused on every layer\n" +
|
|
219
|
-
"- clear — {layer: \"personal\", confirm: true} wipes ALL personal memory entries. clear is personal-only: a missing layer or a project/team layer is refused (use delete batch filters on shared layers)\n" +
|
|
220
|
-
"Layer is the memory tier: personal (private), project (shared via this repo's .thincoder/memory/), team (CLI only, git-synced). The [layer] tag on search/list result rows, the row's id prefix, and the layer parameter are the same concept — pass a result row's [layer] as layer, or omit it on a single delete to auto-route by the id prefix.\n" +
|
|
221
|
-
"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" +
|
|
222
|
-
"Save bugs, conventions, and preferences here — they persist across sessions.\n" +
|
|
223
|
-
"Session message history (what was said in this or past sessions) is NOT in memory — search session messages with read_history."
|
|
224
|
-
|
|
225
|
-
function validateTypeFilter(type) {
|
|
226
|
-
if (type === undefined || type === null || type === "") return null
|
|
227
|
-
const t = String(type)
|
|
228
|
-
if (!["rule", "knowledge", "decision", "pattern"].includes(t)) throw new Error(`Invalid memory type "${t}"; expected one of: rule, knowledge, decision, pattern`)
|
|
229
|
-
return t
|
|
230
|
-
}
|
|
229
|
+
// ─── 迁出面(2026-10-01 拆分批 · #755 ∥ #786):memory agent 工具面外提 `memory/memory-tool.mjs`——
|
|
230
|
+
// 经本档转口保名(消费链 = `memory.mjs → docs.mjs → memory-tool.mjs`;消费面零改)。──────────────
|
|
231
|
+
export { memoryTools } from "./memory-tool.mjs"
|
|
231
232
|
|
|
232
|
-
function normalizeLimit(limit, dflt) {
|
|
233
|
-
const n = Number(limit)
|
|
234
|
-
return Number.isFinite(n) && n > 0 ? Math.floor(n) : dflt
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
function fmtDate(ts) {
|
|
238
|
-
return ts ? new Date(ts).toISOString().slice(0, 10) : "?"
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
const listRowLine = (r) => `[${r.layer}] ${r.id} [${r.type}] ${r.title}(${fmtDate(r.ts)})`
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* Generate the memory agent tool — ONE `memory` tool with five actions (MEMORY.md §6 D-M1).
|
|
245
|
-
* search/list are read-only actions (planMode pass / no permission ask — dispatch classifies
|
|
246
|
-
* them action-level, same as subagent check/status); put keeps its side-effect permission
|
|
247
|
-
* gate; batch delete/clear gate on confirm:true + layer inside the tool (direct-delete
|
|
248
|
-
* ruling — the confirm parameter IS the gate) and stay non-readonly like the retired tools.
|
|
249
|
-
* opts: { cwd, projectDir, author, team: { dir, name } | null }
|
|
250
|
-
*/
|
|
251
|
-
export function memoryTools(memory, opts = {}) {
|
|
252
|
-
const projectDir = opts.projectDir ? (isAbsolute(opts.projectDir) ? opts.projectDir : join(opts.cwd ?? process.cwd(), opts.projectDir)) : null
|
|
253
|
-
// §6.11:dirs 保持原样(目录 I/O 基准)——归一落在各公共入口内(写缝 syncDir / indexMarkdownFile、
|
|
254
|
-
// 删缝 deleteByUid / matchMemoryRows、读缝 search / fetchEntry)⇒ 逐入口一行,非工具层预归一。
|
|
255
|
-
const dirs = { project: projectDir, team: opts.team?.dir ?? null }
|
|
256
|
-
return [
|
|
257
|
-
{
|
|
258
|
-
name: "memory",
|
|
259
|
-
description: MEMORY_TOOL_DESCRIPTION,
|
|
260
|
-
parameters: {
|
|
261
|
-
type: "object",
|
|
262
|
-
properties: {
|
|
263
|
-
action: { type: "string", enum: MEMORY_ACTIONS, description: "Operation to run (required)" },
|
|
264
|
-
layer: { type: "string", enum: MEMORY_LAYERS, description: "The memory layer: personal (private), project (shared via this repo's .thincoder/memory/), team (CLI only). Same concept as the [layer] tag and the id prefix on search/list result rows. put/search/list: optional (put defaults to personal; search/list omit = all layers). single delete: optional (omit = route by id prefix). batch delete/clear: required" },
|
|
265
|
-
type: { type: "string", enum: ["rule", "knowledge", "decision", "pattern"], description: "Entry type: put = what to save; list/delete batch = filter by type" },
|
|
266
|
-
title: { type: "string", description: "put: short title" },
|
|
267
|
-
content: { type: "string", description: "put: full content to remember" },
|
|
268
|
-
tags: { type: "string", description: "put: space-separated tags" },
|
|
269
|
-
query: { type: "string", description: "search: natural-language query" },
|
|
270
|
-
keyword: { type: "string", description: "list/delete batch: filter matching title/content" },
|
|
271
|
-
id: { type: "string", description: "delete single: the entry id from put/search/list output" },
|
|
272
|
-
limit: { type: "number", description: "Max rows: list 50 by default, search 5 by default" },
|
|
273
|
-
confirm: { type: "boolean", description: "delete batch/clear: must be true — without it the tool refuses" },
|
|
274
|
-
},
|
|
275
|
-
required: ["action"],
|
|
276
|
-
},
|
|
277
|
-
readonly: false,
|
|
278
|
-
async execute(args) {
|
|
279
|
-
const action = String(args?.action ?? "")
|
|
280
|
-
if (!MEMORY_ACTIONS.includes(action)) {
|
|
281
|
-
throw new Error(`memory: unknown action "${action}" — expected one of: ${MEMORY_ACTIONS.join("/")}`)
|
|
282
|
-
}
|
|
283
|
-
switch (action) {
|
|
284
|
-
case "search": return execSearch(memory, args)
|
|
285
|
-
case "put": return execPut(memory, args, opts, dirs)
|
|
286
|
-
case "list": return execList(memory, args, dirs)
|
|
287
|
-
case "delete": return execDelete(memory, args, dirs)
|
|
288
|
-
case "clear": return execClear(memory, args)
|
|
289
|
-
}
|
|
290
|
-
},
|
|
291
|
-
},
|
|
292
|
-
]
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/** action search — the retired search tool surface (read-only, same output contract). */
|
|
296
|
-
async function execSearch(memory, args) {
|
|
297
|
-
const layer = args.layer
|
|
298
|
-
if (layer !== undefined && layer !== null && !MEMORY_LAYERS.includes(String(layer))) {
|
|
299
|
-
throw new Error(`memory search: invalid layer "${layer}"`)
|
|
300
|
-
}
|
|
301
|
-
const query = String(args.query ?? "").trim()
|
|
302
|
-
if (!query) return "(no matching memories)" // 空 query 短路——两端同语义(评审 code review #4)
|
|
303
|
-
const limit = normalizeLimit(args.limit, 5)
|
|
304
|
-
let results
|
|
305
|
-
if (!layer) {
|
|
306
|
-
results = await search(memory, query, { limit })
|
|
307
|
-
} else {
|
|
308
|
-
// layer filter: oversample then slice the requested layer (results keep global rank order).
|
|
309
|
-
// 窗口 = max(limit*4, 20) 是召回上限——大库 + 高 limit 时该层结果可能不足 limit(接受的取舍——评审 code review #3)
|
|
310
|
-
const wide = await search(memory, query, { limit: Math.max(limit * 4, 20) })
|
|
311
|
-
results = wide.filter((r) => r.layer === String(layer)).slice(0, limit)
|
|
312
|
-
}
|
|
313
|
-
if (results.length === 0) return "(no matching memories)"
|
|
314
|
-
return results.map((r) => `[${r.layer}][${r.type}] ${r.title} (id=${r.id})\n${r.content}`).join("\n\n")
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/** action put — the retired put tool surface (side-effect gate, unchanged semantics). */
|
|
318
|
-
async function execPut(memory, args, opts, dirs) {
|
|
319
|
-
const layer = String(args.layer ?? "personal")
|
|
320
|
-
if (!MEMORY_LAYERS.includes(layer)) throw new Error(`memory put: invalid layer "${layer}"`)
|
|
321
|
-
if (layer === "personal") {
|
|
322
|
-
const id = await put(memory, { type: args.type, title: args.title, content: args.content, tags: args.tags ?? "" })
|
|
323
|
-
return `Saved to personal memory (id=personal:${id}): [${args.type}] ${args.title}`
|
|
324
|
-
}
|
|
325
|
-
if (layer === "project") {
|
|
326
|
-
if (!dirs.project) throw new Error("project layer unavailable: no project directory configured")
|
|
327
|
-
const filename = await putMarkdown(memory, {
|
|
328
|
-
layer: "project",
|
|
329
|
-
dir: dirs.project,
|
|
330
|
-
type: args.type,
|
|
331
|
-
title: args.title,
|
|
332
|
-
content: args.content,
|
|
333
|
-
tags: (args.tags ?? "").split(/\s+/).filter(Boolean),
|
|
334
|
-
author: opts.author ?? "unknown",
|
|
335
|
-
})
|
|
336
|
-
return `Saved to project memory (id=project:${dirs.project}:${filename}): [${args.type}] ${args.title}`
|
|
337
|
-
}
|
|
338
|
-
if (!dirs.team) {
|
|
339
|
-
throw new Error("team layer not configured: set memory.team in ~/.thincoder/config.json")
|
|
340
|
-
}
|
|
341
|
-
const filename = await putMarkdown(memory, {
|
|
342
|
-
layer: "team",
|
|
343
|
-
dir: dirs.team,
|
|
344
|
-
type: args.type,
|
|
345
|
-
title: args.title,
|
|
346
|
-
content: args.content,
|
|
347
|
-
tags: (args.tags ?? "").split(/\s+/).filter(Boolean),
|
|
348
|
-
author: opts.author ?? "unknown",
|
|
349
|
-
})
|
|
350
|
-
await commitAndPush(dirs.team, filename, `memory: [${args.type}] ${args.title}`)
|
|
351
|
-
return `Saved to team memory and pushed (id=team:${dirs.team}:${filename}): [${args.type}] ${args.title}`
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
/** action list — new inventory action (read-only): layer/type/keyword filters + limit truncation note. */
|
|
355
|
-
async function execList(memory, args, dirs) {
|
|
356
|
-
const layer = args.layer ?? null
|
|
357
|
-
if (layer && !MEMORY_LAYERS.includes(String(layer))) throw new Error(`memory list: invalid layer "${layer}"`)
|
|
358
|
-
const rows = await matchMemoryRows(memory, {
|
|
359
|
-
layer: layer ? String(layer) : null,
|
|
360
|
-
type: validateTypeFilter(args.type),
|
|
361
|
-
keyword: args.keyword ? String(args.keyword).trim() : null,
|
|
362
|
-
projectDir: dirs.project,
|
|
363
|
-
teamDir: dirs.team,
|
|
364
|
-
})
|
|
365
|
-
if (rows.length === 0) return "0 条匹配"
|
|
366
|
-
const limit = normalizeLimit(args.limit, 50)
|
|
367
|
-
const shown = rows.slice(0, limit)
|
|
368
|
-
const lines = shown.map(listRowLine)
|
|
369
|
-
if (rows.length > shown.length) lines.unshift(`${shown.length} 条——截断前 ${rows.length}`)
|
|
370
|
-
return lines.join("\n")
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
/** action delete — single ({ id, layer? } — MEMORY.md §6.2: layer OPTIONAL, validated when
|
|
374
|
-
* passed, else the id prefix routes the delete) + batch (layer + type/keyword + confirm). */
|
|
375
|
-
async function execDelete(memory, args, dirs) {
|
|
376
|
-
const hasId = args.id !== undefined && args.id !== null && String(args.id) !== ""
|
|
377
|
-
if (hasId) return execDeleteSingle(memory, args, dirs)
|
|
378
|
-
// batch form
|
|
379
|
-
const layer = args.layer
|
|
380
|
-
if (!layer) throw new Error("batch delete requires layer plus type and/or keyword filter")
|
|
381
|
-
if (!MEMORY_LAYERS.includes(String(layer))) throw new Error(`memory delete: invalid layer "${layer}"`)
|
|
382
|
-
const type = validateTypeFilter(args.type)
|
|
383
|
-
const keyword = args.keyword ? String(args.keyword).trim() : null
|
|
384
|
-
if (!type && !keyword) {
|
|
385
|
-
throw new Error("batch delete requires type and/or keyword filter — a layer-wide wipe without filters is refused (personal full wipe is the clear action)")
|
|
386
|
-
}
|
|
387
|
-
if (layer === "project" && !dirs.project) throw new Error("project layer unavailable: no project directory configured")
|
|
388
|
-
if (layer === "team" && !dirs.team) throw new Error("team layer not configured: set memory.team in ~/.thincoder/config.json")
|
|
389
|
-
const filters = { layer: String(layer), type, keyword }
|
|
390
|
-
const rows = await matchMemoryRows(memory, { ...filters, projectDir: dirs.project, teamDir: dirs.team })
|
|
391
|
-
if (rows.length === 0) return "0 条匹配"
|
|
392
|
-
if (args.confirm !== true) {
|
|
393
|
-
const lines = [rows.length > 5 ? `将删 ${rows.length} 条:前 5 条预览` : `将删 ${rows.length} 条`]
|
|
394
|
-
lines.push(...rows.slice(0, 5).map(listRowLine))
|
|
395
|
-
if (rows.length > 5) lines.push(`5 条——截断前 ${rows.length}`)
|
|
396
|
-
lines.push("confirm:true required — re-send with it to execute the deletion")
|
|
397
|
-
return lines.join("\n")
|
|
398
|
-
}
|
|
399
|
-
const n = await deleteWhere(memory, filters, { dirs })
|
|
400
|
-
return `Deleted ${n} entries in layer ${layer}`
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
/** Single-entry delete — MEMORY.md §6.2: layer is OPTIONAL. When passed it is validated
|
|
404
|
-
* against the id prefix (mismatch refused — guards against deleting the wrong entry); when
|
|
405
|
-
* omitted the delete routes by the id prefix alone, so any id search/list returned is
|
|
406
|
-
* directly deletable (deleteByUid already resolves the layer from the uid prefix). */
|
|
407
|
-
async function execDeleteSingle(memory, args, dirs) {
|
|
408
|
-
const uid = String(args.id)
|
|
409
|
-
const prefix = uid.split(":")[0]
|
|
410
|
-
const uidLayer = prefix === "personal" || prefix === "project" || prefix === "team" ? prefix : /^\d+$/.test(prefix) ? "personal" : null
|
|
411
|
-
if (!uidLayer) throw new Error(`invalid memory id: ${uid}`)
|
|
412
|
-
const layer = args.layer
|
|
413
|
-
if (layer !== undefined && layer !== null && String(layer) !== uidLayer) {
|
|
414
|
-
throw new Error(`id prefix ${prefix}: 与 layer ${layer} 不匹配`)
|
|
415
|
-
}
|
|
416
|
-
const entry = await deleteByUid(memory, uid, { dirs })
|
|
417
|
-
return `Deleted ${entry.id}: ${entry.title}\n${(entry.content ?? "").slice(0, 500)}`
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
/** action clear — personal-only full wipe (layer + confirm:true gates; project/team refused). */
|
|
421
|
-
function execClear(memory, args) {
|
|
422
|
-
const layer = args.layer
|
|
423
|
-
if (!layer) throw new Error('clear requires layer "personal" — pass layer: "personal" plus confirm: true')
|
|
424
|
-
if (String(layer) !== "personal") {
|
|
425
|
-
if (!MEMORY_LAYERS.includes(String(layer))) throw new Error(`memory clear: invalid layer "${layer}"`)
|
|
426
|
-
throw new Error("shared layers don't support clear — use delete with type/keyword batch filters instead")
|
|
427
|
-
}
|
|
428
|
-
if (args.confirm !== true) throw new Error("clear requires confirm:true — this wipes ALL personal memory")
|
|
429
|
-
const n = clearPersonal(memory)
|
|
430
|
-
return `Cleared personal memory (${n} entries deleted)`
|
|
431
|
-
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory/file-list.mjs — 项目文件清单面(自 `memory/code-sync.mjs` 迁出 · 2026-10-01 core 拆分批 #755 ∥ #786)。
|
|
3
|
+
* 内容 = `indexExtensions`(内置扩展名表 ∪ 项目声明并集)∥ `listProjectFiles`(git 列面 + 非 git walk
|
|
4
|
+
* 回退 + unlisted 计数;`index.excludePaths` §6.14 面① 双径过滤:git 列面筛行 ∥ walk 剪枝)——迁出块逐字。
|
|
5
|
+
* 原档 `memory/code-sync.mjs` 经 `export { … } from` 转口保名(消费面 / 批内件 import 面零改)。
|
|
6
|
+
*/
|
|
7
|
+
import { CODE_EXTS, DOC_EXTS } from "./schema.mjs"
|
|
8
|
+
import { walkProjectFiles, isSkippedRelPath, extensionOf, createUnlistedTally, MAX_WALK_FILES } from "./file-walk.mjs"
|
|
9
|
+
import { loadProjectDeclaration, isExcludedRelPath } from "../conventions.mjs"
|
|
10
|
+
import { logEvent } from "../log.mjs"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Index extension sets = built-in tables ∪ project declaration
|
|
14
|
+
* (`PROJECT-MANIFEST.json` → index.codeExtensions / index.docExtensions;
|
|
15
|
+
* PORTABILITY PO-9/§3.7 — a project may declare extensions this product does not
|
|
16
|
+
* ship a default for). Declaration only ADDS (union), never removes.
|
|
17
|
+
*/
|
|
18
|
+
export function indexExtensions(dir) {
|
|
19
|
+
const conv = loadProjectDeclaration(dir)
|
|
20
|
+
return {
|
|
21
|
+
code: new Set([...CODE_EXTS, ...conv.index.codeExtensions]),
|
|
22
|
+
doc: new Set([...DOC_EXTS, ...conv.index.docExtensions]),
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* List project files matching the given extensions.
|
|
28
|
+
* Preferred source = git (`git ls-files --cached --others --exclude-standard`:
|
|
29
|
+
* tracked + untracked-not-ignored, .gitignore respected). Projects WITHOUT git
|
|
30
|
+
* fall back to a filesystem walk (PORTABILITY FR15/P8) — before, the index was
|
|
31
|
+
* silently empty there. The walk cannot honour .gitignore (no git → no such
|
|
32
|
+
* concept) and skips the shared SKIP_DIRS/dot-directory set instead.
|
|
33
|
+
* Returns { entries, unlisted }: `entries` = { abs, rel } pairs (rel relative to
|
|
34
|
+
* dir); `unlisted` = files whose extension is in NO index list (count + sample),
|
|
35
|
+
* the visible signal behind "my .xyz files are not searchable" (PORTABILITY PO-9).
|
|
36
|
+
* Declared `index.excludePaths` (§6.14 面①) filters BOTH paths — git listing filters the
|
|
37
|
+
* line set; the walk prunes excluded subtrees during traversal (no expansion, no budget).
|
|
38
|
+
* `truncated` = the non-git walk hit its file cap (capped trees are logged, never
|
|
39
|
+
* silently indexed-partial). opts.maxFiles = the walk cap (test seam).
|
|
40
|
+
*/
|
|
41
|
+
export async function listProjectFiles(dir, exts, { maxFiles } = {}) {
|
|
42
|
+
const { execFile: _execFile } = await import("node:child_process")
|
|
43
|
+
const { join: joinPath } = await import("node:path")
|
|
44
|
+
const { code, doc } = indexExtensions(dir)
|
|
45
|
+
const decl = loadProjectDeclaration(dir)
|
|
46
|
+
const excluded = (rel) => isExcludedRelPath(rel, decl, dir)
|
|
47
|
+
const tally = createUnlistedTally(new Set([...code, ...doc]))
|
|
48
|
+
|
|
49
|
+
const files = []
|
|
50
|
+
const finish = (truncated = false) => ({ entries: files, unlisted: tally.result(), truncated })
|
|
51
|
+
|
|
52
|
+
// Git listing when available; a non-git project falls through to the walk.
|
|
53
|
+
let gitTop
|
|
54
|
+
try {
|
|
55
|
+
gitTop = (await new Promise((resolve, reject) => {
|
|
56
|
+
_execFile("git", ["rev-parse", "--show-toplevel"], { cwd: dir, encoding: "utf8", timeout: 5000, windowsHide: true },
|
|
57
|
+
(err, stdout) => { if (err) reject(err); else resolve(stdout.trim()) })
|
|
58
|
+
})).replace(/\\/g, "/")
|
|
59
|
+
} catch {
|
|
60
|
+
// Not a git repo → filesystem walk (FR15: the index must still be usable).
|
|
61
|
+
const walked = await walkProjectFiles(dir, exts, { knownExts: new Set([...code, ...doc]), isExcluded: excluded, ...(maxFiles !== undefined ? { maxFiles } : {}) })
|
|
62
|
+
files.push(...walked.files)
|
|
63
|
+
// The walk's cap must stay visible end-to-end (file-walk.mjs: "never silently
|
|
64
|
+
// dropped") — a capped listing says so in the log, not only in a discarded flag.
|
|
65
|
+
if (walked.truncated) {
|
|
66
|
+
logEvent("index:truncated", { dir, files: walked.files.length, maxFiles: maxFiles ?? MAX_WALK_FILES })
|
|
67
|
+
}
|
|
68
|
+
return { entries: files, unlisted: walked.unlisted, truncated: walked.truncated }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const raw = await new Promise((resolve, reject) => {
|
|
73
|
+
_execFile("git", ["ls-files", "--cached", "--others", "--exclude-standard"],
|
|
74
|
+
{ cwd: dir, encoding: "utf8", timeout: 15000, windowsHide: true, maxBuffer: 10 * 1024 * 1024 },
|
|
75
|
+
(err, stdout) => { if (err) reject(err); else resolve(stdout) })
|
|
76
|
+
})
|
|
77
|
+
for (const line of raw.trim().split("\n")) {
|
|
78
|
+
const p = line.trim()
|
|
79
|
+
if (!p) continue
|
|
80
|
+
const rel = p.replace(/\\/g, "/")
|
|
81
|
+
if (isSkippedRelPath(rel) || excluded(rel)) continue
|
|
82
|
+
const ext = extensionOf(rel)
|
|
83
|
+
if (!ext || !exts.has(ext)) { tally.note(rel); continue }
|
|
84
|
+
files.push({ abs: joinPath(dir, rel), rel })
|
|
85
|
+
}
|
|
86
|
+
} catch { /* ls-files failed */ }
|
|
87
|
+
|
|
88
|
+
return finish()
|
|
89
|
+
}
|
package/memory/file-walk.mjs
CHANGED
|
@@ -68,12 +68,14 @@ export function createUnlistedTally(knownExts) {
|
|
|
68
68
|
* silently dropped.
|
|
69
69
|
* @param {string} dir — project root
|
|
70
70
|
* @param {Set<string>} exts — extensions to keep (lower-case, leading dot)
|
|
71
|
-
* @param {{maxFiles?: number, knownExts?: Set<string>|null}} [opts]
|
|
71
|
+
* @param {{maxFiles?: number, knownExts?: Set<string>|null, isExcluded?: ((rel: string) => boolean)|null}} [opts]
|
|
72
72
|
* knownExts → also tally files whose extension is in NO index list
|
|
73
|
+
* isExcluded → caller-injected exclusion predicate (§6.14 面① 剪枝形): a hit prunes the
|
|
74
|
+
* subtree (never expanded — zero readdir, no MAX_WALK_FILES budget spent) or drops the file
|
|
73
75
|
* @returns {Promise<{files: {abs: string, rel: string}[], truncated: boolean,
|
|
74
76
|
* unlisted: {count: number, exts: {ext: string, count: number}[]}}>}
|
|
75
77
|
*/
|
|
76
|
-
export async function walkProjectFiles(dir, exts, { maxFiles = MAX_WALK_FILES, knownExts = null } = {}) {
|
|
78
|
+
export async function walkProjectFiles(dir, exts, { maxFiles = MAX_WALK_FILES, knownExts = null, isExcluded = null } = {}) {
|
|
77
79
|
const files = []
|
|
78
80
|
const tally = knownExts ? createUnlistedTally(knownExts) : null
|
|
79
81
|
let truncated = false
|
|
@@ -88,10 +90,11 @@ export async function walkProjectFiles(dir, exts, { maxFiles = MAX_WALK_FILES, k
|
|
|
88
90
|
const rel = cur.rel ? `${cur.rel}/${ent.name}` : ent.name
|
|
89
91
|
if (ent.isSymbolicLink()) continue // never follow symlinks
|
|
90
92
|
if (ent.isDirectory()) {
|
|
91
|
-
|
|
93
|
+
// §6.14 面① 剪枝:排除子树零展开(不耗 MAX_WALK_FILES 预算)
|
|
94
|
+
if (!isSkippedRelPath(rel) && !isExcluded?.(rel)) stack.push({ abs: join(cur.abs, ent.name), rel })
|
|
92
95
|
continue
|
|
93
96
|
}
|
|
94
|
-
if (!ent.isFile() || isSkippedRelPath(rel)) continue
|
|
97
|
+
if (!ent.isFile() || isSkippedRelPath(rel) || isExcluded?.(rel)) continue
|
|
95
98
|
const ext = extensionOf(ent.name)
|
|
96
99
|
if (!ext || !exts.has(ext)) {
|
|
97
100
|
tally?.note(rel)
|