thincoder 0.12.58 → 0.12.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -1
- package/README.md +1 -1
- package/bin/thincoder.mjs +8 -0
- package/package.json +1 -1
- package/src/acp/bridge.mjs +132 -26
- package/src/advisor/messages.mjs +34 -1
- package/src/advisor/run.mjs +89 -51
- package/src/advisor.mjs +15 -7
- package/src/agent/dispatch.mjs +91 -14
- package/src/agent/helpers.mjs +35 -4
- package/src/agent/setup.mjs +90 -19
- package/src/agent/spawn-child.mjs +25 -0
- package/src/agent-tools/advisor.mjs +24 -2
- package/src/agent-tools/consult.mjs +37 -6
- package/src/agent-tools/eng.mjs +2 -1
- package/src/agent-tools/goal.mjs +11 -1
- package/src/agent-tools/read-history.mjs +160 -0
- package/src/agent-tools/settings.mjs +162 -0
- package/src/agent-tools/skill.mjs +2 -1
- package/src/agent-tools/subagent-actions.mjs +432 -0
- package/src/agent-tools/subagent-async.mjs +427 -0
- package/src/agent-tools/subagent-scheduler.mjs +319 -0
- package/src/agent-tools/subagent.mjs +467 -193
- package/src/agent-tools/task.mjs +4 -3
- package/src/agent-tools/timer.mjs +9 -4
- package/src/agent-tools/verify.mjs +161 -49
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +161 -125
- package/src/auto-think.mjs +14 -0
- package/src/cli/make-agent.mjs +2 -1
- package/src/cli/permission.mjs +8 -1
- package/src/config.mjs +5 -0
- package/src/context.mjs +87 -27
- package/src/distill.mjs +19 -1
- package/src/escape.mjs +6 -4
- package/src/log.mjs +195 -0
- package/src/memory/code-sync.mjs +1 -1
- package/src/memory/core.mjs +126 -0
- package/src/memory/docs.mjs +196 -87
- package/src/memory.mjs +1 -1
- package/src/model-specs.mjs +15 -1
- package/src/prompts/advisor-design.md +46 -0
- package/src/prompts/advisor-round1.md +49 -2
- package/src/prompts/advisor-round2.md +47 -0
- package/src/prompts/advisor-round3.md +47 -0
- package/src/prompts/coder.md +22 -0
- package/src/prompts/consult-base.md +13 -0
- package/src/prompts/discipline.md +10 -5
- package/src/prompts/eng-coder.md +2 -2
- package/src/prompts/engineering-sub.md +23 -1
- package/src/prompts/engineering.md +106 -56
- package/src/prompts/explore.md +1 -2
- package/src/prompts/main.md +11 -6
- package/src/prompts/methodology-template.md +14 -0
- package/src/prompts/system.md +4 -2
- package/src/provider/core.mjs +56 -2
- package/src/tools/apply_patch.md +3 -1
- package/src/tools/bash.md +1 -1
- package/src/tools/delete.md +1 -0
- package/src/tools/edit-batch.mjs +31 -43
- package/src/tools/edit-diff.mjs +265 -0
- package/src/tools/edit.md +10 -8
- package/src/tools/execute.md +7 -7
- package/src/tools/execute.mjs +24 -20
- package/src/tools/file.mjs +18 -68
- package/src/tools/file_ops.md +2 -1
- package/src/tools/get_current_time.md +3 -1
- package/src/tools/hashline_edit.md +2 -0
- package/src/tools/index.mjs +3 -2
- package/src/tools/insert_after.md +2 -1
- package/src/tools/lint.md +2 -0
- package/src/tools/lsp.md +4 -1
- package/src/tools/patch.mjs +84 -13
- package/src/tools/pdf-parse-text.mjs +497 -0
- package/src/tools/pdf-parse-xref.mjs +499 -0
- package/src/tools/pdf.mjs +155 -0
- package/src/tools/question.md +2 -1
- package/src/tools/read.md +1 -0
- package/src/tools/read_pdf.md +21 -0
- package/src/tools/repomap.mjs +1 -1
- package/src/tools/shared.mjs +4 -12
- package/src/tools/system.mjs +6 -21
- package/src/tools/tree.md +2 -1
- package/src/tools/web.mjs +5 -3
- package/src/tools/websearch.md +2 -1
- package/src/tools/write.md +2 -0
- package/src/traces/trace-store.mjs +224 -0
- package/src/tui/agent-turn.mjs +385 -22
- package/src/tui/clipboard.mjs +15 -4
- package/src/tui/cmd-config.mjs +29 -9
- package/src/tui/cmd-extract.mjs +1 -1
- package/src/tui/cmd-mcp.mjs +9 -0
- package/src/tui/cmd-think.mjs +1 -1
- package/src/tui/index.mjs +29 -95
- package/src/tui/interaction.mjs +13 -2
- package/src/tui/key-handler.mjs +105 -155
- package/src/tui/key-modes.mjs +215 -0
- package/src/tui/layout.mjs +22 -1
- package/src/tui/mouse.mjs +40 -0
- package/src/tui/pickers.mjs +11 -3
- package/src/tui/render-conversation.mjs +13 -161
- package/src/tui/render-frame.mjs +27 -10
- package/src/tui/render-loop.mjs +4 -1
- package/src/tui/render-segments.mjs +165 -0
- package/src/tui/startup.mjs +36 -0
- package/src/tui/subagent-blocks.mjs +322 -144
- package/src/tui/subagent-panel.mjs +88 -13
- package/src/tui/tool-args.mjs +10 -2
- package/src/tui/tool-events.mjs +132 -100
- package/src/tui/update-notice.mjs +72 -0
- package/src/tui/wizard.mjs +36 -6
- package/src/agent-tools/escalate.mjs +0 -179
- package/src/agent-tools/subagent-check.mjs +0 -107
- package/src/tools/exec-prelude.mjs +0 -84
package/src/context.mjs
CHANGED
|
@@ -37,9 +37,13 @@ export function estimateTokens(messages) {
|
|
|
37
37
|
const KEEP_HEAD = 0 // No dedicated head: earliest messages may be a COMPLETED earlier task in multi-task
|
|
38
38
|
// sessions — keeping them verbatim anchored attention on stale work. Everything before the tail is
|
|
39
39
|
// summarized (the summary itself distinguishes completed vs in-progress work; see SUMMARIZE_PROMPT).
|
|
40
|
-
// Tail
|
|
41
|
-
//
|
|
42
|
-
//
|
|
40
|
+
// Tail count formula (D4): window-adaptive (~30 msgs per 100K — old fixed 10 too thin on 1M), capped
|
|
41
|
+
// at 40% of history; §9 D-T1/D-T2 make the count only a CANDIDATE — a token budget (TAIL_BUDGET_FRACTION
|
|
42
|
+
// × window − SUMMARY_TOKEN_ESTIMATE ≈1K, §8) tightens it over pair-safe boundaries when compaction runs,
|
|
43
|
+
// never below TAIL_FLOOR_MESSAGES; ordinary sessions never reach it (D-T4: trigger 0.6 untouched).
|
|
44
|
+
const TAIL_BUDGET_FRACTION = 0.15
|
|
45
|
+
const SUMMARY_TOKEN_ESTIMATE = 1000 // §8: summary output target ~1K tokens — reserved from the 15%
|
|
46
|
+
const TAIL_FLOOR_MESSAGES = 10 // §9 D-T2: the tail keeps ≥10 verbatim messages — floor beats budget
|
|
43
47
|
function keepTailSize(provider, historyLen) {
|
|
44
48
|
// provider is guaranteed at every call site (runAgent always builds one); providerSpec
|
|
45
49
|
// degrades to DEFAULT_SPEC (128K) only if provider is somehow absent — acceptable
|
|
@@ -48,6 +52,10 @@ function keepTailSize(provider, historyLen) {
|
|
|
48
52
|
const ctxWindow = providerSpec(provider).context
|
|
49
53
|
return Math.min(Math.max(10, Math.floor((ctxWindow / 100_000) * 30)), Math.floor(historyLen * 0.4))
|
|
50
54
|
}
|
|
55
|
+
// §9 D-T1 tail token budget: window×15% − summary ~1K — the compressed history segment (summary + placeholder + tail) lands ≈ 15% (B 口径 §9.5).
|
|
56
|
+
function tailBudgetTokens(provider) {
|
|
57
|
+
return Math.max(0, Math.floor(providerSpec(provider).context * TAIL_BUDGET_FRACTION) - SUMMARY_TOKEN_ESTIMATE)
|
|
58
|
+
}
|
|
51
59
|
|
|
52
60
|
export const SUMMARIZE_PROMPT = `You are a conversation compressor. Summarize the following agent work log into a compact summary for use as context in the ongoing conversation.
|
|
53
61
|
Requirements:
|
|
@@ -59,7 +67,7 @@ Requirements:
|
|
|
59
67
|
- Explicitly list UNRESOLVED ISSUES / TODOs: anything still open plus the next steps — so post-compaction recovery knows where to resume
|
|
60
68
|
- Drop: pleasantries, repetition, fine-grained tool output details
|
|
61
69
|
- Honestly mark uncertain items: anything not actually verified must say "unverified"; do not present guesses as facts
|
|
62
|
-
- Use bullet-point output
|
|
70
|
+
- Use bullet-point output. Stay under ~1K tokens (≈1000 Chinese chars / 4000 ASCII chars) — a hard target. An oversized summary wastes window and dilutes the tail; the old unbounded-length guidance is deprecated. When over budget, trim in this order: completed recaps to one line; FILES CHANGED why-notes to bare paths; in-progress prose tightened. NEVER cut design anchors or UNRESOLVED ISSUES/TODOs — recovery depends on them.
|
|
63
71
|
|
|
64
72
|
Work log:
|
|
65
73
|
`
|
|
@@ -68,7 +76,7 @@ Work log:
|
|
|
68
76
|
const COMPACTION_PREFIX =
|
|
69
77
|
"[Context was automatically compacted. Below is a summary of earlier work. " +
|
|
70
78
|
"Treat it as notes, not proof — trust its conclusions (don't redo what it reports as done) " +
|
|
71
|
-
"but re-verify transient state with tools. Check
|
|
79
|
+
"but re-verify transient state with tools. Check memory search for any missing decisions.]\n\n"
|
|
72
80
|
|
|
73
81
|
/** After this many consecutive compaction summary failures, degrade to deterministic truncation (losing info is better than task-killing 400 errors) */
|
|
74
82
|
export const COMPRESS_FAILURE_LIMIT = 3
|
|
@@ -84,12 +92,13 @@ const FALLBACK_NOTE =
|
|
|
84
92
|
|
|
85
93
|
/**
|
|
86
94
|
* Split history into head / middle (to be summarized) / tail; return null if no middle to compress.
|
|
87
|
-
* head is normally empty (KEEP_HEAD = 0 — earliest messages go into the summary); the
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
* the
|
|
95
|
+
* head is normally empty (KEEP_HEAD = 0 — earliest messages go into the summary); the tool_calls-extension logic below is defensive for future KEEP_HEAD > 0.
|
|
96
|
+
* The tail boundary must include any assistant whose tool results are in the tail — if the assistant is in the middle, the summary swallows it, leaving orphan tool results → protocol 400.
|
|
97
|
+
* `budgetTokens` (optional, §9 D-T1): when the candidate's estimate exceeds it, the boundary moves
|
|
98
|
+
* forward until the tail fits — never below the D-T2 floor (10 msgs, or the candidate itself when
|
|
99
|
+
* the 40% cap made it < 10 — short history).
|
|
91
100
|
*/
|
|
92
|
-
function splitHistory(history, keepTail) {
|
|
101
|
+
function splitHistory(history, keepTail, budgetTokens = null) {
|
|
93
102
|
if (history.length <= KEEP_HEAD + keepTail + 1) return null
|
|
94
103
|
let headEnd = KEEP_HEAD
|
|
95
104
|
// head must not end with dangling tool_calls: when assistant declares tool_calls, all its tool results must stay in head.
|
|
@@ -97,10 +106,24 @@ function splitHistory(history, keepTail) {
|
|
|
97
106
|
if (history[headEnd - 1]?.role === "assistant" && history[headEnd - 1].tool_calls?.length) {
|
|
98
107
|
while (headEnd < history.length && history[headEnd].role === "tool") headEnd++
|
|
99
108
|
}
|
|
100
|
-
|
|
109
|
+
const candidate = repairedTailStart(history, headEnd, history.length - keepTail)
|
|
110
|
+
if (candidate <= headEnd) return null
|
|
111
|
+
let tailStart = candidate
|
|
112
|
+
// §9 D-T1: tighten only above the floor — a candidate ≤ 10 IS the floor (short history under the 40% cap must not tighten further, review #5); the floor is D5-repaired too.
|
|
113
|
+
if (budgetTokens > 0 && keepTail > TAIL_FLOOR_MESSAGES) {
|
|
114
|
+
const floor = repairedTailStart(history, headEnd, history.length - TAIL_FLOOR_MESSAGES)
|
|
115
|
+
if (floor > candidate) tailStart = tightenTailByBudget(history, candidate, floor, budgetTokens)
|
|
116
|
+
}
|
|
117
|
+
return { headEnd, tailStart }
|
|
118
|
+
}
|
|
101
119
|
|
|
102
|
-
|
|
103
|
-
|
|
120
|
+
/**
|
|
121
|
+
* D5 tail-side pairing repair for a raw cut at history.length − tailCount: pull into the tail any
|
|
122
|
+
* assistant whose tool results are in the tail (the summary swallowing the owner leaves orphan tool
|
|
123
|
+
* results → protocol 400), then skip orphan tool messages at the new boundary. Single-assistant
|
|
124
|
+
* assumption (nearest owner only — a tail spans at most one assistant→tools cycle); bounds-guarded.
|
|
125
|
+
*/
|
|
126
|
+
function repairedTailStart(history, headEnd, tailStart) {
|
|
104
127
|
const tailToolIds = new Set()
|
|
105
128
|
for (let i = tailStart; i < history.length; i++) {
|
|
106
129
|
if (history[i].role === "tool") tailToolIds.add(history[i].tool_call_id)
|
|
@@ -112,16 +135,28 @@ function splitHistory(history, keepTail) {
|
|
|
112
135
|
break
|
|
113
136
|
}
|
|
114
137
|
}
|
|
115
|
-
|
|
116
|
-
// skip orphan tool messages at the new tail boundary (tool whose assistant was pulled in above)
|
|
117
|
-
// NOTE: single-assistant assumption — the backwards scan pulls the nearest owner only; in
|
|
118
|
-
// practice a tail spans at most one assistant→tools cycle (parallel calls share one assistant).
|
|
119
|
-
// Bounds-guarded so an all-tool tail cannot push tailStart past history.length.
|
|
120
138
|
while (tailStart < history.length && tailStart > headEnd && history[tailStart].role === "tool") {
|
|
121
139
|
tailStart++
|
|
122
140
|
}
|
|
123
|
-
|
|
124
|
-
|
|
141
|
+
return tailStart
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* §9 D-T1 budget tightening (pair-safe, review #2): walk the boundary FORWARD (fewer tail messages —
|
|
146
|
+
* the rest joins the summary) while the tail's estimated tokens exceed the budget. Only pair-safe
|
|
147
|
+
* positions may stop the walk: a boundary ON a tool message would orphan its owner assistant into the
|
|
148
|
+
* middle (D5); pairing is contiguous in the machine line (§6 note) — every non-tool boundary is safe.
|
|
149
|
+
* No fit before the floor → keep the floor, accept the overrun.
|
|
150
|
+
*/
|
|
151
|
+
function tightenTailByBudget(history, start, floorStart, budgetTokens) {
|
|
152
|
+
const suffixTokens = new Array(history.length + 1)
|
|
153
|
+
suffixTokens[history.length] = 0
|
|
154
|
+
for (let i = history.length - 1; i >= 0; i--) suffixTokens[i] = suffixTokens[i + 1] + estimateTokens([history[i]])
|
|
155
|
+
if (suffixTokens[start] <= budgetTokens) return start // already fits — ordinary sessions stay untouched (D-T2)
|
|
156
|
+
for (let p = start + 1; p <= floorStart; p++) { // first fit keeps the most recent verbatim context
|
|
157
|
+
if (history[p].role !== "tool" && suffixTokens[p] <= budgetTokens) return p
|
|
158
|
+
}
|
|
159
|
+
return floorStart
|
|
125
160
|
}
|
|
126
161
|
|
|
127
162
|
/**
|
|
@@ -132,9 +167,14 @@ function splitHistory(history, keepTail) {
|
|
|
132
167
|
* Machine-only messages ([System reminder:...], compaction notes, task/plan/checkpoint re-injections)
|
|
133
168
|
* are pushed directly to agent.history WITHOUT going through here, so they never enter _fullHistory.
|
|
134
169
|
* The two lines are written independently at the source — no after-the-fact delta sync.
|
|
170
|
+
* Message timestamps (SESSION.md §9 D-S1): stamped HERE once at push time (epoch ms) — a single
|
|
171
|
+
* point covers every real message. Pre-existing ts (e.g. from another end writing the shared slot)
|
|
172
|
+
* is preserved; restored old messages keep no ts rather than getting a misleading backdate (D-S3).
|
|
173
|
+
* ts is a LOCAL-ONLY field — the send layer strips it before any provider request (T-S3).
|
|
135
174
|
*/
|
|
136
175
|
export function pushReal(agent, msg) {
|
|
137
176
|
if (!Array.isArray(agent._fullHistory)) agent._fullHistory = []
|
|
177
|
+
if (msg && msg.ts === undefined) msg.ts = Date.now()
|
|
138
178
|
agent._fullHistory.push(msg)
|
|
139
179
|
agent.history.push(msg)
|
|
140
180
|
}
|
|
@@ -148,10 +188,14 @@ function applyCompression(agent, headEnd, tailStart, note) {
|
|
|
148
188
|
// possibly-completed earlier requests.
|
|
149
189
|
const head = agent.history.slice(0, headEnd)
|
|
150
190
|
const tail = agent.history.slice(tailStart)
|
|
191
|
+
// SESSION.md §9 D-S1: compaction-injected messages (note + "Understood") carry a ts —
|
|
192
|
+
// Date.now() at the compaction moment. They are machine-only (never in _fullHistory),
|
|
193
|
+
// but the machine-line timeline stays consistent for any audit use.
|
|
194
|
+
const now = Date.now()
|
|
151
195
|
agent.history = [
|
|
152
196
|
...head,
|
|
153
|
-
{ role: "user", content: note },
|
|
154
|
-
{ role: "assistant", content: "Understood. I'll continue from these notes, re-verifying anything transient." },
|
|
197
|
+
{ role: "user", content: note, ts: now },
|
|
198
|
+
{ role: "assistant", content: "Understood. I'll continue from these notes, re-verifying anything transient.", ts: now },
|
|
155
199
|
...tail,
|
|
156
200
|
]
|
|
157
201
|
// Compaction REBUILDS the machine line (head + note + "Understood" + tail), so the pre-compaction
|
|
@@ -217,7 +261,7 @@ export async function compressIfNeeded(agent, threshold, callbacks, extras = {},
|
|
|
217
261
|
if (tokens <= threshold) return false
|
|
218
262
|
|
|
219
263
|
const keepTail = keepTailSize(agent.provider, history.length)
|
|
220
|
-
const split = splitHistory(history, keepTail)
|
|
264
|
+
const split = splitHistory(history, keepTail, tailBudgetTokens(agent.provider))
|
|
221
265
|
if (!split) {
|
|
222
266
|
// History is too short (≤KEEP_HEAD+keepTail+1 messages) to find a middle section, but tokens exceed threshold — typically a single giant message
|
|
223
267
|
// (large paste / huge injection). When summarization has no room, degrade to deterministic shrinking to ensure context always reduces
|
|
@@ -250,6 +294,14 @@ export async function compressIfNeeded(agent, threshold, callbacks, extras = {},
|
|
|
250
294
|
const summary = await chat({ ...agent.provider, thinking: null, reasoningEffort: null }, {
|
|
251
295
|
messages: [{ role: "user", content: SUMMARIZE_PROMPT + serialized }],
|
|
252
296
|
signal,
|
|
297
|
+
// §18.6 D-TR4:轨迹元数据增补——kind=compress(上下文构建面——agent 元数据透出;
|
|
298
|
+
// depth 经 extras.traceDepth——agent.mjs 主作用域传入——compress 调用点补齐)
|
|
299
|
+
logCtx: {
|
|
300
|
+
stage: "compress", child: agent._logId, kind: "compress",
|
|
301
|
+
role: agent._role ?? null, depth: extras?.traceDepth ?? null,
|
|
302
|
+
session: agent._sessionStart ?? null, cwd: agent.cwd,
|
|
303
|
+
traces: agent.config?.traces?.enabled !== false,
|
|
304
|
+
},
|
|
253
305
|
})
|
|
254
306
|
|
|
255
307
|
applyCompression(agent, split.headEnd, split.tailStart, COMPACTION_PREFIX + summary.content)
|
|
@@ -272,7 +324,7 @@ export async function compressIfNeeded(agent, threshold, callbacks, extras = {},
|
|
|
272
324
|
*/
|
|
273
325
|
export function compressFallback(agent) {
|
|
274
326
|
const keepTail = keepTailSize(agent.provider, agent.history.length)
|
|
275
|
-
const split = splitHistory(agent.history, keepTail)
|
|
327
|
+
const split = splitHistory(agent.history, keepTail, tailBudgetTokens(agent.provider))
|
|
276
328
|
if (!split) return false
|
|
277
329
|
const tailMessages = agent.history.length - split.tailStart
|
|
278
330
|
applyCompression(agent, split.headEnd, split.tailStart, FALLBACK_NOTE)
|
|
@@ -404,7 +456,7 @@ function serializeExplorationMessages(messages) {
|
|
|
404
456
|
* or null when there is nothing to shrink (<3 exploration results / LLM failure). Pairing-safe:
|
|
405
457
|
* whole assistant→tool blocks are removed, so no orphan tool_calls/tool can survive.
|
|
406
458
|
*/
|
|
407
|
-
async function distillExplorations(history, start, provider, signal) {
|
|
459
|
+
async function distillExplorations(history, start, provider, signal, agent, depth) {
|
|
408
460
|
if (!Array.isArray(history) || history.length - start < 2) return null
|
|
409
461
|
const blocks = findExplorationBlocks(history, start)
|
|
410
462
|
const resultCount = blocks.reduce((n, b) => n + b.toolCount, 0)
|
|
@@ -419,6 +471,14 @@ async function distillExplorations(history, start, provider, signal) {
|
|
|
419
471
|
const resp = await chat({ ...provider, thinking: null, reasoningEffort: null }, {
|
|
420
472
|
messages: [{ role: "user", content: EXPLORE_SUMMARY_PROMPT + serialized }],
|
|
421
473
|
signal,
|
|
474
|
+
// §18.6 D-TR4:轨迹元数据增补——kind=distill(探索蒸馏面——agent 元数据透出;
|
|
475
|
+
// depth 经 summarizeRunExplorations 参数透传——agent.mjs 主作用域传入)
|
|
476
|
+
logCtx: {
|
|
477
|
+
stage: "distill", child: agent?._logId ?? null, kind: "distill",
|
|
478
|
+
role: agent?._role ?? null, depth: depth ?? null,
|
|
479
|
+
session: agent?._sessionStart ?? null, cwd: agent?.cwd ?? process.cwd(),
|
|
480
|
+
traces: agent?.config?.traces?.enabled !== false,
|
|
481
|
+
},
|
|
422
482
|
})
|
|
423
483
|
summary = resp?.content
|
|
424
484
|
} catch {
|
|
@@ -449,8 +509,8 @@ async function distillExplorations(history, start, provider, signal) {
|
|
|
449
509
|
* ONLY after the replacement actually lands (never on no-op/failure) — callers persist the
|
|
450
510
|
* compressed session (SEND-STALL-DISTILL §2.3).
|
|
451
511
|
*/
|
|
452
|
-
export async function summarizeRunExplorations(agent, callbacks, signal) {
|
|
453
|
-
const next = await distillExplorations(agent.history, agent._runStartHistoryLen ?? 0, agent.provider, signal)
|
|
512
|
+
export async function summarizeRunExplorations(agent, callbacks, signal, depth = 0) {
|
|
513
|
+
const next = await distillExplorations(agent.history, agent._runStartHistoryLen ?? 0, agent.provider, signal, agent, depth)
|
|
454
514
|
if (!next) return
|
|
455
515
|
agent.history = next
|
|
456
516
|
// The machine line changed shape — the measured token baseline was for the pre-shrink context.
|
package/src/distill.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { chat } from "./provider/index.mjs"
|
|
8
|
+
import { loadConfig } from "./config.mjs"
|
|
8
9
|
import { put, putMarkdown } from "./memory.mjs"
|
|
9
10
|
import { commitAndPush } from "./git/gitmem.mjs"
|
|
10
11
|
|
|
@@ -39,13 +40,30 @@ If the session is long, prioritize conclusions that appeared last and are still
|
|
|
39
40
|
Session log:
|
|
40
41
|
`
|
|
41
42
|
|
|
43
|
+
/** §18.6 D-TR6(2026-09-04 fix round1):distill 调用点无 agent 作用域——traces 开关
|
|
44
|
+
* 缺省回退磁盘配置(loadConfig——与 agent.config 同源:traces.enabled 缺省 on);
|
|
45
|
+
* 配置不可读时按缺省 on(注:CLI 启动早已 loadConfig——此处仅是兜底防御)。 */
|
|
46
|
+
function tracesEnabledFromConfig() {
|
|
47
|
+
try {
|
|
48
|
+
return loadConfig().traces?.enabled !== false
|
|
49
|
+
} catch {
|
|
50
|
+
return true
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
42
54
|
/**
|
|
43
55
|
* Extract candidates from a session transcript. transcript: plain-text session record.
|
|
44
56
|
* Returns [{ type, title, content, tags, scope }], or [] on parse failure.
|
|
57
|
+
* opts.traces(可选):§18.6 D-TR6 开关显式透传(测试隔离/未来调用方)——缺省回退
|
|
58
|
+
* 磁盘配置(tracesEnabledFromConfig)——关 = chat() 出口不落盘。
|
|
45
59
|
*/
|
|
46
|
-
export async function extractCandidates(provider, transcript) {
|
|
60
|
+
export async function extractCandidates(provider, transcript, opts = {}) {
|
|
61
|
+
const traces = opts.traces ?? tracesEnabledFromConfig()
|
|
47
62
|
const res = await chat(provider, {
|
|
48
63
|
messages: [{ role: "user", content: DISTILL_PROMPT + transcript }],
|
|
64
|
+
// §18.6 D-TR4/D-TR6(fix round1):distill 调用经 chat() 唯一采集点——补轨迹
|
|
65
|
+
// 元数据 + traces 开关透传(关=不落盘必须全覆盖——不再静默越过开关)
|
|
66
|
+
logCtx: { stage: "distill", kind: "distill", traces },
|
|
49
67
|
})
|
|
50
68
|
// Balanced-bracket extraction: find the first '[' and track depth through nested
|
|
51
69
|
// brackets (tags arrays, nested objects, etc.) until the matching ']'.
|
package/src/escape.mjs
CHANGED
|
@@ -131,13 +131,15 @@ export function escapeMessageContent(message) {
|
|
|
131
131
|
return changed ? next : message
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
/** IKBGX4
|
|
134
|
+
/** IKBGX4 + SESSION.md §9 D-S1:剥离仅本地使用的整消息标记字段(transient/ts)——发送给 provider 前移除。
|
|
135
135
|
* 严格 OpenAI 兼容服务端(opencode/LiteLLM 等)会拒绝消息级未知 key
|
|
136
|
-
* ("Extra inputs are not permitted, field: 'messages[i].transient'"
|
|
136
|
+
* ("Extra inputs are not permitted, field: 'messages[i].transient'");ts 同理
|
|
137
|
+
* (消息时间戳是本地取证字段,不进任何 provider 请求——T-S3)。copy-on-write:
|
|
138
|
+
* 历史里的原对象不动(read_history 仍能读到 ts)。 */
|
|
137
139
|
export function stripLocalMessageFields(messages) {
|
|
138
140
|
return messages.map((m) => {
|
|
139
|
-
if (m && typeof m === "object" && "transient" in m) {
|
|
140
|
-
const { transient, ...rest } = m
|
|
141
|
+
if (m && typeof m === "object" && ("transient" in m || "ts" in m)) {
|
|
142
|
+
const { transient, ts, ...rest } = m
|
|
141
143
|
return rest
|
|
142
144
|
}
|
|
143
145
|
return m
|
package/src/log.mjs
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* log.mjs — 诊断事件日志(docs/design/LOGGING.md 权威规格)。
|
|
3
|
+
* 双端同构:thincoder/src/log.mjs 与 thincoder-vscode/src/log.mjs 同一实现语义
|
|
4
|
+
* (共享 ~/.thincoder/logs/、同格式、同事件面——F-L6)。
|
|
5
|
+
*
|
|
6
|
+
* 常驻骨架日志:回合/LLM/工具/子代理/挂起的关键事件——每行一个 JSON 事件,按天轮转
|
|
7
|
+
* (agent-YYYY-MM-DD.log),保留 1 天——用户零手动维护(F-L3/NF-L4)。问题发生时
|
|
8
|
+
* 直接 tail/grep 定位断点(根治"临时插桩再删"循环)。
|
|
9
|
+
*
|
|
10
|
+
* 纪律(实现侧逐条落实):
|
|
11
|
+
* - fire-and-forget:logEvent 失败静默降级(NF-L1)——主流程零影响。进程内首次写失败
|
|
12
|
+
* 即置死(_dead),当日不再尝试(磁盘满/权限错不逐事件空转)。
|
|
13
|
+
* - 单事件行 <512 字符(NF-L2):head(LLM ≤300 / 工具 ≤200)、err ≤200 由调用方截断,
|
|
14
|
+
* 本模块对任意字符串字段做上限兜底(head 300 / err 200 / 其余 120)+ 超长时丢可选字段。
|
|
15
|
+
* - 敏感字段零落盘(NF-L3/§2.4,2026-09-03 评审 refinement #7 匹配语义定稿):
|
|
16
|
+
* ① 字段名精确匹配黑名单(apiKey/designToken/password/secret/token,大小写不敏感;
|
|
17
|
+
* 另加防御性名称 authorization/proxy/proxyUri——防凭据型 URL/头字段泄漏——§2.5)
|
|
18
|
+
* → 整个字段丢弃;
|
|
19
|
+
* ② 内容只扫密钥形态(sk-xxx / Bearer xxx / key=… 等)→ 截断到形态之前
|
|
20
|
+
* ——宁可丢信息不漏密钥。
|
|
21
|
+
* 工具事件不记 args;URL 不入事件(llm/tool 事件从不携带 URL;err 文本经形态扫描)。
|
|
22
|
+
* - 摘要截断(B 方案,2026-09-03 用户裁定):截断处带 "…" 标记(截后仍 ≤上限)。
|
|
23
|
+
* - 测试隔离:node --test 进程(NODE_TEST_CONTEXT)默认不写盘——防测试事件污染真实
|
|
24
|
+
* 诊断日志(两端测试套件都会跑真实 agent 管线);显式设置 THINCODER_LOG_DIR 强制
|
|
25
|
+
* 写入该目录(log.test.mjs 用它隔离临时目录——refinement #6)。
|
|
26
|
+
* - 轮转清理(refinement #3——长驻进程覆盖):非仅启动时——每进程每日**首次写事件**
|
|
27
|
+
* 时顺带清理 >1 天的 agent-*.log(extension host 可长驻数月,启动清理覆盖不到)。
|
|
28
|
+
* - seq:每进程单调计数器。双端同写一个文件时 seq 会各自重复——定位同文件时序以
|
|
29
|
+
* ts 为准,seq 仅进程内参照(refinement #8)。
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { appendFileSync, mkdirSync, readdirSync, rmSync, existsSync, statSync } from "node:fs"
|
|
33
|
+
import { homedir } from "node:os"
|
|
34
|
+
import { join } from "node:path"
|
|
35
|
+
|
|
36
|
+
/** 单行长度硬上限(NF-L2) */
|
|
37
|
+
export const MAX_LINE = 512
|
|
38
|
+
/** 字段名精确黑名单(NF-L3/§2.4——2026-09-03 评审 refinement #7:与 §2.4 词表一致含 token) */
|
|
39
|
+
const BLACKLIST_FIELDS = new Set(["apikey", "designtoken", "password", "secret", "token", "authorization", "proxyuri", "proxy"])
|
|
40
|
+
/** 内容密钥形态扫描(大小写不敏感)——命中即截断到形态前 */
|
|
41
|
+
const SECRET_FORM = /(\bsk-[A-Za-z0-9_-]{6,}|\bBearer\s+[A-Za-z0-9._~+/=-]{6,}|\b(?:api[_-]?key|key|token|secret|password|pwd|passwd)\s*[=:]\s*['"]?[A-Za-z0-9._~+/=-]{6,})/i
|
|
42
|
+
/** 字符串字段长度兜底:head ≤300 / err ≤200 / 其余 ≤120 */
|
|
43
|
+
const FIELD_CAPS = { head: 300, err: 200 }
|
|
44
|
+
|
|
45
|
+
let _seq = 0
|
|
46
|
+
let _dead = false // 进程内写失败即死(NF-L1 静默降级——不逐事件重复空转)
|
|
47
|
+
let _cleanupDate = null // 本进程已执行过清理的日期(每日首次写时清一次)
|
|
48
|
+
|
|
49
|
+
/** 日志目录:THINCODER_LOG_DIR(测试隔离/override)> ~/.thincoder/logs(与 sessions/ 同域) */
|
|
50
|
+
export function logsDir() {
|
|
51
|
+
return process.env.THINCODER_LOG_DIR ?? join(homedir(), ".thincoder", "logs")
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 今日日志文件路径(agent-YYYY-MM-DD.log) */
|
|
55
|
+
export function todayLogPath(now = new Date()) {
|
|
56
|
+
const ymd = now.toISOString().slice(0, 10)
|
|
57
|
+
return join(logsDir(), `agent-${ymd}.log`)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 写门(测试隔离):test runner 进程(NODE_TEST_CONTEXT)默认跳过——除显式
|
|
61
|
+
* THINCODER_LOG_DIR override(log.test.mjs 隔离临时目录——refinement #6)。 */
|
|
62
|
+
function writeEnabled() {
|
|
63
|
+
if (_dead) return false
|
|
64
|
+
if (process.env.NODE_TEST_CONTEXT && !process.env.THINCODER_LOG_DIR) return false
|
|
65
|
+
return true
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 清理 >1 天的 agent-*.log(NF-L4——refinement #3:机会式,非仅启动时)。
|
|
69
|
+
* 保留窗口(2026-09-03 code review #1 修正):以文件名日期的**当日结束**为龄基准——
|
|
70
|
+
* 今天+昨天的文件保留(任何事件至少留存 24h、最多 48h),早于昨天的删除。
|
|
71
|
+
* 无法按名解析日期的 agent-*.log 按 mtime 兜底(>1 天未写的删除)。
|
|
72
|
+
* 导出供测试直呼(T-L7/T-L7b)。静默:任何失败都不影响主流程。 */
|
|
73
|
+
export const LOG_RETENTION_MS = 24 * 60 * 60 * 1000
|
|
74
|
+
export function cleanupOldLogs(now = new Date()) {
|
|
75
|
+
const dir = logsDir()
|
|
76
|
+
if (!existsSync(dir)) return
|
|
77
|
+
let names
|
|
78
|
+
try {
|
|
79
|
+
names = readdirSync(dir)
|
|
80
|
+
} catch {
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
for (const name of names) {
|
|
84
|
+
if (!name.startsWith("agent-") || !name.endsWith(".log")) continue
|
|
85
|
+
const m = name.match(/^agent-(\d{4}-\d{2}-\d{2})\.log$/)
|
|
86
|
+
const ts = m ? Date.parse(m[1] + "T00:00:00Z") : NaN
|
|
87
|
+
// 文件名日期 → 龄以该日结束(ts+24h)计:事件在删除时至少已留存 24h
|
|
88
|
+
const ageMs = Number.isFinite(ts) ? now - (ts + LOG_RETENTION_MS) : (tryStatAge(dir, name, now) ?? Infinity)
|
|
89
|
+
if (ageMs > LOG_RETENTION_MS) {
|
|
90
|
+
try { rmSync(join(dir, name), { force: true }) } catch { /* 静默 */ }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function tryStatAge(dir, name, now) {
|
|
96
|
+
try {
|
|
97
|
+
return now - statSync(join(dir, name)).mtimeMs
|
|
98
|
+
} catch {
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 写入一条事件(fire-and-forget)。fields 内所有字符串都经黑名单/截断处理。
|
|
105
|
+
* 事件行结构:{"ts":ISO,"ev":kind,"seq":N,...fields}——单行 JSON <512 字符。
|
|
106
|
+
*/
|
|
107
|
+
export function logEvent(kind, fields = {}) {
|
|
108
|
+
if (!writeEnabled()) return
|
|
109
|
+
const now = new Date()
|
|
110
|
+
// 机会式轮转清理:每进程每日首次写事件时执行一次(refinement #3——长驻进程覆盖)
|
|
111
|
+
const ymd = now.toISOString().slice(0, 10)
|
|
112
|
+
if (_cleanupDate !== ymd) {
|
|
113
|
+
_cleanupDate = ymd
|
|
114
|
+
cleanupOldLogs(now)
|
|
115
|
+
}
|
|
116
|
+
const entry = { ts: now.toISOString(), ev: kind, seq: ++_seq }
|
|
117
|
+
for (const [k, v] of Object.entries(fields ?? {})) {
|
|
118
|
+
if (v === undefined || v === null) continue
|
|
119
|
+
// ① 字段名精确黑名单 → 丢弃(token 族永不落盘)
|
|
120
|
+
if (BLACKLIST_FIELDS.has(String(k).toLowerCase())) continue
|
|
121
|
+
if (typeof v === "string") entry[k] = sanitizeString(k, v)
|
|
122
|
+
else if (typeof v === "number" || typeof v === "boolean") entry[k] = v
|
|
123
|
+
}
|
|
124
|
+
let line = JSON.stringify(entry)
|
|
125
|
+
// ② 防御性收尾(NF-L2):字段上限后仍超长 → 丢可选字段(保留 ts/ev/seq)直至 <512
|
|
126
|
+
while (line.length > MAX_LINE && Object.keys(entry).length > 3) {
|
|
127
|
+
for (const k of Object.keys(entry)) {
|
|
128
|
+
if (k !== "ts" && k !== "ev" && k !== "seq") { delete entry[k]; break }
|
|
129
|
+
}
|
|
130
|
+
line = JSON.stringify(entry)
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
const dir = logsDir()
|
|
134
|
+
mkdirSync(dir, { recursive: true })
|
|
135
|
+
appendFileSync(join(dir, `agent-${ymd}.log`), line + "\n", "utf8")
|
|
136
|
+
} catch {
|
|
137
|
+
_dead = true // NF-L1:写失败静默降级——主流程零影响
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 共享脱敏(§18.6 D-TR2——trace-store 复用;logEvent 自身保持"丢弃字段"语义,
|
|
142
|
+
* 本函数提供"遮蔽标记"语义——同一字段名黑名单 + 同一 SECRET_FORM 形态扫描,
|
|
143
|
+
* 不发明新遮蔽模式)。字段名命中黑名单 → "[REDACTED](整个字段遮蔽);内容命中
|
|
144
|
+
* 密钥形态 → 截断到形态前 + "[redacted]" 标记(宁可丢信息不漏密钥——§2.4)。 */
|
|
145
|
+
export function redactSecret(fieldKey, value) {
|
|
146
|
+
const s = String(value)
|
|
147
|
+
if (BLACKLIST_FIELDS.has(String(fieldKey).toLowerCase())) return "[REDACTED]"
|
|
148
|
+
const hit = s.match(SECRET_FORM)
|
|
149
|
+
return hit ? s.slice(0, hit.index) + "[redacted]" : s
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 内容级净化:密钥形态截断到形态前(②);超长截断 + "…" 标记。 */
|
|
153
|
+
export function sanitizeString(key, value) {
|
|
154
|
+
let s = String(value)
|
|
155
|
+
// 密钥形态 → 截断到形态前(宁可丢信息不漏密钥——§2.4)
|
|
156
|
+
const hit = s.match(SECRET_FORM)
|
|
157
|
+
if (hit) s = s.slice(0, hit.index)
|
|
158
|
+
const cap = FIELD_CAPS[key] ?? 120
|
|
159
|
+
if (s.length > cap) s = s.slice(0, cap - 1) + "…"
|
|
160
|
+
return s
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 错误文本提取:message + cause 链首条、压成单行、≤max(默认 200——NF-L2/§2.2) */
|
|
164
|
+
export function errText(err, max = 200) {
|
|
165
|
+
let msg = err?.message ?? String(err ?? "")
|
|
166
|
+
if (!msg && err?.cause) msg = String(err.cause)
|
|
167
|
+
if (err?.cause?.message && !/^LLM API error/.test(msg)) msg += ` (${err.cause.message})`
|
|
168
|
+
msg = msg.replace(/\s*\n\s*/g, " ").trim()
|
|
169
|
+
if (msg.length > max) msg = msg.slice(0, max - 1) + "…"
|
|
170
|
+
return msg
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** 错误 kind 分类(llm:error 字段):timeout(网关超时/超时信号)/ abort(用户中止)/
|
|
174
|
+
* error(其余——API/网络/未分类)。signal 为请求中止信号(chat 调用点直传)。 */
|
|
175
|
+
export function classifyErr(err, signal) {
|
|
176
|
+
const msg = String(err?.message ?? "").toLowerCase() + " " + String(err?.cause?.message ?? "").toLowerCase()
|
|
177
|
+
if (/timeout|timed ?out|etimedout|time limit exceeded/i.test(msg)) return "timeout"
|
|
178
|
+
if (err?.name === "AbortError" || signal?.aborted) {
|
|
179
|
+
return signal?.reason?.name === "TimeoutError" ? "timeout" : "abort"
|
|
180
|
+
}
|
|
181
|
+
return "error"
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** 文本头截取:前 max 字符、单行化(JSON 单行约束)、截断带 "…" 标记。
|
|
185
|
+
* paragraph=true(llm:done 响应头):取首段(首个空行前)——B 方案"首段文本"语义。 */
|
|
186
|
+
export function headText(text, max, { paragraph = false } = {}) {
|
|
187
|
+
let s = String(text ?? "")
|
|
188
|
+
if (paragraph) {
|
|
189
|
+
const p = s.split(/\n\s*\n/, 1)[0]
|
|
190
|
+
if (p.length < s.length) s = p
|
|
191
|
+
}
|
|
192
|
+
s = s.replace(/\s*\n+\s*/g, " ").trim()
|
|
193
|
+
if (s.length > max) s = s.slice(0, max - 1) + "…"
|
|
194
|
+
return s
|
|
195
|
+
}
|
package/src/memory/code-sync.mjs
CHANGED
|
@@ -313,7 +313,7 @@ export function codeSearchTool(memory) {
|
|
|
313
313
|
return {
|
|
314
314
|
name: "code_search",
|
|
315
315
|
description:
|
|
316
|
-
"Search the project's source code for relevant code. Use this to find functions, classes, or code patterns across the codebase. Supports natural language queries and code snippets. Returns matching code chunks with file paths and line numbers.",
|
|
316
|
+
"Search the project's source code for relevant code. Use this to find functions, classes, or code patterns across the codebase. Supports natural language queries and code snippets. Returns matching code chunks with file paths and line numbers. Prefer doc_search for the intended design (design docs, conventions); code_search for the implementation as written.",
|
|
317
317
|
parameters: {
|
|
318
318
|
type: "object",
|
|
319
319
|
properties: {
|
package/src/memory/core.mjs
CHANGED
|
@@ -272,6 +272,132 @@ export async function list(memory, { type, limit = DEFAULT_LIST_LIMIT } = {}) {
|
|
|
272
272
|
.all(limit)
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
+
/** LIKE pattern from a keyword (wildcards escaped — literal substring match, MEMORY.md §6 keyword filter). */
|
|
276
|
+
function likePattern(keyword) {
|
|
277
|
+
return `%${keyword.replace(/[\\%_]/g, (c) => `\\${c}`)}%`
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Shared row query for the §6 list action and the §6 batch delete (one match surface —
|
|
282
|
+
* rows carry { layer, id, type, title, ts }). Filters:
|
|
283
|
+
* scope: "personal" | "project" | "team" | null (null = all layers)
|
|
284
|
+
* type / keyword: optional (keyword matches title OR content, substring)
|
|
285
|
+
* Personal rows come from the entries table; project/team rows come from a DISK scan of
|
|
286
|
+
* the managed dir (2026-09-05 fix — disk is the truth): files present on disk but
|
|
287
|
+
* missing from the files index (orphans: external copies / gitmem pull / an earlier
|
|
288
|
+
* index failure) were invisible to list AND immune to batch delete — the old table-only
|
|
289
|
+
* match surface made a scope wipe need repeated delete rounds (deleteWhere→syncDir
|
|
290
|
+
* re-indexed the orphans one round later). Scanning disk keeps list and batch delete
|
|
291
|
+
* consistent with what the user can see and delete. Rows from other projects'/team
|
|
292
|
+
* repos' dirs stay out (the scan only covers the dirs this memory context manages).
|
|
293
|
+
* Malformed files are skipped (parseEntry failure — same semantics as syncDir). Sorted
|
|
294
|
+
* by ts (created/updated, ms) DESC.
|
|
295
|
+
*/
|
|
296
|
+
export async function matchMemoryRows(memory, { scope = null, type = null, keyword = null, projectDir = null, teamDir = null } = {}) {
|
|
297
|
+
const rows = []
|
|
298
|
+
const wantLayer = (l) => !scope || scope === l
|
|
299
|
+
if (wantLayer("personal")) {
|
|
300
|
+
let sql = `SELECT id, type, title, created_at AS ts FROM entries`
|
|
301
|
+
const cond = []
|
|
302
|
+
const params = []
|
|
303
|
+
if (type) { cond.push("type = ?"); params.push(type) }
|
|
304
|
+
if (keyword) { cond.push("(title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\')"); const p = likePattern(keyword); params.push(p, p) }
|
|
305
|
+
if (cond.length) sql += " WHERE " + cond.join(" AND ")
|
|
306
|
+
sql += " ORDER BY created_at DESC"
|
|
307
|
+
for (const r of memory.db.prepare(sql).all(...params)) {
|
|
308
|
+
rows.push({ layer: "personal", id: `personal:${r.id}`, uid: `personal:${r.id}`, type: r.type, title: r.title, ts: r.ts })
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (wantLayer("project") && projectDir) {
|
|
312
|
+
for (const r of await diskFileRows(projectDir, type, keyword)) rows.push({ ...r, layer: "project", id: `project:${projectDir}:${r.path}` })
|
|
313
|
+
}
|
|
314
|
+
if (wantLayer("team") && teamDir) {
|
|
315
|
+
for (const r of await diskFileRows(teamDir, type, keyword)) rows.push({ ...r, layer: "team", id: `team:${teamDir}:${r.path}` })
|
|
316
|
+
}
|
|
317
|
+
rows.sort((a, b) => (b.ts ?? 0) - (a.ts ?? 0))
|
|
318
|
+
return rows
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Disk-truth file scan for the project/team layer (2026-09-05 fix — see matchMemoryRows):
|
|
323
|
+
* readdir + parse every .md entry in dir, filter by type equality and keyword substring
|
|
324
|
+
* on title OR content (case-insensitive — SQLite LIKE parity). ts = file mtime (ms).
|
|
325
|
+
* Rows come back WITHOUT the layer field — the caller stamps layer and builds the uid.
|
|
326
|
+
*/
|
|
327
|
+
async function diskFileRows(dir, type, keyword) {
|
|
328
|
+
let names
|
|
329
|
+
try {
|
|
330
|
+
names = (await readdir(dir)).filter((n) => n.endsWith(".md"))
|
|
331
|
+
} catch {
|
|
332
|
+
return []
|
|
333
|
+
}
|
|
334
|
+
const kw = keyword ? keyword.toLowerCase() : null
|
|
335
|
+
const out = []
|
|
336
|
+
for (const name of names) {
|
|
337
|
+
try {
|
|
338
|
+
const abs = join(dir, name)
|
|
339
|
+
const { meta, content } = parseEntry(await readFile(abs, "utf8"))
|
|
340
|
+
if (type && meta.type !== type) continue
|
|
341
|
+
if (kw && !(meta.title.toLowerCase().includes(kw) || content.toLowerCase().includes(kw))) continue
|
|
342
|
+
const mtime = Math.floor((await stat(abs)).mtimeMs)
|
|
343
|
+
out.push({ path: name, type: meta.type, title: meta.title, ts: mtime })
|
|
344
|
+
} catch (e) {
|
|
345
|
+
console.error(`[memory] skip ${name}: ${e.message}`)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
out.sort((a, b) => b.ts - a.ts)
|
|
349
|
+
return out
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* §6 batch delete (action delete + type/keyword filter, confirm handled by the tool layer):
|
|
354
|
+
* deletes every row matchMemoryRows returns for the scope. Personal rows go straight to the
|
|
355
|
+
* DB (FTS + embedding cleanup via row triggers); project/team rows delete the markdown file
|
|
356
|
+
* (path containment enforced, ENOENT tolerated) then re-sync the layer dir once (index
|
|
357
|
+
* cleanup single source). Match surface = disk scan (2026-09-05 fix — orphans on disk
|
|
358
|
+
* with no index row are matched and deleted in the same pass; the trailing syncDir
|
|
359
|
+
* re-indexes the survivors). Team deletion never touches git — a later gitmem pull may
|
|
360
|
+
* resurrect the file while the remote still has it (same semantics as deleteByUid).
|
|
361
|
+
* Returns the number of deleted rows.
|
|
362
|
+
*/
|
|
363
|
+
export async function deleteWhere(memory, { scope, type = null, keyword = null } = {}, { dirs = {} } = {}) {
|
|
364
|
+
const rows = await matchMemoryRows(memory, { scope, type, keyword, projectDir: dirs.project ?? null, teamDir: dirs.team ?? null })
|
|
365
|
+
if (rows.length === 0) return 0
|
|
366
|
+
const personalIds = []
|
|
367
|
+
const byDir = new Map() // "layer\x00dir" → { layer, dir, paths: [] }
|
|
368
|
+
for (const r of rows) {
|
|
369
|
+
if (r.layer === "personal") {
|
|
370
|
+
const id = Number(String(r.uid).split(":")[1])
|
|
371
|
+
if (Number.isInteger(id)) personalIds.push(id)
|
|
372
|
+
continue
|
|
373
|
+
}
|
|
374
|
+
const dir = r.layer === "project" ? dirs.project : dirs.team
|
|
375
|
+
if (!dir) continue
|
|
376
|
+
const key = `${r.layer}\x00${dir}`
|
|
377
|
+
let group = byDir.get(key)
|
|
378
|
+
if (!group) { group = { layer: r.layer, dir, paths: [] }; byDir.set(key, group) }
|
|
379
|
+
group.paths.push(r.path)
|
|
380
|
+
}
|
|
381
|
+
const del = memory.db.prepare(`DELETE FROM entries WHERE id = ?`)
|
|
382
|
+
for (const id of personalIds) del.run(id)
|
|
383
|
+
for (const group of byDir.values()) {
|
|
384
|
+
for (const path of group.paths) {
|
|
385
|
+
assertPathInside(group.dir, path)
|
|
386
|
+
const abs = join(group.dir, path)
|
|
387
|
+
await unlink(abs).catch((e) => { if (e.code !== "ENOENT") throw e })
|
|
388
|
+
}
|
|
389
|
+
await syncDir(memory, { layer: group.layer, dir: group.dir })
|
|
390
|
+
}
|
|
391
|
+
return rows.length
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** §6 clear action: wipe ALL personal entries (pure DB rows — files are project/team only).
|
|
395
|
+
* FTS + embedding go with the row triggers. Returns the number of deleted rows. */
|
|
396
|
+
export function clearPersonal(memory) {
|
|
397
|
+
const { changes } = memory.db.prepare(`DELETE FROM entries`).run()
|
|
398
|
+
return changes
|
|
399
|
+
}
|
|
400
|
+
|
|
275
401
|
/** Delete a memory entry by unified id. Returns the deleted entry (F3: { id, layer, type, title, content, tags }).
|
|
276
402
|
* - personal:<n> (or bare <n>) → DELETE the entries row; FTS syncs via the entries_ad trigger and the
|
|
277
403
|
* embedding BLOB column goes with the row.
|