@thincoder/core 0.9.4 → 0.10.1
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 +63 -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 +14 -14
- package/advisor/messages.mjs +6 -6
- package/advisor/project-context.mjs +5 -5
- package/advisor/repos.mjs +3 -3
- package/advisor/run.mjs +23 -13
- 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 +55 -294
- package/agent/family-tools.mjs +11 -11
- package/agent/helpers.mjs +63 -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 +19 -15
- 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 +90 -30
- 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 +11 -11
- package/agent-tools/advisor-settle.mjs +17 -17
- package/agent-tools/advisor.mjs +38 -43
- package/agent-tools/async-discard.mjs +44 -1
- package/agent-tools/async-settle.mjs +23 -20
- package/agent-tools/audit-block.mjs +7 -7
- package/agent-tools/bash-async.mjs +281 -0
- package/agent-tools/batch-lifecycle.mjs +65 -34
- package/agent-tools/batch-paths.mjs +186 -0
- package/agent-tools/batch-skeleton.mjs +19 -1
- package/agent-tools/batch.mjs +22 -119
- package/agent-tools/checkpoint.mjs +121 -0
- package/agent-tools/child-permission.mjs +5 -3
- package/agent-tools/consult.mjs +58 -58
- 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 +18 -13
- package/agent-tools/goal.mjs +3 -10
- package/agent-tools/panel-blocks.mjs +3 -3
- 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 +29 -273
- package/agent-tools/subagent-async.mjs +36 -31
- package/agent-tools/subagent-panel.mjs +38 -9
- package/agent-tools/subagent-run.mjs +44 -29
- package/agent-tools/subagent-scheduler.mjs +29 -28
- package/agent-tools/subagent-spawn.mjs +57 -25
- package/agent-tools/subagent.mjs +46 -66
- 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 +20 -351
- package/attachments.mjs +125 -0
- package/auto-think.mjs +1 -1
- package/config-presets.mjs +12 -7
- package/config-watch.mjs +83 -0
- package/config.mjs +17 -4
- 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/git/checkpoint.mjs +4 -8
- 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 +35 -7
- package/ledger-migrate.mjs +299 -0
- package/ledger-tools.mjs +188 -0
- package/ledger.mjs +11 -5
- 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-specs.mjs +131 -66
- package/notify-policy.mjs +51 -0
- package/package.json +1 -1
- package/peer-claims.mjs +263 -0
- package/peer-domains.mjs +100 -71
- 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 +19 -7
- 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 +1 -2
- package/prompts/persona-engineering.md +41 -11
- package/prompts/persona-explore.md +0 -1
- package/prompts/persona-normal.md +10 -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-gc.mjs +67 -6
- package/session-guard.mjs +27 -7
- package/session-lifecycle.mjs +90 -18
- 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 -236
- 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/question.md +1 -0
- 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 +10 -11
- package/tools/edit-diff.mjs +33 -25
- package/tools/exec-run.mjs +102 -4
- package/tools/execute.mjs +5 -2
- package/tools/file.mjs +11 -12
- package/tools/git-run.mjs +1 -1
- package/tools/git.mjs +0 -24
- package/tools/glob-dialect.mjs +3 -3
- 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/search.mjs +2 -2
- package/tools/shared.mjs +9 -7
- 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
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory/memory-tool.mjs — memory agent 工具面(自 `memory/docs.mjs` 迁出 · 2026-10-01 core 拆分批 #755 ∥ #786)。
|
|
3
|
+
* 内容 = `MEMORY_ACTIONS` ∥ `MEMORY_LAYERS` ∥ 校验 / 格式化小件 ∥ `memoryTools`(单一 `memory` 工具,
|
|
4
|
+
* 五 action)+ 各 action 执行器(`execSearch` / `execPut` / `execList` / `execDelete` / `execDeleteSingle`
|
|
5
|
+
* / `execClear`)——迁出块逐字;原档 `memory/docs.mjs` 经 `export { … } from` 转口保名
|
|
6
|
+
* (消费链 `memory.mjs → docs.mjs → memory-tool.mjs` 零改)。
|
|
7
|
+
*/
|
|
8
|
+
import { isAbsolute, join } from "node:path"
|
|
9
|
+
import { put, search, putMarkdown, clearPersonal } from "./core.mjs"
|
|
10
|
+
import { deleteByUid, matchMemoryRows, deleteWhere } from "./delete.mjs"
|
|
11
|
+
import { commitAndPush } from "../git/gitmem.mjs"
|
|
12
|
+
import { DESC } from "../tools/shared.mjs" // #15 描述外置:文本单点 = tool-docs/memory.md
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------- agent tools
|
|
15
|
+
|
|
16
|
+
/** §6 shared tool surface — action enum / parameter shapes / output contract shared with the
|
|
17
|
+
* VS Code face (`thincoder-vscode/src/memory-tool.mjs`); the description is a per-end form —
|
|
18
|
+
* core text lives in `tool-docs/memory.md` (DESC() load; #15 外置). Layer VALUES per end
|
|
19
|
+
* (VS Code has no team layer and rejects it with CLI guidance). */
|
|
20
|
+
const MEMORY_ACTIONS = ["search", "put", "list", "delete", "clear"]
|
|
21
|
+
const MEMORY_LAYERS = ["personal", "project", "team"]
|
|
22
|
+
|
|
23
|
+
function validateTypeFilter(type) {
|
|
24
|
+
if (type === undefined || type === null || type === "") return null
|
|
25
|
+
const t = String(type)
|
|
26
|
+
if (!["rule", "knowledge", "decision", "pattern"].includes(t)) throw new Error(`Invalid memory type "${t}"; expected one of: rule, knowledge, decision, pattern`)
|
|
27
|
+
return t
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalizeLimit(limit, dflt) {
|
|
31
|
+
const n = Number(limit)
|
|
32
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : dflt
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function fmtDate(ts) {
|
|
36
|
+
return ts ? new Date(ts).toISOString().slice(0, 10) : "?"
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const listRowLine = (r) => `[${r.layer}] ${r.id} [${r.type}] ${r.title}(${fmtDate(r.ts)})`
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Generate the memory agent tool — ONE `memory` tool with five actions (MEMORY.md §6 D-M1).
|
|
43
|
+
* search/list are read-only actions (planMode pass / no permission ask — dispatch classifies
|
|
44
|
+
* them action-level, same as subagent check/status); put keeps its side-effect permission
|
|
45
|
+
* gate; batch delete/clear gate on confirm:true + layer inside the tool (direct-delete
|
|
46
|
+
* ruling — the confirm parameter IS the gate) and stay non-readonly like the retired tools.
|
|
47
|
+
* opts: { cwd, projectDir, author, team: { dir, name } | null }
|
|
48
|
+
*/
|
|
49
|
+
export function memoryTools(memory, opts = {}) {
|
|
50
|
+
const projectDir = opts.projectDir ? (isAbsolute(opts.projectDir) ? opts.projectDir : join(opts.cwd ?? process.cwd(), opts.projectDir)) : null
|
|
51
|
+
// §6.11:dirs 保持原样(目录 I/O 基准)——归一落在各公共入口内(写缝 syncDir / indexMarkdownFile、
|
|
52
|
+
// 删缝 deleteByUid / matchMemoryRows、读缝 search / fetchEntry)⇒ 逐入口一行,非工具层预归一。
|
|
53
|
+
const dirs = { project: projectDir, team: opts.team?.dir ?? null }
|
|
54
|
+
return [
|
|
55
|
+
{
|
|
56
|
+
name: "memory",
|
|
57
|
+
description: DESC("memory"), // #15 外置:文本单点 = tool-docs/memory.md
|
|
58
|
+
parameters: {
|
|
59
|
+
type: "object",
|
|
60
|
+
properties: {
|
|
61
|
+
action: { type: "string", enum: MEMORY_ACTIONS, description: "Operation to run (required)" },
|
|
62
|
+
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" },
|
|
63
|
+
type: { type: "string", enum: ["rule", "knowledge", "decision", "pattern"], description: "Entry type: put = what to save; list/delete batch = filter by type" },
|
|
64
|
+
title: { type: "string", description: "put: short title" },
|
|
65
|
+
content: { type: "string", description: "put: full content to remember" },
|
|
66
|
+
tags: { type: "string", description: "put: space-separated tags" },
|
|
67
|
+
query: { type: "string", description: "search: natural-language query" },
|
|
68
|
+
keyword: { type: "string", description: "list/delete batch: filter matching title/content" },
|
|
69
|
+
id: { type: "string", description: "delete single: the entry id from put/search/list output" },
|
|
70
|
+
limit: { type: "number", description: "Max rows: list 50 by default, search 5 by default" },
|
|
71
|
+
confirm: { type: "boolean", description: "delete batch/clear: must be true — without it the tool refuses" },
|
|
72
|
+
},
|
|
73
|
+
required: ["action"],
|
|
74
|
+
},
|
|
75
|
+
readonly: false,
|
|
76
|
+
async execute(args) {
|
|
77
|
+
const action = String(args?.action ?? "")
|
|
78
|
+
if (!MEMORY_ACTIONS.includes(action)) {
|
|
79
|
+
throw new Error(`memory: unknown action "${action}" — expected one of: ${MEMORY_ACTIONS.join("/")}`)
|
|
80
|
+
}
|
|
81
|
+
switch (action) {
|
|
82
|
+
case "search": return execSearch(memory, args)
|
|
83
|
+
case "put": return execPut(memory, args, opts, dirs)
|
|
84
|
+
case "list": return execList(memory, args, dirs)
|
|
85
|
+
case "delete": return execDelete(memory, args, dirs)
|
|
86
|
+
case "clear": return execClear(memory, args)
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** action search — the retired search tool surface (read-only, same output contract). */
|
|
94
|
+
async function execSearch(memory, args) {
|
|
95
|
+
const layer = args.layer
|
|
96
|
+
if (layer !== undefined && layer !== null && !MEMORY_LAYERS.includes(String(layer))) {
|
|
97
|
+
throw new Error(`memory search: invalid layer "${layer}"`)
|
|
98
|
+
}
|
|
99
|
+
const query = String(args.query ?? "").trim()
|
|
100
|
+
if (!query) return "(no matching memories)" // 空 query 短路——两端同语义(评审 code review #4)
|
|
101
|
+
const limit = normalizeLimit(args.limit, 5)
|
|
102
|
+
let results
|
|
103
|
+
if (!layer) {
|
|
104
|
+
results = await search(memory, query, { limit })
|
|
105
|
+
} else {
|
|
106
|
+
// layer filter: oversample then slice the requested layer (results keep global rank order).
|
|
107
|
+
// 窗口 = max(limit*4, 20) 是召回上限——大库 + 高 limit 时该层结果可能不足 limit(接受的取舍——评审 code review #3)
|
|
108
|
+
const wide = await search(memory, query, { limit: Math.max(limit * 4, 20) })
|
|
109
|
+
results = wide.filter((r) => r.layer === String(layer)).slice(0, limit)
|
|
110
|
+
}
|
|
111
|
+
if (results.length === 0) return "(no matching memories)"
|
|
112
|
+
return results.map((r) => `[${r.layer}][${r.type}] ${r.title} (id=${r.id})\n${r.content}`).join("\n\n")
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** action put — the retired put tool surface (side-effect gate, unchanged semantics). */
|
|
116
|
+
async function execPut(memory, args, opts, dirs) {
|
|
117
|
+
const layer = String(args.layer ?? "personal")
|
|
118
|
+
if (!MEMORY_LAYERS.includes(layer)) throw new Error(`memory put: invalid layer "${layer}"`)
|
|
119
|
+
if (layer === "personal") {
|
|
120
|
+
const id = await put(memory, { type: args.type, title: args.title, content: args.content, tags: args.tags ?? "" })
|
|
121
|
+
return `Saved to personal memory (id=personal:${id}): [${args.type}] ${args.title}`
|
|
122
|
+
}
|
|
123
|
+
if (layer === "project") {
|
|
124
|
+
if (!dirs.project) throw new Error("project layer unavailable: no project directory configured")
|
|
125
|
+
const filename = await putMarkdown(memory, {
|
|
126
|
+
layer: "project",
|
|
127
|
+
dir: dirs.project,
|
|
128
|
+
type: args.type,
|
|
129
|
+
title: args.title,
|
|
130
|
+
content: args.content,
|
|
131
|
+
tags: (args.tags ?? "").split(/\s+/).filter(Boolean),
|
|
132
|
+
author: opts.author ?? "unknown",
|
|
133
|
+
})
|
|
134
|
+
return `Saved to project memory (id=project:${dirs.project}:${filename}): [${args.type}] ${args.title}`
|
|
135
|
+
}
|
|
136
|
+
if (!dirs.team) {
|
|
137
|
+
throw new Error("team layer not configured: set memory.team in ~/.thincoder/config.json")
|
|
138
|
+
}
|
|
139
|
+
const filename = await putMarkdown(memory, {
|
|
140
|
+
layer: "team",
|
|
141
|
+
dir: dirs.team,
|
|
142
|
+
type: args.type,
|
|
143
|
+
title: args.title,
|
|
144
|
+
content: args.content,
|
|
145
|
+
tags: (args.tags ?? "").split(/\s+/).filter(Boolean),
|
|
146
|
+
author: opts.author ?? "unknown",
|
|
147
|
+
})
|
|
148
|
+
await commitAndPush(dirs.team, filename, `memory: [${args.type}] ${args.title}`)
|
|
149
|
+
return `Saved to team memory and pushed (id=team:${dirs.team}:${filename}): [${args.type}] ${args.title}`
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** action list — new inventory action (read-only): layer/type/keyword filters + limit truncation note. */
|
|
153
|
+
async function execList(memory, args, dirs) {
|
|
154
|
+
const layer = args.layer ?? null
|
|
155
|
+
if (layer && !MEMORY_LAYERS.includes(String(layer))) throw new Error(`memory list: invalid layer "${layer}"`)
|
|
156
|
+
const rows = await matchMemoryRows(memory, {
|
|
157
|
+
layer: layer ? String(layer) : null,
|
|
158
|
+
type: validateTypeFilter(args.type),
|
|
159
|
+
keyword: args.keyword ? String(args.keyword).trim() : null,
|
|
160
|
+
projectDir: dirs.project,
|
|
161
|
+
teamDir: dirs.team,
|
|
162
|
+
})
|
|
163
|
+
if (rows.length === 0) return "0 条匹配"
|
|
164
|
+
const limit = normalizeLimit(args.limit, 50)
|
|
165
|
+
const shown = rows.slice(0, limit)
|
|
166
|
+
const lines = shown.map(listRowLine)
|
|
167
|
+
if (rows.length > shown.length) lines.unshift(`${shown.length} 条——截断前 ${rows.length}`)
|
|
168
|
+
return lines.join("\n")
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** action delete — single ({ id, layer? } — MEMORY.md §6.2: layer OPTIONAL, validated when
|
|
172
|
+
* passed, else the id prefix routes the delete) + batch (layer + type/keyword + confirm). */
|
|
173
|
+
async function execDelete(memory, args, dirs) {
|
|
174
|
+
const hasId = args.id !== undefined && args.id !== null && String(args.id) !== ""
|
|
175
|
+
if (hasId) return execDeleteSingle(memory, args, dirs)
|
|
176
|
+
// batch form
|
|
177
|
+
const layer = args.layer
|
|
178
|
+
if (!layer) throw new Error("batch delete requires layer plus type and/or keyword filter")
|
|
179
|
+
if (!MEMORY_LAYERS.includes(String(layer))) throw new Error(`memory delete: invalid layer "${layer}"`)
|
|
180
|
+
const type = validateTypeFilter(args.type)
|
|
181
|
+
const keyword = args.keyword ? String(args.keyword).trim() : null
|
|
182
|
+
if (!type && !keyword) {
|
|
183
|
+
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)")
|
|
184
|
+
}
|
|
185
|
+
if (layer === "project" && !dirs.project) throw new Error("project layer unavailable: no project directory configured")
|
|
186
|
+
if (layer === "team" && !dirs.team) throw new Error("team layer not configured: set memory.team in ~/.thincoder/config.json")
|
|
187
|
+
const filters = { layer: String(layer), type, keyword }
|
|
188
|
+
const rows = await matchMemoryRows(memory, { ...filters, projectDir: dirs.project, teamDir: dirs.team })
|
|
189
|
+
if (rows.length === 0) return "0 条匹配"
|
|
190
|
+
if (args.confirm !== true) {
|
|
191
|
+
const lines = [rows.length > 5 ? `将删 ${rows.length} 条:前 5 条预览` : `将删 ${rows.length} 条`]
|
|
192
|
+
lines.push(...rows.slice(0, 5).map(listRowLine))
|
|
193
|
+
if (rows.length > 5) lines.push(`5 条——截断前 ${rows.length}`)
|
|
194
|
+
lines.push("confirm:true required — re-send with it to execute the deletion")
|
|
195
|
+
return lines.join("\n")
|
|
196
|
+
}
|
|
197
|
+
const n = await deleteWhere(memory, filters, { dirs })
|
|
198
|
+
return `Deleted ${n} entries in layer ${layer}`
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Single-entry delete — MEMORY.md §6.2: layer is OPTIONAL. When passed it is validated
|
|
202
|
+
* against the id prefix (mismatch refused — guards against deleting the wrong entry); when
|
|
203
|
+
* omitted the delete routes by the id prefix alone, so any id search/list returned is
|
|
204
|
+
* directly deletable (deleteByUid already resolves the layer from the uid prefix). */
|
|
205
|
+
async function execDeleteSingle(memory, args, dirs) {
|
|
206
|
+
const uid = String(args.id)
|
|
207
|
+
const prefix = uid.split(":")[0]
|
|
208
|
+
const uidLayer = prefix === "personal" || prefix === "project" || prefix === "team" ? prefix : /^\d+$/.test(prefix) ? "personal" : null
|
|
209
|
+
if (!uidLayer) throw new Error(`invalid memory id: ${uid}`)
|
|
210
|
+
const layer = args.layer
|
|
211
|
+
if (layer !== undefined && layer !== null && String(layer) !== uidLayer) {
|
|
212
|
+
throw new Error(`id prefix ${prefix}: 与 layer ${layer} 不匹配`)
|
|
213
|
+
}
|
|
214
|
+
const entry = await deleteByUid(memory, uid, { dirs })
|
|
215
|
+
return `Deleted ${entry.id}: ${entry.title}\n${(entry.content ?? "").slice(0, 500)}`
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** action clear — personal-only full wipe (layer + confirm:true gates; project/team refused). */
|
|
219
|
+
function execClear(memory, args) {
|
|
220
|
+
const layer = args.layer
|
|
221
|
+
if (!layer) throw new Error('clear requires layer "personal" — pass layer: "personal" plus confirm: true')
|
|
222
|
+
if (String(layer) !== "personal") {
|
|
223
|
+
if (!MEMORY_LAYERS.includes(String(layer))) throw new Error(`memory clear: invalid layer "${layer}"`)
|
|
224
|
+
throw new Error("shared layers don't support clear — use delete with type/keyword batch filters instead")
|
|
225
|
+
}
|
|
226
|
+
if (args.confirm !== true) throw new Error("clear requires confirm:true — this wipes ALL personal memory")
|
|
227
|
+
const n = clearPersonal(memory)
|
|
228
|
+
return `Cleared personal memory (${n} entries deleted)`
|
|
229
|
+
}
|
package/memory/schema.mjs
CHANGED
|
@@ -15,13 +15,18 @@ import { segmentCJK } from "../fts-text.mjs"
|
|
|
15
15
|
export { segmentCJK } from "../fts-text.mjs"
|
|
16
16
|
|
|
17
17
|
export const VALID_TYPES = new Set(["rule", "knowledge", "decision", "pattern"])
|
|
18
|
-
export const SCHEMA_VERSION =
|
|
18
|
+
export const SCHEMA_VERSION = 10
|
|
19
19
|
export const SQLITE_BUSY_TIMEOUT = 3000
|
|
20
20
|
/** WAL 回收后的文件截断上界(§6.12 修法①——防复胀;实测曾达 587 MB)。 */
|
|
21
21
|
export const WAL_SIZE_LIMIT_BYTES = 64 * 1024 * 1024
|
|
22
22
|
|
|
23
|
+
/** 索引行预算(§6.14 面③ P2——每 origin 库内行数 code+doc 合计):WARN 线出**一行**可见警示
|
|
24
|
+
* (≈0.5 s 趟量级);CAP 线起同步跳过后列文件(只停新增——存量行不失效)。B3 ∕ B4 复用 WARN 值。 */
|
|
25
|
+
export const INDEX_ORIGIN_ROW_WARN = 20000
|
|
26
|
+
export const INDEX_ORIGIN_ROW_CAP = 100000
|
|
27
|
+
|
|
23
28
|
// Code index: source file extensions. Curated DEFAULTS — a project can declare
|
|
24
|
-
// more (union) through .
|
|
29
|
+
// more (union) through PROJECT-MANIFEST.json → index.codeExtensions
|
|
25
30
|
// (PORTABILITY PO-9: an unlisted extension used to be invisible AND undeclarable).
|
|
26
31
|
export const CODE_EXTS = new Set([
|
|
27
32
|
".mjs", ".js", ".cjs", ".mts", ".cts", ".ts", ".tsx", ".jsx",
|
|
@@ -87,7 +92,7 @@ export function createMemory({ dbPath }) {
|
|
|
87
92
|
`)
|
|
88
93
|
|
|
89
94
|
migrate(db)
|
|
90
|
-
return { db }
|
|
95
|
+
return { db, dbPath }
|
|
91
96
|
}
|
|
92
97
|
|
|
93
98
|
/** Step-by-step migration by user_version. Single transaction — any step failure rolls back, no half-finished schema left behind. */
|
|
@@ -445,6 +450,16 @@ export function migrate(db) {
|
|
|
445
450
|
`)
|
|
446
451
|
db.exec(`PRAGMA user_version = 9`)
|
|
447
452
|
}
|
|
453
|
+
if (version < 10) {
|
|
454
|
+
// v10: 每回合 embedding 回填探针(B2)的部分索引——`WHERE embedding IS NULL` 改走 seek(原无索引全表扫)。
|
|
455
|
+
// 键列 = `embedding`(**`rowid` 不可作索引键列**——D-MEM22 本机实测 `no such column: rowid`)。
|
|
456
|
+
// 只增索引(无表结构 ∕ 行级变更);探针调用点 SQL 形不变(计划改走索引——预期 `SEARCH … USING INDEX`)。
|
|
457
|
+
db.exec(`
|
|
458
|
+
CREATE INDEX IF NOT EXISTS code_chunks_embedding_null ON code_chunks(embedding) WHERE embedding IS NULL;
|
|
459
|
+
CREATE INDEX IF NOT EXISTS doc_chunks_embedding_null ON doc_chunks(embedding) WHERE embedding IS NULL;
|
|
460
|
+
`)
|
|
461
|
+
db.exec(`PRAGMA user_version = 10`)
|
|
462
|
+
}
|
|
448
463
|
db.exec("COMMIT")
|
|
449
464
|
} catch (err) {
|
|
450
465
|
db.exec("ROLLBACK")
|
package/memory/sweep.mjs
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory/sweep.mjs — origin 级库治理(命令面 + 安全设计 · MEMORY.md §6.13 · 台账 #175 · 2026-09-25)。
|
|
3
|
+
*
|
|
4
|
+
* 命令面(CLI 壳 = `thincoder-cli/src/cli/memory-command.mjs`):`thincoder memory sweep [--origin <o>] [--path <sub>] [--dry-run|--confirm]`。
|
|
5
|
+
* 两档判据(单源 = §6.13——本档不另立规则):
|
|
6
|
+
* · **全扫(缺省)** = 信号 A 折叠(`normalizeOrigin(o) !== o` ⇒ 并入归一形,**不删**)+ 信号 B 删除
|
|
7
|
+
* (**树亡** = 原样 / 归一形两路径皆 ENOENT ⇒ 可删);
|
|
8
|
+
* · **`--origin` 档** = 靶向整档删除(显式点名 ≠ 树亡推断——不以树存活为判据、不走信号 A/B):
|
|
9
|
+
* 范围 = 三表内 `normalizeOrigin(origin) = normalizeOrigin(<o>)` 的全部行(非归一变体随删;活树亦可整删)。
|
|
10
|
+
* · **`--path` 档**(§6.14 面①-3 存量剪枝——须与 `--origin` 同用):删除范围收窄至 `path` 前缀命中行
|
|
11
|
+
* (恰等 ∨ 后随 `/`——与 `index.excludePaths` 同语义;code ∕ doc 两表,`files` 表不涉)。
|
|
12
|
+
* 安全三件(§6.13):① **备份前置**(`VACUUM INTO <dbPath>.sweep-backup-<UTC yyyymmddHHMMSS>`,
|
|
13
|
+
* 判据 = 存在 ∧ 大小 > 0 ∧ `PRAGMA integrity_check` = ok;目标已存在 / 判据不过 ⇒ **中止零写**);
|
|
14
|
+
* ② **干跑默认**(`--confirm` 才写;零写判据 = 库字节 / 行数不变);③ **审计**(逐 origin 动作 + 计数 +
|
|
15
|
+
* 折叠映射 + 备份路径 + 写后回读判据)。
|
|
16
|
+
* **折叠规则单源 = §6.11 步 2**(同归一键内 `(path, line_start)`(`files` = `(layer, path)`)恰一行——
|
|
17
|
+
* 保留 `mtime_ms` 最大者,并列取 `rowid` 最小者;FTS 由既有触发器随行同步)。
|
|
18
|
+
* 探针 fail-safe:**仅 ENOENT 判亡**,其余错误(权限 / 非法路径)⇒ 保留(先例 `session-stale.mjs:124-127`)。
|
|
19
|
+
* 执行分工:真实库写 = 父侧 ops;本档只在传入的库句柄上作业,写闸 = `confirm` 单一入参。
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync, statSync } from "node:fs"
|
|
22
|
+
import { DatabaseSync } from "node:sqlite"
|
|
23
|
+
import { normalizeOrigin } from "./origin.mjs"
|
|
24
|
+
|
|
25
|
+
/** 面表(§6.13 面表):三表 + 各表折叠唯一键(`files` 无 `line_start`——PK = (layer, origin, path))。 */
|
|
26
|
+
const TABLES = [
|
|
27
|
+
{ key: "code", table: "code_chunks", dup: ["path", "line_start"] },
|
|
28
|
+
{ key: "doc", table: "doc_chunks", dup: ["path", "line_start"] },
|
|
29
|
+
{ key: "files", table: "files", dup: ["layer", "path"] },
|
|
30
|
+
]
|
|
31
|
+
const KEYS = TABLES.map((t) => t.key)
|
|
32
|
+
const emptyCounts = () => ({ code: 0, doc: 0, files: 0 })
|
|
33
|
+
const totalOf = (c) => c.code + c.doc + c.files
|
|
34
|
+
|
|
35
|
+
/** 树存活探针(信号 B 单源):`statSync` 成功 ⇒ "alive";**仅 ENOENT** ⇒ "dead";其余错误 ⇒ "unknown"。 */
|
|
36
|
+
export function probeOriginPath(p) {
|
|
37
|
+
try { statSync(p); return "alive" } catch (e) { return e?.code === "ENOENT" ? "dead" : "unknown" }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 双路径树读数(原样 + 归一形):alive 优先;两路径皆死才判亡;任一探针错误 ⇒ unknown(保留)。 */
|
|
41
|
+
function treeState(o) {
|
|
42
|
+
const a = probeOriginPath(o), b = probeOriginPath(normalizeOrigin(o))
|
|
43
|
+
return { alive: a === "alive" || b === "alive", dead: a === "dead" && b === "dead", unknown: a === "unknown" || b === "unknown" }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 逐(原样 origin)行数:Map<origin, { code, doc, files }>——三表并集。 */
|
|
47
|
+
function originCounts(db) {
|
|
48
|
+
const map = new Map()
|
|
49
|
+
for (const { key, table } of TABLES) {
|
|
50
|
+
for (const r of db.prepare(`SELECT origin, COUNT(*) AS n FROM ${table} GROUP BY origin`).all()) {
|
|
51
|
+
const cur = map.get(r.origin) ?? emptyCounts()
|
|
52
|
+
cur[key] = Number(r.n)
|
|
53
|
+
map.set(r.origin, cur)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return map
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const ACTION_RANK = { delete: 0, fold: 1, keep: 2 }
|
|
60
|
+
|
|
61
|
+
/** `--path` 前缀归一(与 `index.excludePaths` 同款:trim ∕ `\`→`/` ∕ 首部 `./` 剥离 ∕ 去尾斜杠)。 */
|
|
62
|
+
function normalizePrefix(p) {
|
|
63
|
+
const s = String(p ?? "").trim().replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "")
|
|
64
|
+
if (!s) throw new Error("memory sweep: `--path` 需要一个非空前缀")
|
|
65
|
+
return s
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 前缀命中行数(恰等 ∨ 后随 `/`——与 §6.14 面① 谓词同语义;raws = 归入目标键的原样键集)。 */
|
|
69
|
+
function pathHitCount(db, table, raws, sub) {
|
|
70
|
+
if (raws.length === 0) return 0
|
|
71
|
+
const ph = raws.map(() => "?").join(", ")
|
|
72
|
+
const { n } = db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE origin IN (${ph}) AND (path = ? OR substr(path, 1, ?) = ?)`).get(...raws, sub, sub.length + 1, `${sub}/`)
|
|
73
|
+
return Number(n ?? 0)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 干跑计划(**零写**)。row = { origin(归一形), raws(归入本键的原样键集), foldFrom(信号 A 的原样键),
|
|
78
|
+
* alive, unknown, action, code / doc / files / total }。
|
|
79
|
+
* 动作三态:`fold`(信号 A)/ `delete`(信号 B 树亡,或 `--origin` 档显式点名)/ `keep`(其余)。
|
|
80
|
+
*/
|
|
81
|
+
export function planSweep(memory, { origin = null, path = null } = {}) {
|
|
82
|
+
if (path !== null && origin === null) throw new Error("memory sweep: `--path` 须与 `--origin` 同用")
|
|
83
|
+
const counts = originCounts(memory.db)
|
|
84
|
+
const sub = path === null ? null : normalizePrefix(path)
|
|
85
|
+
const rows = []
|
|
86
|
+
if (origin !== null) {
|
|
87
|
+
const target = normalizeOrigin(origin)
|
|
88
|
+
const raws = [...counts.keys()].filter((o) => normalizeOrigin(o) === target)
|
|
89
|
+
const agg = emptyCounts()
|
|
90
|
+
if (sub === null) {
|
|
91
|
+
for (const o of raws) for (const k of KEYS) agg[k] += counts.get(o)[k]
|
|
92
|
+
} else {
|
|
93
|
+
// §6.14 面①-3:`--path` 档——计数收窄至两表前缀命中行(files 表不涉——记忆层非项目文件)
|
|
94
|
+
agg.code = pathHitCount(memory.db, "code_chunks", raws, sub)
|
|
95
|
+
agg.doc = pathHitCount(memory.db, "doc_chunks", raws, sub)
|
|
96
|
+
}
|
|
97
|
+
const state = treeState(target)
|
|
98
|
+
rows.push({
|
|
99
|
+
origin: target, raws, foldFrom: null, alive: state.alive, unknown: state.unknown,
|
|
100
|
+
action: totalOf(agg) > 0 ? "delete" : "keep", ...agg, total: totalOf(agg),
|
|
101
|
+
})
|
|
102
|
+
} else {
|
|
103
|
+
for (const [o, c] of counts) {
|
|
104
|
+
const norm = normalizeOrigin(o)
|
|
105
|
+
const state = treeState(o)
|
|
106
|
+
const variant = norm !== o
|
|
107
|
+
rows.push({
|
|
108
|
+
origin: norm, raws: [o], foldFrom: variant ? o : null, alive: state.alive, unknown: state.unknown,
|
|
109
|
+
action: variant ? "fold" : state.dead ? "delete" : "keep", ...c, total: totalOf(c),
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
rows.sort((a, b) => ACTION_RANK[a.action] - ACTION_RANK[b.action] || (a.origin < b.origin ? -1 : a.origin > b.origin ? 1 : 0))
|
|
113
|
+
}
|
|
114
|
+
const totals = emptyCounts()
|
|
115
|
+
for (const r of rows) for (const k of KEYS) totals[k] += r[k]
|
|
116
|
+
totals.total = totalOf(totals)
|
|
117
|
+
return { mode: origin !== null ? "origin" : "full", target: origin !== null ? normalizeOrigin(origin) : null, path: sub, rows, totals }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 备份路径(§6.13):`<dbPath>.sweep-backup-<UTC yyyymmddHHMMSS>`(同目录)。 */
|
|
121
|
+
export function backupPathFor(dbPath, now = new Date()) {
|
|
122
|
+
const p = (n) => String(n).padStart(2, "0")
|
|
123
|
+
const stamp = `${now.getUTCFullYear()}${p(now.getUTCMonth() + 1)}${p(now.getUTCDate())}${p(now.getUTCHours())}${p(now.getUTCMinutes())}${p(now.getUTCSeconds())}`
|
|
124
|
+
return `${dbPath}.sweep-backup-${stamp}`
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 备份前置(`--confirm` 档且命中 > 0):`VACUUM INTO` + 判据(存在 ∧ 大小 > 0 ∧ `integrity_check` = ok)。
|
|
129
|
+
* 目标已存在 / 判据不过 ⇒ 抛错(调用方在**任何写之前**收到 ⇒ 零写)。返回备份路径。
|
|
130
|
+
*/
|
|
131
|
+
function takeBackup(db, path) {
|
|
132
|
+
if (existsSync(path)) throw new Error(`memory sweep: backup target already exists (${path}) — aborted with zero writes`)
|
|
133
|
+
db.exec(`VACUUM INTO '${path.replaceAll("'", "''")}'`)
|
|
134
|
+
if (!existsSync(path) || statSync(path).size <= 0) throw new Error(`memory sweep: backup judgment failed (missing or empty: ${path}) — aborted with zero writes`)
|
|
135
|
+
const bdb = new DatabaseSync(path, { readOnly: true })
|
|
136
|
+
try {
|
|
137
|
+
const row = bdb.prepare("PRAGMA integrity_check").get()
|
|
138
|
+
if (Object.values(row ?? {})[0] !== "ok") throw new Error(`memory sweep: backup judgment failed (integrity_check ≠ ok: ${path}) — aborted with zero writes`)
|
|
139
|
+
} finally { bdb.close() }
|
|
140
|
+
return path
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 单键折叠(§6.11 步 2 逐字口径):同 (dup) 组恰一行——保留 `mtime_ms` 最大者、并列取 `rowid` 最小者。
|
|
144
|
+
* 先删非胜者、再改胜者 origin(PK 复用无冲突);返回触碰行数(删除 + 改键)。 */
|
|
145
|
+
function foldKey(db, key, raws) {
|
|
146
|
+
let touched = 0
|
|
147
|
+
const ph = raws.map(() => "?").join(", ")
|
|
148
|
+
for (const { table, dup } of TABLES) {
|
|
149
|
+
const rows = db.prepare(`SELECT rowid, ${dup.join(", ")}, mtime_ms FROM ${table} WHERE origin IN (${ph})`).all(...raws)
|
|
150
|
+
if (rows.length === 0) continue
|
|
151
|
+
const gk = (r) => dup.map((c) => String(r[c])).join("\u0001")
|
|
152
|
+
const best = new Map()
|
|
153
|
+
for (const r of rows) {
|
|
154
|
+
const cur = best.get(gk(r))
|
|
155
|
+
if (!cur || Number(r.mtime_ms) > Number(cur.mtime_ms) || (Number(r.mtime_ms) === Number(cur.mtime_ms) && r.rowid < cur.rowid)) best.set(gk(r), r)
|
|
156
|
+
}
|
|
157
|
+
const del = db.prepare(`DELETE FROM ${table} WHERE rowid = ?`)
|
|
158
|
+
const upd = db.prepare(`UPDATE ${table} SET origin = ? WHERE rowid = ?`)
|
|
159
|
+
for (const r of rows) if (best.get(gk(r)) !== r) { del.run(r.rowid); touched += 1 }
|
|
160
|
+
for (const r of best.values()) if (r.origin !== key) { upd.run(key, r.rowid); touched += 1 }
|
|
161
|
+
}
|
|
162
|
+
return touched
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** 写后回读判据(§6.13「按档分列」,不过 ⇒ 抛 ⇒ 事务回滚 ⇒ 零写);`hitsByRaw` = `--path` 档逐原样键命中数。 */
|
|
166
|
+
function readBack(db, plan, before, hitsByRaw = null) {
|
|
167
|
+
const { mode, target } = plan
|
|
168
|
+
const after = originCounts(db)
|
|
169
|
+
const totalBefore = [...before.values()].reduce((n, c) => n + totalOf(c), 0)
|
|
170
|
+
const totalAfter = [...after.values()].reduce((n, c) => n + totalOf(c), 0)
|
|
171
|
+
if (totalAfter > totalBefore) throw new Error(`memory sweep read-back failed: COUNT(*) grew (${totalBefore} → ${totalAfter})`)
|
|
172
|
+
if (mode === "origin" && plan.path) {
|
|
173
|
+
// `--path` 档:命中行 0(两表,目标原样键集)∧ 非目标键逐表等前值 ∧ 目标键:files 零变 ∕ code-doc 恰减命中数
|
|
174
|
+
const raws = [...new Set(plan.rows.flatMap((r) => r.raws))]
|
|
175
|
+
for (const t of ["code_chunks", "doc_chunks"]) {
|
|
176
|
+
if (pathHitCount(db, t, raws, plan.path) !== 0) throw new Error(`memory sweep read-back failed: --path hit rows remain in ${t}`)
|
|
177
|
+
}
|
|
178
|
+
for (const [o, c] of before) {
|
|
179
|
+
const now = after.get(o) ?? emptyCounts()
|
|
180
|
+
if (!raws.includes(o)) {
|
|
181
|
+
if (KEYS.some((k) => now[k] !== c[k])) throw new Error(`memory sweep read-back failed: non-target origin changed (${o})`)
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
const hits = hitsByRaw?.get(o) ?? { code: 0, doc: 0 }
|
|
185
|
+
if (now.files !== c.files || now.code !== c.code - hits.code || now.doc !== c.doc - hits.doc) {
|
|
186
|
+
throw new Error(`memory sweep read-back failed: --path target rows changed beyond hits (${o})`)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
} else if (mode === "origin") {
|
|
190
|
+
// `--origin` 档:目标归一键三表零命中 ∧ 非目标 origin 零变(逐键等前值)
|
|
191
|
+
for (const [o] of after) if (normalizeOrigin(o) === target) throw new Error(`memory sweep read-back failed: --origin target key still indexed (${o})`)
|
|
192
|
+
for (const [o, c] of before) {
|
|
193
|
+
if (normalizeOrigin(o) === target) continue
|
|
194
|
+
const now = after.get(o) ?? emptyCounts()
|
|
195
|
+
if (KEYS.some((k) => now[k] !== c[k])) throw new Error(`memory sweep read-back failed: non-target origin changed (${o})`)
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
// 折叠档:归一键集合 = 1(无残留非归一键)∧ 任一 (归一键, path[, line_start]) 恰一行(下循环)
|
|
199
|
+
for (const [o] of after) if (normalizeOrigin(o) !== o) throw new Error(`memory sweep read-back failed: non-normalized origin key remains (${o})`)
|
|
200
|
+
}
|
|
201
|
+
for (const { table, dup } of TABLES) {
|
|
202
|
+
const { n } = db.prepare(`SELECT COUNT(*) AS n FROM (SELECT 1 FROM ${table} GROUP BY origin, ${dup.join(", ")} HAVING COUNT(*) > 1)`).get()
|
|
203
|
+
if (Number(n) !== 0) throw new Error(`memory sweep read-back failed: duplicate rows per (origin, ${dup.join(", ")}) in ${table}`)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 写面(`--confirm` 档):单事务 —— ① 折叠(先于删除,死树键的变体行随键级删除一并清掉)② 键级删除
|
|
208
|
+
* (`--path` 档:仅两表前缀命中行,逐原样键记命中数供回读判据)③ 写后回读判据(不过 ⇒ 回滚 + 抛)。
|
|
209
|
+
* 返回实际触碰行数 { folded, deleted }。 */
|
|
210
|
+
function applySweep(db, plan) {
|
|
211
|
+
const before = originCounts(db)
|
|
212
|
+
const foldGroups = new Map()
|
|
213
|
+
for (const r of plan.rows) {
|
|
214
|
+
if (r.action !== "fold") continue
|
|
215
|
+
// 折叠候选集 = 归一键**自身** + 其全部变体拼写(设计口径 = 同归一键内恰一行——
|
|
216
|
+
// 归一键行亦入组,否则胜者改键会与已在归一键上的同 dup 行撞 PK,整批回滚)
|
|
217
|
+
if (!foldGroups.has(r.origin)) foldGroups.set(r.origin, new Set([r.origin]))
|
|
218
|
+
for (const o of r.raws) foldGroups.get(r.origin).add(o)
|
|
219
|
+
}
|
|
220
|
+
const deleteKeys = plan.rows.filter((r) => r.action === "delete").map((r) => [...new Set([r.origin, ...r.raws])])
|
|
221
|
+
const affected = { folded: 0, deleted: 0 }
|
|
222
|
+
const hitsByRaw = plan.path ? new Map() : null
|
|
223
|
+
db.exec("BEGIN IMMEDIATE")
|
|
224
|
+
try {
|
|
225
|
+
for (const [key, raws] of foldGroups) affected.folded += foldKey(db, key, [...raws])
|
|
226
|
+
for (const raws of deleteKeys) {
|
|
227
|
+
const ph = raws.map(() => "?").join(", ")
|
|
228
|
+
if (plan.path) {
|
|
229
|
+
// §6.14 面①-3:仅 code ∕ doc 两表按前缀删(files 表不涉);逐原样键记命中数
|
|
230
|
+
for (const raw of raws) {
|
|
231
|
+
const hits = { code: 0, doc: 0 }
|
|
232
|
+
for (const [t, k] of [["code_chunks", "code"], ["doc_chunks", "doc"]]) {
|
|
233
|
+
hits[k] = Number(db.prepare(`DELETE FROM ${t} WHERE origin = ? AND (path = ? OR substr(path, 1, ?) = ?)`).run(raw, plan.path, plan.path.length + 1, `${plan.path}/`).changes)
|
|
234
|
+
}
|
|
235
|
+
hitsByRaw.set(raw, hits)
|
|
236
|
+
affected.deleted += hits.code + hits.doc
|
|
237
|
+
}
|
|
238
|
+
} else {
|
|
239
|
+
for (const { table } of TABLES) affected.deleted += Number(db.prepare(`DELETE FROM ${table} WHERE origin IN (${ph})`).run(...raws).changes)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
readBack(db, plan, before, hitsByRaw)
|
|
243
|
+
db.exec("COMMIT")
|
|
244
|
+
} catch (e) {
|
|
245
|
+
try { db.exec("ROLLBACK") } catch { /* 已回滚 / 事务未开 */ }
|
|
246
|
+
throw e
|
|
247
|
+
}
|
|
248
|
+
return affected
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* sweep 主入口。`confirm: false`(缺省)⇒ 纯干跑(零写、零备份);`confirm: true` ⇒ 命中 > 0 时先取备份
|
|
253
|
+
* (判据不过即抛 ⇒ 零写),再单事务执行折叠 / 删除并跑写后回读判据。
|
|
254
|
+
* 返回:{ mode, target, dryRun, rows, totals, hits, backupPath, affected }。
|
|
255
|
+
*/
|
|
256
|
+
export function sweepMemory(memory, { origin = null, path = null, confirm = false, dbPath = null, now = new Date() } = {}) {
|
|
257
|
+
const plan = planSweep(memory, { origin, path })
|
|
258
|
+
const hits = plan.rows.reduce((n, r) => n + (r.action === "keep" ? 0 : r.total), 0)
|
|
259
|
+
const result = { mode: plan.mode, target: plan.target, path: plan.path, dryRun: !confirm, rows: plan.rows, totals: plan.totals, hits, backupPath: null, affected: null }
|
|
260
|
+
if (!confirm) return result
|
|
261
|
+
if (hits > 0) {
|
|
262
|
+
if (typeof dbPath !== "string" || !dbPath || dbPath === ":memory:") {
|
|
263
|
+
throw new Error("memory sweep: `--confirm` 需要文件型 dbPath(备份前置的命名依据)——拒绝无回退对象的写入")
|
|
264
|
+
}
|
|
265
|
+
result.backupPath = takeBackup(memory.db, backupPathFor(dbPath, now))
|
|
266
|
+
}
|
|
267
|
+
result.affected = applySweep(memory.db, plan)
|
|
268
|
+
return result
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** 审计输出形态(§6.13):逐 origin 一行 `origin=<归一形> · 树存活=<是|否> · code / doc / files / 合计`
|
|
272
|
+
* (活树附警示行)+ 折叠映射行 + 删除行数 + 末行合计 + 备份路径(`--confirm` 档)/ 实写读数。 */
|
|
273
|
+
export function formatSweepReport(result) {
|
|
274
|
+
const mode = result.mode === "origin" ? `--origin ${result.target}${result.path ? ` --path ${result.path}` : ""}` : "全扫"
|
|
275
|
+
const lines = [`memory sweep ${mode} · ${result.dryRun ? "dry-run(零写)" : "confirm(写档)"}`]
|
|
276
|
+
for (const r of result.rows) {
|
|
277
|
+
lines.push(`origin=${r.origin} · 树存活=${r.alive ? "是" : "否"} · code=${r.code} / doc=${r.doc} / files=${r.files} / 合计=${r.total}`)
|
|
278
|
+
if (r.foldFrom) lines.push(` 折叠: ${r.foldFrom} → ${r.origin}(并入归一键)`)
|
|
279
|
+
if (r.unknown) lines.push(" ⚠ 探针错误——fail-safe 保留(仅 ENOENT 判亡)")
|
|
280
|
+
if (r.alive) lines.push(` ⚠ 树存活——${result.mode === "origin" ? "显式点名整档删除(不以树存活为判据)" : "保留(全扫档仅删树亡 origin)"}`)
|
|
281
|
+
if (r.action === "delete") lines.push(` 删除: code=${r.code} / doc=${r.doc} / files=${r.files}(${r.total} 行)`)
|
|
282
|
+
}
|
|
283
|
+
const sum = (act) => result.rows.filter((r) => r.action === act).reduce((n, r) => n + r.total, 0)
|
|
284
|
+
const t = result.totals
|
|
285
|
+
lines.push(`合计: code=${t.code} / doc=${t.doc} / files=${t.files} / 合计=${t.total} · 删除 ${sum("delete")} 行 / 折叠 ${sum("fold")} 行 / 保留 ${sum("keep")} 行`)
|
|
286
|
+
if (result.affected) lines.push(`实写: 删除 ${result.affected.deleted} 行 / 折叠 ${result.affected.folded} 行`)
|
|
287
|
+
if (result.backupPath) lines.push(`备份: ${result.backupPath}`)
|
|
288
|
+
return lines
|
|
289
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory/sync-tail.mjs — 同步收尾族(§6.14 面② B6 ∕ M3 收尾让出 + 面③ P2 行预算——**单源**)。
|
|
3
|
+
*
|
|
4
|
+
* 两个同步入口(`codeSync` ∕ `docSync`)共用的收尾机制都住本档(拆分点 = 批档 §2.12 补块 3 缝
|
|
5
|
+
* 「code-sync〔预算判定 + stale 收尾块〕」):
|
|
6
|
+
* · **P2 行预算**(`createRowBudget`):基准 = 本 origin 库内行数(code+doc,开趟读一次);过
|
|
7
|
+
* `INDEX_ORIGIN_ROW_WARN` 出一行可见警示(每趟至多一行)+ `logEvent`;过 `INDEX_ORIGIN_ROW_CAP`
|
|
8
|
+
* 由调用方跳过列序后缀(**只停新增**——存量行不失效;跳过 = 不落行 ∕ 不记 mtime ⇒ 下趟重试)。
|
|
9
|
+
* · **stale 收尾循环**(`sweepStaleRows`):同款时间片让出(§6.10 A2 口径——每 `SCAN_YIELD_CHECK_ROWS`
|
|
10
|
+
* 行读钟,累计 ≥ `yieldMs` 即让出)+ 面① **收尾保护位**(`isExcludedRelPath` 命中路径短路——
|
|
11
|
+
* 被排除但存在的路径存量行不因声明删除,收敛仅经 `sweep --path`;缺省 `[]` ⇒ 恒不命中)。
|
|
12
|
+
*
|
|
13
|
+
* 零环:只引 `log.mjs` ∕ `conventions.mjs`(谓词)∕ `schema.mjs`(常量)∕ `scan.mjs`(预算常量)∕
|
|
14
|
+
* `code-index.mjs`(`yieldTick`)——不引 `code-sync.mjs` ∕ `docs.mjs`。作业皆经传入句柄,档内零状态。
|
|
15
|
+
*/
|
|
16
|
+
import { logEvent } from "../log.mjs"
|
|
17
|
+
import { isExcludedRelPath } from "../conventions.mjs"
|
|
18
|
+
import { INDEX_ORIGIN_ROW_WARN, INDEX_ORIGIN_ROW_CAP } from "./schema.mjs"
|
|
19
|
+
import { SCAN_YIELD_CHECK_ROWS, SCAN_YIELD_MS } from "./scan.mjs"
|
|
20
|
+
import { yieldTick } from "./code-index.mjs"
|
|
21
|
+
|
|
22
|
+
/** 本 origin 库内行数(`code_chunks` + `doc_chunks` 合计——§6.14 P2 预算基准;开趟读一次)。 */
|
|
23
|
+
export function originRowCount(memory, origin) {
|
|
24
|
+
const n = (t) => Number(memory.db.prepare(`SELECT COUNT(*) AS n FROM ${t} WHERE origin = ?`).get(origin)?.n ?? 0)
|
|
25
|
+
return n("code_chunks") + n("doc_chunks")
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 单路径行数(预算差量用——PK 前缀范围扫描)。 */
|
|
29
|
+
export function rowCountOfPath(memory, table, origin, rel) {
|
|
30
|
+
return Number(memory.db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE origin = ? AND path = ?`).get(origin, rel)?.n ?? 0)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** §6.14 P2 行预算(单源——codeSync ∕ docSync 同用):`note()` 每趟至多一行 WARN 警示;`over()` =
|
|
34
|
+
* 已到 CAP(调用方跳过后列文件);`add(delta)` = 本趟净增行数(新入 − 被替换——与「库内行数」同刻度)。 */
|
|
35
|
+
export function createRowBudget(memory, origin, kind, dir) {
|
|
36
|
+
const base = originRowCount(memory, origin)
|
|
37
|
+
let landed = 0, warned = false, capped = false
|
|
38
|
+
const rows = () => base + landed
|
|
39
|
+
const pruneHint = `declare index.excludePaths in PROJECT-MANIFEST.json, or prune: thincoder memory sweep --origin "${origin}" --path <sub>`
|
|
40
|
+
return {
|
|
41
|
+
add: (delta) => { landed += delta },
|
|
42
|
+
over: () => rows() >= INDEX_ORIGIN_ROW_CAP,
|
|
43
|
+
note() {
|
|
44
|
+
if (!warned && rows() >= INDEX_ORIGIN_ROW_WARN) {
|
|
45
|
+
warned = true
|
|
46
|
+
console.warn(`[memory] index origin=${origin} rows≈${rows()} ≥ WARN ${INDEX_ORIGIN_ROW_WARN} — ${pruneHint}`)
|
|
47
|
+
logEvent("index:budget", { dir, kind, rows: rows(), warn: INDEX_ORIGIN_ROW_WARN })
|
|
48
|
+
}
|
|
49
|
+
if (!capped && rows() >= INDEX_ORIGIN_ROW_CAP) {
|
|
50
|
+
capped = true
|
|
51
|
+
console.warn(`[memory] index origin=${origin} rows≈${rows()} ≥ CAP ${INDEX_ORIGIN_ROW_CAP} — skipping remaining files this pass (existing rows are kept)`)
|
|
52
|
+
logEvent("index:budget-cap", { dir, kind, rows: rows(), cap: INDEX_ORIGIN_ROW_CAP })
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* §6.14 B6 ∕ M3:收尾 stale 删除循环(同款让出 + 收尾保护位)。返回删除行数(文件级)。
|
|
60
|
+
* @param {object} memory 句柄
|
|
61
|
+
* @param {{table: string, origin: string, indexed: Map<string, number>, seen: Set<string>, decl: object,
|
|
62
|
+
* base?: string|null, yieldFn?: Function, nowFn?: Function, yieldMs?: number}} opts
|
|
63
|
+
* `indexed` = 库内 path→mtime(开趟读);`seen` = 本趟列文件;`decl` = `loadProjectDeclaration()` 回执;
|
|
64
|
+
* `base` = `indexed`/`seen` 里 `rel` 的相对基(调用面 dir——#700 等价换算;缺省 = 根相对)
|
|
65
|
+
*/
|
|
66
|
+
export async function sweepStaleRows(memory, { table, origin, indexed, seen, decl, base = null, yieldFn = yieldTick, nowFn = Date.now, yieldMs = SCAN_YIELD_MS }) {
|
|
67
|
+
let removed = 0
|
|
68
|
+
let checked = 0
|
|
69
|
+
let lastYieldAt = nowFn()
|
|
70
|
+
for (const stale of indexed.keys()) {
|
|
71
|
+
if (!seen.has(stale) && !isExcludedRelPath(stale, decl, base)) {
|
|
72
|
+
memory.db.prepare(`DELETE FROM ${table} WHERE origin = ? AND path = ?`).run(origin, stale)
|
|
73
|
+
removed++
|
|
74
|
+
}
|
|
75
|
+
if (++checked >= SCAN_YIELD_CHECK_ROWS) {
|
|
76
|
+
checked = 0
|
|
77
|
+
if (nowFn() - lastYieldAt >= yieldMs) { await yieldFn(); lastYieldAt = nowFn() }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return removed
|
|
81
|
+
}
|