thincoder 0.12.59 → 0.12.60

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.
Files changed (127) hide show
  1. package/CHANGELOG.md +38 -3
  2. package/README.md +2 -2
  3. package/bin/thincoder.mjs +80 -19
  4. package/package.json +4 -3
  5. package/src/acp/bridge.mjs +7 -4
  6. package/src/advisor/messages.mjs +24 -4
  7. package/src/advisor/run.mjs +35 -33
  8. package/src/advisor.mjs +25 -6
  9. package/src/agent/completion.mjs +17 -11
  10. package/src/agent/dispatch.mjs +102 -19
  11. package/src/agent/helpers.mjs +36 -0
  12. package/src/agent/record-results.mjs +46 -10
  13. package/src/agent/run-stages.mjs +227 -0
  14. package/src/agent/setup-reminders.mjs +62 -0
  15. package/src/agent/setup.mjs +18 -2
  16. package/src/agent/spawn-child.mjs +29 -4
  17. package/src/agent-tools/advisor-async.mjs +456 -0
  18. package/src/agent-tools/advisor.mjs +110 -108
  19. package/src/agent-tools/async-settle.mjs +191 -0
  20. package/src/agent-tools/consult.mjs +121 -102
  21. package/src/agent-tools/design-token.mjs +104 -0
  22. package/src/agent-tools/eng.mjs +24 -29
  23. package/src/agent-tools/escalate-async.mjs +286 -0
  24. package/src/agent-tools/read-history.mjs +155 -31
  25. package/src/agent-tools/recent-changes.mjs +2 -1
  26. package/src/agent-tools/settings.mjs +7 -17
  27. package/src/agent-tools/subagent-actions.mjs +168 -130
  28. package/src/agent-tools/subagent-async.mjs +129 -174
  29. package/src/agent-tools/subagent-panel.mjs +153 -0
  30. package/src/agent-tools/subagent-run.mjs +202 -0
  31. package/src/agent-tools/subagent-scheduler.mjs +45 -21
  32. package/src/agent-tools/subagent-spawn.mjs +406 -0
  33. package/src/agent-tools/subagent.mjs +107 -555
  34. package/src/agent-tools/verify.mjs +118 -270
  35. package/src/agent.mjs +57 -190
  36. package/src/cli/distill-command.mjs +10 -4
  37. package/src/cli/make-agent.mjs +3 -1
  38. package/src/cli/memory-command.mjs +2 -1
  39. package/src/cli/permission.mjs +2 -2
  40. package/src/cli/setup-wizard.mjs +17 -12
  41. package/src/config.mjs +56 -8
  42. package/src/context.mjs +5 -147
  43. package/src/crash-reports.mjs +123 -0
  44. package/src/distill.mjs +11 -11
  45. package/src/explore-distill.mjs +155 -0
  46. package/src/memory/code-sync.mjs +2 -1
  47. package/src/memory/core.mjs +6 -193
  48. package/src/memory/delete.mjs +234 -0
  49. package/src/memory/docs.mjs +58 -48
  50. package/src/memory.mjs +3 -1
  51. package/src/peer-domains.mjs +265 -0
  52. package/src/peer-instances.mjs +231 -0
  53. package/src/prompt-overlays.mjs +25 -0
  54. package/src/prompts/advisor-design.md +9 -76
  55. package/src/prompts/advisor-round1.md +9 -68
  56. package/src/prompts/advisor-round2.md +7 -54
  57. package/src/prompts/advisor-round3.md +7 -54
  58. package/src/prompts/coder.md +7 -50
  59. package/src/prompts/consult-base.md +4 -24
  60. package/src/prompts/discipline.md +26 -44
  61. package/src/prompts/eng-coder.md +7 -32
  62. package/src/prompts/engineering-sub.md +3 -23
  63. package/src/prompts/engineering.md +53 -306
  64. package/src/prompts/explore.md +3 -12
  65. package/src/prompts/main.md +10 -32
  66. package/src/prompts/methodology-template.md +28 -48
  67. package/src/prompts/plan.md +2 -9
  68. package/src/prompts/system.md +16 -35
  69. package/src/provider/core.mjs +6 -67
  70. package/src/provider/errors.mjs +76 -0
  71. package/src/provider/retry.mjs +8 -45
  72. package/src/session-gc.mjs +214 -0
  73. package/src/session-guard.mjs +47 -0
  74. package/src/session-rename.mjs +38 -0
  75. package/src/session-slots.mjs +181 -58
  76. package/src/session.mjs +48 -89
  77. package/src/token-ttl.mjs +273 -0
  78. package/src/tools/checklist-sync.mjs +181 -0
  79. package/src/tools/checklist.mjs +52 -39
  80. package/src/tools/edit-batch.mjs +109 -10
  81. package/src/tools/edit-diff.mjs +110 -27
  82. package/src/tools/edit.md +17 -12
  83. package/src/tools/execute.mjs +31 -4
  84. package/src/tools/file.mjs +11 -6
  85. package/src/tools/git.mjs +14 -6
  86. package/src/tools/glob-dialect.mjs +130 -0
  87. package/src/tools/glob.md +3 -3
  88. package/src/tools/grep.md +1 -1
  89. package/src/tools/index.mjs +5 -6
  90. package/src/tools/ops.mjs +175 -3
  91. package/src/tools/patch.mjs +3 -3
  92. package/src/tools/question.md +3 -0
  93. package/src/tools/read.md +0 -1
  94. package/src/tools/shared.mjs +14 -13
  95. package/src/tools/system.mjs +44 -9
  96. package/src/tools/wait_for.md +22 -0
  97. package/src/tui/agent-turn.mjs +17 -228
  98. package/src/tui/cmd-config.mjs +48 -7
  99. package/src/tui/cmd-eng.mjs +20 -16
  100. package/src/tui/cmd-mcp.mjs +8 -2
  101. package/src/tui/cmd-new.mjs +3 -2
  102. package/src/tui/cmd-session.mjs +19 -4
  103. package/src/tui/cmd-think.mjs +10 -10
  104. package/src/tui/cmd-upgrade.mjs +19 -4
  105. package/src/tui/config-helpers.mjs +28 -16
  106. package/src/tui/distill-cmd.mjs +1 -1
  107. package/src/tui/index.mjs +3 -2
  108. package/src/tui/interaction.mjs +3 -3
  109. package/src/tui/mouse.mjs +7 -1
  110. package/src/tui/pickers.mjs +40 -22
  111. package/src/tui/render-segments.mjs +27 -10
  112. package/src/tui/startup.mjs +4 -0
  113. package/src/tui/subagent-blocks.mjs +95 -263
  114. package/src/tui/subagent-children.mjs +176 -0
  115. package/src/tui/subagent-freeze.mjs +172 -0
  116. package/src/tui/subagent-panel.mjs +61 -23
  117. package/src/tui/suspension-drive.mjs +351 -0
  118. package/src/tui/tool-args.mjs +3 -3
  119. package/src/tui/tool-display.mjs +142 -0
  120. package/src/tui/tool-events.mjs +37 -173
  121. package/src/tui/tui-lifecycle.mjs +29 -0
  122. package/src/tui/update-notice.mjs +4 -0
  123. package/src/tui/wizard.mjs +12 -6
  124. package/src/tools/pdf-parse-text.mjs +0 -497
  125. package/src/tools/pdf-parse-xref.mjs +0 -499
  126. package/src/tools/pdf.mjs +0 -155
  127. package/src/tools/read_pdf.md +0 -21
@@ -1,11 +1,14 @@
1
1
  /**
2
- * memory/core.mjs — memory CRUD, hybrid retrieval, embedding management
2
+ * memory/core.mjs — memory CRUD, hybrid retrieval, embedding management.
3
+ * The disk-truth row-match + deletion family (matchMemoryRows/deleteWhere/deleteByUid/remove/
4
+ * assertPathInside) lives in delete.mjs — this module imports nothing from it (delete.mjs
5
+ * imports fetchEntry/syncDir from here).
3
6
  */
4
7
 
5
8
  import { parseEntry, serializeEntry, entryFilename } from "../markdown.mjs"
6
9
  import { embed, cosine, toBlob, fromBlob } from "../embedding.mjs"
7
- import { readFile, stat, readdir, writeFile, mkdir, unlink } from "node:fs/promises"
8
- import { join, resolve } from "node:path"
10
+ import { readFile, stat, readdir, writeFile, mkdir } from "node:fs/promises"
11
+ import { join } from "node:path"
9
12
  import { segmentCJK, VALID_TYPES, SCHEMA_VERSION } from "./schema.mjs"
10
13
 
11
14
  const EMBED_BATCH_SIZE = 256
@@ -272,125 +275,6 @@ export async function list(memory, { type, limit = DEFAULT_LIST_LIMIT } = {}) {
272
275
  .all(limit)
273
276
  }
274
277
 
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
278
  /** §6 clear action: wipe ALL personal entries (pure DB rows — files are project/team only).
395
279
  * FTS + embedding go with the row triggers. Returns the number of deleted rows. */
396
280
  export function clearPersonal(memory) {
@@ -398,77 +282,6 @@ export function clearPersonal(memory) {
398
282
  return changes
399
283
  }
400
284
 
401
- /** Delete a memory entry by unified id. Returns the deleted entry (F3: { id, layer, type, title, content, tags }).
402
- * - personal:<n> (or bare <n>) → DELETE the entries row; FTS syncs via the entries_ad trigger and the
403
- * embedding BLOB column goes with the row.
404
- * - project:<origin>:<path> / team:<origin>:<path> → delete the markdown file (path must resolve inside
405
- * the layer dir — dirs[layer], passed by the caller — `..`/absolute variants (incl. `..\`) are rejected),
406
- * then syncDir clears the files row (single source of index cleanup). ENOENT on the file is treated as
407
- * already-deleted and continues. Team deletion never touches git (git propagation is gitmem's job; a
408
- * later gitmem pull may resurrect the file while the remote still has it).
409
- * Throws on invalid id / missing entry (NF2) / path escaping the layer dir. */
410
- export async function deleteByUid(memory, uid, { dirs = {} } = {}) {
411
- const norm = /^\d+$/.test(uid) ? `personal:${uid}` : String(uid)
412
- const [layer, ...rest] = norm.split(":")
413
- if (layer === "personal") {
414
- const id = rest[0] ?? ""
415
- if (!/^\d+$/.test(id)) throw new Error(`invalid memory id: ${norm}`)
416
- const entry = fetchEntry(memory, norm)
417
- if (!entry) throw new Error(`memory ${norm} not found in scope personal`)
418
- memory.db.prepare(`DELETE FROM entries WHERE id = ?`).run(Number(id))
419
- return entry
420
- }
421
- if (layer !== "project" && layer !== "team") throw new Error(`invalid memory id: ${norm}`)
422
- const dir = dirs[layer]
423
- if (!dir) throw new Error(`${layer} scope unavailable: no ${layer} directory configured`)
424
- // path = segment after the LAST colon — origins may contain colons (Windows drive letters)
425
- const lastColon = norm.lastIndexOf(":")
426
- const path = lastColon > layer.length ? norm.slice(lastColon + 1) : norm.slice(layer.length + 1)
427
- assertPathInside(dir, path)
428
- let entry = fetchFileEntry(memory, layer, norm, path)
429
- const abs = join(dir, path)
430
- let fileExists = false
431
- try { await stat(abs); fileExists = true } catch { /* ENOENT — treat as already deleted */ }
432
- if (!entry && fileExists) {
433
- try {
434
- const { meta, content } = parseEntry(await readFile(abs, "utf8"))
435
- entry = { layer, id: norm, type: meta.type, title: meta.title, content, tags: meta.tags.join(" ") }
436
- } catch { /* malformed file — keep the DB row (or null → not found below) */ }
437
- }
438
- if (!entry) throw new Error(`memory ${norm} not found in scope ${layer}`)
439
- if (fileExists) await unlink(abs).catch((e) => { if (e.code !== "ENOENT") throw e })
440
- await syncDir(memory, { layer, dir })
441
- return entry
442
- }
443
-
444
- /** Legacy personal-only delete (bare numeric id) — kept as the compat surface over deleteByUid. */
445
- export async function remove(memory, id) {
446
- const uid = /^\d+$/.test(String(id)) ? `personal:${id}` : String(id)
447
- if (!fetchEntry(memory, uid)) return false
448
- await deleteByUid(memory, uid, {})
449
- return true
450
- }
451
-
452
- /** Fetch a project/team file row for deletion: fetchEntry first, then a path-only fallback
453
- * (origins with Windows drive letters, e.g. project:C:\dir:file.md, break naive ":" splitting). */
454
- function fetchFileEntry(memory, layer, uid, path) {
455
- const entry = fetchEntry(memory, uid)
456
- if (entry) return entry
457
- const r = memory.db.prepare(`SELECT type, title, content, tags, author FROM files WHERE layer = ? AND path = ?`).get(layer, path)
458
- return r ? { ...r, layer, id: uid } : null
459
- }
460
-
461
- /** Separator-agnostic containment check: the resolved path must stay inside dir.
462
- * Both / and \ count as separators, so Windows-style traversal (..\..\x) is caught on every platform. */
463
- function assertPathInside(dir, path) {
464
- if (!path) throw new Error(`invalid memory id: empty path`)
465
- const base = resolve(dir).replaceAll("\\", "/")
466
- const abs = resolve(dir, path.replaceAll("\\", "/")).replaceAll("\\", "/")
467
- if (abs !== base && !abs.startsWith(base + "/")) {
468
- throw new Error(`invalid memory path "${path}": must stay within ${dir}`)
469
- }
470
- }
471
-
472
285
  /**
473
286
  * Build an FTS5 query: first split by whitespace/punctuation into tokens,
474
287
  * then apply CJK character segmentation to each token.
@@ -0,0 +1,234 @@
1
+ /**
2
+ * memory/delete.mjs — file-layer disk-truth row match + deletion family.
3
+ *
4
+ * Split out of core.mjs (2026-09-08 — core.mjs crossed the 500-line hard limit).
5
+ * Owns: the shared §6 list/batch-delete row match (matchMemoryRows/diskFileRows/
6
+ * likePattern — one match surface, MEMORY.md §6), single/batch deletion
7
+ * (deleteByUid/deleteWhere), the legacy personal-only compat shell (remove),
8
+ * file-row fetching and path containment (fetchFileEntry/assertPathInside).
9
+ *
10
+ * Imports CRUD/retrieval/sync primitives from core.mjs (fetchEntry/syncDir) —
11
+ * one direction, no cycle.
12
+ */
13
+
14
+ import { parseEntry } from "../markdown.mjs"
15
+ import { readFile, stat, readdir, unlink } from "node:fs/promises"
16
+ import { join, resolve } from "node:path"
17
+ import { fetchEntry, syncDir } from "./core.mjs"
18
+
19
+ /** LIKE pattern from a keyword (wildcards escaped — literal substring match, MEMORY.md §6 keyword filter). */
20
+ function likePattern(keyword) {
21
+ return `%${keyword.replace(/[\\%_]/g, (c) => `\\${c}`)}%`
22
+ }
23
+
24
+ /**
25
+ * Shared row query for the §6 list action and the §6 batch delete (one match surface —
26
+ * rows carry { layer, id, type, title, ts }). Filters:
27
+ * layer: "personal" | "project" | "team" | null (null = all layers)
28
+ * type / keyword: optional (keyword matches title OR content, substring)
29
+ * Personal rows come from the entries table; project/team rows come from a DISK scan of
30
+ * the managed dir (2026-09-05 fix — disk is the truth): files present on disk but
31
+ * missing from the files index (orphans: external copies / gitmem pull / an earlier
32
+ * index failure) were invisible to list AND immune to batch delete — the old table-only
33
+ * match surface made a layer wipe need repeated delete rounds (deleteWhere→syncDir
34
+ * re-indexed the orphans one round later). Scanning disk keeps list and batch delete
35
+ * consistent with what the user can see and delete. Rows from other projects'/team
36
+ * repos' dirs stay out (the scan only covers the dirs this memory context manages).
37
+ * Malformed files are skipped (parseEntry failure — same semantics as syncDir). Sorted
38
+ * by ts (created/updated, ms) DESC.
39
+ */
40
+ export async function matchMemoryRows(memory, { layer = null, type = null, keyword = null, projectDir = null, teamDir = null } = {}) {
41
+ const rows = []
42
+ const wantLayer = (l) => !layer || layer === l
43
+ if (wantLayer("personal")) {
44
+ let sql = `SELECT id, type, title, created_at AS ts FROM entries`
45
+ const cond = []
46
+ const params = []
47
+ if (type) { cond.push("type = ?"); params.push(type) }
48
+ if (keyword) { cond.push("(title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\')"); const p = likePattern(keyword); params.push(p, p) }
49
+ if (cond.length) sql += " WHERE " + cond.join(" AND ")
50
+ sql += " ORDER BY created_at DESC"
51
+ for (const r of memory.db.prepare(sql).all(...params)) {
52
+ rows.push({ layer: "personal", id: `personal:${r.id}`, uid: `personal:${r.id}`, type: r.type, title: r.title, ts: r.ts })
53
+ }
54
+ }
55
+ if (wantLayer("project") && projectDir) {
56
+ for (const r of await diskFileRows(projectDir, type, keyword)) rows.push({ ...r, layer: "project", id: `project:${projectDir}:${r.path}` })
57
+ }
58
+ if (wantLayer("team") && teamDir) {
59
+ for (const r of await diskFileRows(teamDir, type, keyword)) rows.push({ ...r, layer: "team", id: `team:${teamDir}:${r.path}` })
60
+ }
61
+ rows.sort((a, b) => (b.ts ?? 0) - (a.ts ?? 0))
62
+ return rows
63
+ }
64
+
65
+ /**
66
+ * Disk-truth file scan for the project/team layer (2026-09-05 fix — see matchMemoryRows):
67
+ * readdir + parse every .md entry in dir, filter by type equality and keyword substring
68
+ * on title OR content (case-insensitive — SQLite LIKE parity). ts = file mtime (ms).
69
+ * Rows come back WITHOUT the layer field — the caller stamps layer and builds the uid.
70
+ */
71
+ async function diskFileRows(dir, type, keyword) {
72
+ let names
73
+ try {
74
+ names = (await readdir(dir)).filter((n) => n.endsWith(".md"))
75
+ } catch {
76
+ return []
77
+ }
78
+ const kw = keyword ? keyword.toLowerCase() : null
79
+ const out = []
80
+ for (const name of names) {
81
+ try {
82
+ const abs = join(dir, name)
83
+ const { meta, content } = parseEntry(await readFile(abs, "utf8"))
84
+ if (type && meta.type !== type) continue
85
+ if (kw && !(meta.title.toLowerCase().includes(kw) || content.toLowerCase().includes(kw))) continue
86
+ const mtime = Math.floor((await stat(abs)).mtimeMs)
87
+ out.push({ path: name, type: meta.type, title: meta.title, ts: mtime })
88
+ } catch (e) {
89
+ console.error(`[memory] skip ${name}: ${e.message}`)
90
+ }
91
+ }
92
+ out.sort((a, b) => b.ts - a.ts)
93
+ return out
94
+ }
95
+
96
+ /**
97
+ * §6 batch delete (action delete + type/keyword filter, confirm handled by the tool layer):
98
+ * deletes every row matchMemoryRows returns for the layer. Personal rows go straight to the
99
+ * DB (FTS + embedding cleanup via row triggers); project/team rows delete the markdown file
100
+ * (path containment enforced, ENOENT tolerated) then re-sync the layer dir once (index
101
+ * cleanup single source). Match surface = disk scan (2026-09-05 fix — orphans on disk
102
+ * with no index row are matched and deleted in the same pass; the trailing syncDir
103
+ * re-indexes the survivors). Team deletion never touches git — a later gitmem pull may
104
+ * resurrect the file while the remote still has it (same semantics as deleteByUid).
105
+ * Returns the number of deleted rows.
106
+ */
107
+ export async function deleteWhere(memory, { layer, type = null, keyword = null } = {}, { dirs = {} } = {}) {
108
+ const rows = await matchMemoryRows(memory, { layer, type, keyword, projectDir: dirs.project ?? null, teamDir: dirs.team ?? null })
109
+ if (rows.length === 0) return 0
110
+ const personalIds = []
111
+ const byDir = new Map() // "layer\x00dir" → { layer, dir, paths: [] }
112
+ for (const r of rows) {
113
+ if (r.layer === "personal") {
114
+ const id = Number(String(r.uid).split(":")[1])
115
+ if (Number.isInteger(id)) personalIds.push(id)
116
+ continue
117
+ }
118
+ const dir = r.layer === "project" ? dirs.project : dirs.team
119
+ if (!dir) continue
120
+ const key = `${r.layer}\x00${dir}`
121
+ let group = byDir.get(key)
122
+ if (!group) { group = { layer: r.layer, dir, paths: [] }; byDir.set(key, group) }
123
+ group.paths.push(r.path)
124
+ }
125
+ const del = memory.db.prepare(`DELETE FROM entries WHERE id = ?`)
126
+ for (const id of personalIds) del.run(id)
127
+ for (const group of byDir.values()) {
128
+ for (const path of group.paths) {
129
+ assertPathInside(group.dir, path)
130
+ const abs = join(group.dir, path)
131
+ await unlink(abs).catch((e) => { if (e.code !== "ENOENT") throw e })
132
+ }
133
+ await syncDir(memory, { layer: group.layer, dir: group.dir })
134
+ }
135
+ return rows.length
136
+ }
137
+
138
+ /** Delete a memory entry by unified id. Returns the deleted entry (F3: { id, layer, type, title, content, tags }).
139
+ * - personal:<n> (or bare <n>) → DELETE the entries row; FTS syncs via the entries_ad trigger and the
140
+ * embedding BLOB column goes with the row.
141
+ * - project:<origin>:<path> / team:<origin>:<path> → delete the markdown file at the uid's ORIGIN dir
142
+ * (2026-09-08, MEMORY.md §6.2: search rows surfaced from a non-current origin — another project's
143
+ * memory dir / another team clone — must be deletable where the file actually lives; the caller's
144
+ * dirs[layer] is only the fallback base for origin-less legacy uids `project::file.md`/`project:file.md`).
145
+ * Path containment is enforced against that base (assertPathInside — `..`/absolute variants incl.
146
+ * `..\` are rejected), then syncDir clears the files row (single source of index cleanup). ENOENT on
147
+ * the file is treated as already-deleted and continues; when the origin dir is missing locally,
148
+ * syncDir still drops the stale (layer, origin) index rows (readdir of a missing dir yields nothing —
149
+ * every row of a vanished dir is stale). Team deletion never touches git (git propagation is gitmem's
150
+ * job; a later gitmem pull may resurrect the file while the remote still has it).
151
+ * Throws on invalid id / missing entry (NF2) / path escaping the base dir.
152
+ * 2026-09-08 (code review): the orphan-disk fallback below is confined to managed memory dirs —
153
+ * an unmanaged origin must not make deleteByUid a crafted-id deletion primitive. */
154
+ export async function deleteByUid(memory, uid, { dirs = {} } = {}) {
155
+ const norm = /^\d+$/.test(uid) ? `personal:${uid}` : String(uid)
156
+ const [layer, ...rest] = norm.split(":")
157
+ if (layer === "personal") {
158
+ const id = rest[0] ?? ""
159
+ if (!/^\d+$/.test(id)) throw new Error(`invalid memory id: ${norm}`)
160
+ const entry = fetchEntry(memory, norm)
161
+ if (!entry) throw new Error(`memory ${norm} not found in layer personal`)
162
+ memory.db.prepare(`DELETE FROM entries WHERE id = ?`).run(Number(id))
163
+ return entry
164
+ }
165
+ if (layer !== "project" && layer !== "team") throw new Error(`invalid memory id: ${norm}`)
166
+ // origin = between the layer prefix and the LAST colon (origins may contain colons — Windows drive
167
+ // letters); path = segment after the last colon. Empty origin = legacy uid (`project::file.md` /
168
+ // `project:file.md`) → dirs[layer] fallback.
169
+ const lastColon = norm.lastIndexOf(":")
170
+ const origin = lastColon > layer.length ? norm.slice(layer.length + 1, lastColon) : ""
171
+ const path = lastColon > layer.length ? norm.slice(lastColon + 1) : norm.slice(layer.length + 1)
172
+ const dir = origin || dirs[layer]
173
+ if (!dir) throw new Error(`${layer} layer unavailable: no ${layer} directory configured for an origin-less id`)
174
+ assertPathInside(dir, path)
175
+ // Entry lookup: exact (layer, origin, path) row for origin-ful uids — the delete target is the uid's
176
+ // own dir, so a path-only fallback row from another origin must not be reported as deleted.
177
+ // Origin-less legacy uids keep the compat fallbacks (fetchFileEntry: current-origin then path-only).
178
+ let entry = null
179
+ if (origin) {
180
+ const r = memory.db.prepare(`SELECT type, title, content, tags, author FROM files WHERE layer = ? AND origin = ? AND path = ?`).get(layer, origin, path)
181
+ if (r) entry = { ...r, layer, id: norm }
182
+ } else {
183
+ entry = fetchFileEntry(memory, layer, norm, path)
184
+ }
185
+ const abs = join(dir, path)
186
+ let fileExists = false
187
+ try { await stat(abs); fileExists = true } catch { /* ENOENT — treat as already deleted */ }
188
+ if (!entry && fileExists) {
189
+ // Origin confinement (2026-09-08 code review): the orphan fallback deletes a file that has NO
190
+ // index row — only allow it when the origin is a managed memory dir (it already has files rows,
191
+ // or it is the caller's current layer dir). A uid naming an unmanaged directory must not delete
192
+ // a memory-shaped .md there.
193
+ if (origin && origin !== dirs[layer]) {
194
+ const known = memory.db.prepare(`SELECT 1 FROM files WHERE layer = ? AND origin = ? LIMIT 1`).get(layer, origin)
195
+ if (!known) throw new Error(`memory ${norm} not found in layer ${layer}: origin is not a managed ${layer} memory dir`)
196
+ }
197
+ try {
198
+ const { meta, content } = parseEntry(await readFile(abs, "utf8"))
199
+ entry = { layer, id: norm, type: meta.type, title: meta.title, content, tags: meta.tags.join(" ") }
200
+ } catch { /* malformed file — keep the DB row (or null → not found below) */ }
201
+ }
202
+ if (!entry) throw new Error(`memory ${norm} not found in layer ${layer}`)
203
+ if (fileExists) await unlink(abs).catch((e) => { if (e.code !== "ENOENT") throw e })
204
+ await syncDir(memory, { layer, dir })
205
+ return entry
206
+ }
207
+
208
+ /** Legacy personal-only delete (bare numeric id) — kept as the compat surface over deleteByUid. */
209
+ export async function remove(memory, id) {
210
+ const uid = /^\d+$/.test(String(id)) ? `personal:${id}` : String(id)
211
+ if (!fetchEntry(memory, uid)) return false
212
+ await deleteByUid(memory, uid, {})
213
+ return true
214
+ }
215
+
216
+ /** Fetch a project/team file row for deletion: fetchEntry first, then a path-only fallback
217
+ * (origins with Windows drive letters, e.g. project:C:\dir:file.md, break naive ":" splitting). */
218
+ function fetchFileEntry(memory, layer, uid, path) {
219
+ const entry = fetchEntry(memory, uid)
220
+ if (entry) return entry
221
+ const r = memory.db.prepare(`SELECT type, title, content, tags, author FROM files WHERE layer = ? AND path = ?`).get(layer, path)
222
+ return r ? { ...r, layer, id: uid } : null
223
+ }
224
+
225
+ /** Separator-agnostic containment check: the resolved path must stay inside dir.
226
+ * Both / and \ count as separators, so Windows-style traversal (..\..\\x) is caught on every platform. */
227
+ function assertPathInside(dir, path) {
228
+ if (!path) throw new Error(`invalid memory id: empty path`)
229
+ const base = resolve(dir).replaceAll("\\", "/")
230
+ const abs = resolve(dir, path.replaceAll("\\", "/")).replaceAll("\\", "/")
231
+ if (abs !== base && !abs.startsWith(base + "/")) {
232
+ throw new Error(`invalid memory path "${path}": must stay within ${dir}`)
233
+ }
234
+ }