thincoder 0.12.54 → 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.
Files changed (129) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +1 -1
  3. package/bin/thincoder.mjs +25 -3
  4. package/package.json +3 -7
  5. package/src/acp/bridge.mjs +132 -26
  6. package/src/advisor/messages.mjs +38 -3
  7. package/src/advisor/run.mjs +91 -53
  8. package/src/advisor.mjs +15 -7
  9. package/src/agent/dispatch.mjs +156 -39
  10. package/src/agent/helpers.mjs +46 -4
  11. package/src/agent/setup.mjs +102 -19
  12. package/src/agent/spawn-child.mjs +28 -1
  13. package/src/agent-tools/advisor.mjs +43 -11
  14. package/src/agent-tools/consult.mjs +37 -6
  15. package/src/agent-tools/eng.mjs +4 -1
  16. package/src/agent-tools/goal.mjs +11 -1
  17. package/src/agent-tools/read-history.mjs +160 -0
  18. package/src/agent-tools/settings.mjs +162 -0
  19. package/src/agent-tools/skill.mjs +2 -1
  20. package/src/agent-tools/subagent-actions.mjs +432 -0
  21. package/src/agent-tools/subagent-async.mjs +427 -0
  22. package/src/agent-tools/subagent-scheduler.mjs +319 -0
  23. package/src/agent-tools/subagent.mjs +565 -128
  24. package/src/agent-tools/task.mjs +4 -3
  25. package/src/agent-tools/timer.mjs +9 -4
  26. package/src/agent-tools/verify.mjs +161 -49
  27. package/src/agent-tools.mjs +1 -0
  28. package/src/agent.mjs +182 -81
  29. package/src/auto-think.mjs +14 -0
  30. package/src/cli/make-agent.mjs +27 -1
  31. package/src/cli/memory-command.mjs +28 -7
  32. package/src/cli/permission.mjs +8 -1
  33. package/src/config.mjs +125 -8
  34. package/src/context.mjs +115 -34
  35. package/src/distill.mjs +19 -1
  36. package/src/escape.mjs +82 -27
  37. package/src/log.mjs +195 -0
  38. package/src/mcp/transport-http.mjs +13 -1
  39. package/src/mcp.mjs +52 -7
  40. package/src/memory/code-sync.mjs +1 -1
  41. package/src/memory/core.mjs +204 -10
  42. package/src/memory/docs.mjs +197 -62
  43. package/src/memory.mjs +1 -1
  44. package/src/model-specs.mjs +38 -1
  45. package/src/prompts/advisor-design.md +46 -0
  46. package/src/prompts/advisor-round1.md +49 -2
  47. package/src/prompts/advisor-round2.md +47 -0
  48. package/src/prompts/advisor-round3.md +47 -0
  49. package/src/prompts/coder.md +22 -0
  50. package/src/prompts/consult-base.md +13 -0
  51. package/src/prompts/discipline.md +25 -6
  52. package/src/prompts/eng-coder.md +2 -2
  53. package/src/prompts/engineering-sub.md +23 -1
  54. package/src/prompts/engineering.md +157 -50
  55. package/src/prompts/explore.md +1 -2
  56. package/src/prompts/main.md +11 -5
  57. package/src/prompts/methodology-template.md +14 -0
  58. package/src/prompts/system.md +5 -2
  59. package/src/provider/anthropic.mjs +7 -5
  60. package/src/provider/core.mjs +104 -28
  61. package/src/provider/google.mjs +57 -24
  62. package/src/provider/normalize.mjs +1 -1
  63. package/src/provider/rate.mjs +0 -2
  64. package/src/provider/responses.mjs +8 -13
  65. package/src/provider/sse.mjs +20 -0
  66. package/src/session.mjs +15 -0
  67. package/src/tools/apply_patch.md +5 -1
  68. package/src/tools/bash.md +3 -3
  69. package/src/tools/delete.md +1 -0
  70. package/src/tools/edit-batch.mjs +92 -0
  71. package/src/tools/edit-diff.mjs +265 -0
  72. package/src/tools/edit.md +11 -6
  73. package/src/tools/execute.md +8 -8
  74. package/src/tools/execute.mjs +31 -35
  75. package/src/tools/file.mjs +26 -114
  76. package/src/tools/file_ops.md +3 -2
  77. package/src/tools/get_current_time.md +3 -1
  78. package/src/tools/git.md +1 -1
  79. package/src/tools/git.mjs +8 -16
  80. package/src/tools/hashline_edit.md +2 -0
  81. package/src/tools/index.mjs +3 -2
  82. package/src/tools/insert_after.md +2 -1
  83. package/src/tools/lint.md +3 -1
  84. package/src/tools/linter.mjs +9 -37
  85. package/src/tools/lsp.md +4 -1
  86. package/src/tools/patch.mjs +84 -13
  87. package/src/tools/pdf-parse-text.mjs +497 -0
  88. package/src/tools/pdf-parse-xref.mjs +499 -0
  89. package/src/tools/pdf.mjs +155 -0
  90. package/src/tools/question.md +2 -1
  91. package/src/tools/read.md +1 -0
  92. package/src/tools/read_pdf.md +21 -0
  93. package/src/tools/repomap.mjs +1 -1
  94. package/src/tools/shared.mjs +11 -32
  95. package/src/tools/system.mjs +6 -21
  96. package/src/tools/tree.md +2 -1
  97. package/src/tools/web.mjs +5 -3
  98. package/src/tools/websearch.md +2 -1
  99. package/src/tools/write.md +2 -0
  100. package/src/traces/trace-store.mjs +224 -0
  101. package/src/tui/agent-turn.mjs +387 -24
  102. package/src/tui/clipboard.mjs +17 -6
  103. package/src/tui/cmd-config.mjs +29 -9
  104. package/src/tui/cmd-eng.mjs +1 -0
  105. package/src/tui/cmd-extract.mjs +1 -1
  106. package/src/tui/cmd-mcp-form.mjs +197 -0
  107. package/src/tui/cmd-mcp.mjs +264 -114
  108. package/src/tui/cmd-think.mjs +1 -1
  109. package/src/tui/index.mjs +49 -95
  110. package/src/tui/interaction.mjs +41 -3
  111. package/src/tui/key-handler.mjs +105 -143
  112. package/src/tui/key-modes.mjs +215 -0
  113. package/src/tui/layout.mjs +22 -1
  114. package/src/tui/mouse.mjs +41 -1
  115. package/src/tui/pickers.mjs +73 -7
  116. package/src/tui/render-conversation.mjs +13 -161
  117. package/src/tui/render-frame.mjs +45 -20
  118. package/src/tui/render-loop.mjs +4 -1
  119. package/src/tui/render-segments.mjs +165 -0
  120. package/src/tui/render.mjs +4 -4
  121. package/src/tui/startup.mjs +40 -2
  122. package/src/tui/subagent-blocks.mjs +404 -111
  123. package/src/tui/subagent-panel.mjs +88 -13
  124. package/src/tui/tool-args.mjs +10 -2
  125. package/src/tui/tool-events.mjs +172 -95
  126. package/src/tui/update-notice.mjs +72 -0
  127. package/src/tui/wizard.mjs +36 -6
  128. package/src/agent-tools/escalate.mjs +0 -179
  129. package/src/tools/exec-prelude.mjs +0 -84
@@ -4,8 +4,8 @@
4
4
 
5
5
  import { parseEntry, serializeEntry, entryFilename } from "../markdown.mjs"
6
6
  import { embed, cosine, toBlob, fromBlob } from "../embedding.mjs"
7
- import { readFile, stat, readdir, writeFile, mkdir } from "node:fs/promises"
8
- import { join } from "node:path"
7
+ import { readFile, stat, readdir, writeFile, mkdir, unlink } from "node:fs/promises"
8
+ import { join, resolve } from "node:path"
9
9
  import { segmentCJK, VALID_TYPES, SCHEMA_VERSION } from "./schema.mjs"
10
10
 
11
11
  const EMBED_BATCH_SIZE = 256
@@ -92,11 +92,11 @@ export function ftsSearch(memory, ftsQuery, limit) {
92
92
  const originFilter = memory.projectOrigin ? `AND (f.layer = 'team' OR f.origin = ?)` : ""
93
93
  const originParams = memory.projectOrigin ? [ftsQuery, memory.projectOrigin, limit] : [ftsQuery, limit]
94
94
  const files = memory.db.prepare(`
95
- SELECT f.layer, f.path, f.type, f.title, f.content, f.tags, f.author, bm25(files_fts) AS rank
95
+ SELECT f.layer, f.origin, f.path, f.type, f.title, f.content, f.tags, f.author, bm25(files_fts) AS rank
96
96
  FROM files_fts JOIN files f ON f.rowid = files_fts.rowid
97
97
  WHERE files_fts MATCH ? ${originFilter}
98
98
  ORDER BY rank LIMIT ?
99
- `).all(...originParams).map((r) => ({ ...r, id: `${r.layer}:${r.origin ?? ""}:${r.path}` }))
99
+ `).all(...originParams).map((r) => ({ ...r, id: `${r.layer}:${r.origin}:${r.path}` }))
100
100
 
101
101
  return [...personal, ...files].sort((a, b) => a.rank - b.rank).slice(0, limit)
102
102
  }
@@ -110,9 +110,12 @@ export function fetchEntry(memory, uid) {
110
110
  const r = memory.db.prepare(`SELECT id, type, title, content, tags FROM entries WHERE id = ?`).get(Number(rest[0]))
111
111
  return r ? { ...r, layer, id: uid } : null
112
112
  }
113
- // rest = [origin, ...pathParts]; origin may be empty string (compat with old format)
114
- const origin = rest[0] ?? ""
115
- const path = rest.slice(1).join(":")
113
+ // Files branch: origins may contain colons (Windows drive letters, e.g. project:C:\dir:file.md),
114
+ // so the LAST colon is always the origin/path separator same parsing as deleteByUid.
115
+ // origin may be empty (compat with the old `project::file.md` format).
116
+ const lastColon = uid.lastIndexOf(":")
117
+ const origin = lastColon > layer.length ? uid.slice(layer.length + 1, lastColon) : ""
118
+ const path = lastColon > layer.length ? uid.slice(lastColon + 1) : uid.slice(layer.length + 1)
116
119
  if (layer === "project" && memory.projectOrigin) {
117
120
  const r = memory.db.prepare(`SELECT type, title, content, tags, author FROM files WHERE layer = ? AND origin = ? AND path = ?`).get(layer, origin || memory.projectOrigin, path)
118
121
  if (r) return { ...r, layer, id: uid }
@@ -269,10 +272,201 @@ export async function list(memory, { type, limit = DEFAULT_LIST_LIMIT } = {}) {
269
272
  .all(limit)
270
273
  }
271
274
 
272
- /** Delete a memory entry. Returns whether deletion succeeded */
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
+
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. */
273
445
  export async function remove(memory, id) {
274
- const info = memory.db.prepare(`DELETE FROM entries WHERE id = ?`).run(id)
275
- return info.changes > 0
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
+ }
276
470
  }
277
471
 
278
472
  /**
@@ -7,7 +7,7 @@ import { join } from "node:path"
7
7
  import { embed, cosine, toBlob, fromBlob } from "../embedding.mjs"
8
8
  import { commitAndPush } from "../git/gitmem.mjs"
9
9
  import { DOC_EXTS, SKIP_DIRS, MAX_DOC_FILE_BYTES } from "./schema.mjs"
10
- import { buildFtsQuery, put, search, putMarkdown, EMBED_TEXT_MAX_LEN } from "./core.mjs"
10
+ import { buildFtsQuery, put, search, putMarkdown, deleteByUid, matchMemoryRows, deleteWhere, clearPersonal, EMBED_TEXT_MAX_LEN } from "./core.mjs"
11
11
  import { _upsertDocFile, yieldTick } from "./code-index.mjs"
12
12
  import { markIndexedCommit, listProjectFiles } from "./code-sync.mjs"
13
13
 
@@ -164,7 +164,8 @@ export function docSearchTool(memory) {
164
164
  return {
165
165
  name: "doc_search",
166
166
  description:
167
- "Search the project's documentation (README, design docs, guides, markdown files) for relevant information. Use this to find design decisions, coding conventions, architecture docs, or project rules. Prefer this over code_search when you need to understand the project's intended design rather than existing implementation.",
167
+ "Search the project's documentation (README, design docs, guides, markdown files) for relevant information. Use this to find design decisions, coding conventions, architecture docs, or project rules. Prefer this over code_search when you need to understand the project's intended design rather than existing implementation. " +
168
+ "Returns matching doc chunks: path, heading, line range, relevance score, content excerpt.",
168
169
  parameters: {
169
170
  type: "object",
170
171
  properties: {
@@ -186,83 +187,217 @@ export function docSearchTool(memory) {
186
187
 
187
188
  // ---------------------------------------------------------------- agent tools
188
189
 
190
+ /** §6 shared tool surface — action enum / parameter shapes / descriptions byte-identical
191
+ * with thincoder-vscode/src/memory.mjs (MEMORY.md §6 D-M1/F-M6); scope VALUES per end
192
+ * (VS Code has no team layer and rejects it with CLI guidance). */
193
+ const MEMORY_ACTIONS = ["search", "put", "list", "delete", "clear"]
194
+ const MEMORY_SCOPES = ["personal", "project", "team"]
195
+ const MEMORY_TOOL_DESCRIPTION =
196
+ "Manage long-term memory in ONE tool — the action parameter picks the operation:\n" +
197
+ "- search — find knowledge saved in previous sessions (query, optional scope/limit); results include every entry's id\n" +
198
+ "- put — save a piece of knowledge for future sessions (type: rule = coding standards, knowledge = project facts, decision = architecture decisions, pattern = debugging/workflow patterns; title/content/tags/scope)\n" +
199
+ "- list — inventory what memory holds: optional scope/type/keyword filters, limit default 50; one row per entry: id [type] title (date); a truncated list notes the full count\n" +
200
+ "- delete — SINGLE: {id, scope} deletes one entry by the id shown in put/search/list output. BATCH: {scope + type and/or keyword} deletes every matching entry in that scope — a call without confirm:true is refused and returns the count plus a preview (re-send with confirm:true to execute); scope-wide wipes without filters are refused on every layer\n" +
201
+ "- clear — {scope: \"personal\", confirm: true} wipes ALL personal memory entries. clear is personal-only: a missing scope or a project/team scope is refused (use delete batch filters on shared layers)\n" +
202
+ "Deleting project/team (CLI) entries removes the local markdown file and its index row — team deletion is local only and a later team sync may resurrect the file while the remote still has it.\n" +
203
+ "Save bugs, conventions, and preferences here — they persist across sessions."
204
+
205
+ function validateTypeFilter(type) {
206
+ if (type === undefined || type === null || type === "") return null
207
+ const t = String(type)
208
+ if (!["rule", "knowledge", "decision", "pattern"].includes(t)) throw new Error(`Invalid memory type "${t}"; expected one of: rule, knowledge, decision, pattern`)
209
+ return t
210
+ }
211
+
212
+ function normalizeLimit(limit, dflt) {
213
+ const n = Number(limit)
214
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : dflt
215
+ }
216
+
217
+ function fmtDate(ts) {
218
+ return ts ? new Date(ts).toISOString().slice(0, 10) : "?"
219
+ }
220
+
221
+ const listRowLine = (r) => `${r.id} [${r.type}] ${r.title}(${fmtDate(r.ts)})`
222
+
189
223
  /**
190
- * Generate the two memory-related agent tools (following the tools.mjs tool shape).
191
- * memory_put is a side-effecting tool (needs permission confirmation), memory_search is read-only.
224
+ * Generate the memory agent tool ONE `memory` tool with five actions (MEMORY.md §6 D-M1).
225
+ * search/list are read-only actions (planMode pass / no permission ask dispatch classifies
226
+ * them action-level, same as subagent check/status); put keeps its side-effect permission
227
+ * gate; batch delete/clear gate on confirm:true + scope inside the tool (direct-delete
228
+ * ruling — the confirm parameter IS the gate) and stay non-readonly like the retired tools.
192
229
  * opts: { cwd, projectDir, author, team: { dir, name } | null }
193
230
  */
194
231
  export function memoryTools(memory, opts = {}) {
195
232
  const projectDir = opts.projectDir ? join(opts.cwd ?? process.cwd(), opts.projectDir) : null
233
+ const dirs = { project: projectDir, team: opts.team?.dir ?? null }
196
234
  return [
197
235
  {
198
- name: "memory_put",
199
- description:
200
- "Save a piece of knowledge to long-term memory. Use when you learn something worth remembering across sessions: a project convention, a debugging insight, an architecture decision. Types: rule (coding standards), knowledge (project facts), decision (architecture decisions), pattern (debugging/workflow patterns). Scopes: personal (default, private to you), project (shared via this repo's .thincoder/memory/), team (org-wide team repo, if configured).",
236
+ name: "memory",
237
+ description: MEMORY_TOOL_DESCRIPTION,
201
238
  parameters: {
202
239
  type: "object",
203
240
  properties: {
204
- type: { type: "string", enum: ["rule", "knowledge", "decision", "pattern"] },
205
- title: { type: "string", description: "Short title" },
206
- content: { type: "string", description: "Full content to remember" },
207
- tags: { type: "string", description: "Space-separated tags" },
208
- scope: { type: "string", enum: ["personal", "project", "team"], description: "Where to save (default personal)" },
241
+ action: { type: "string", enum: MEMORY_ACTIONS, description: "Operation to run (required)" },
242
+ scope: { type: "string", enum: MEMORY_SCOPES, description: "Where the memory lives: personal (private), project (shared via this repo's .thincoder/memory/), team (CLI only). put defaults to personal; search/list search every layer when omitted; delete/clear require it" },
243
+ type: { type: "string", enum: ["rule", "knowledge", "decision", "pattern"], description: "Entry type: put = what to save; list/delete batch = filter by type" },
244
+ title: { type: "string", description: "put: short title" },
245
+ content: { type: "string", description: "put: full content to remember" },
246
+ tags: { type: "string", description: "put: space-separated tags" },
247
+ query: { type: "string", description: "search: natural-language query" },
248
+ keyword: { type: "string", description: "list/delete batch: filter matching title/content" },
249
+ id: { type: "string", description: "delete single: the entry id from put/search/list output" },
250
+ limit: { type: "number", description: "Max rows: list 50 by default, search 5 by default" },
251
+ confirm: { type: "boolean", description: "delete batch/clear: must be true — without it the tool refuses" },
209
252
  },
210
- required: ["type", "title", "content"],
253
+ required: ["action"],
211
254
  },
212
255
  readonly: false,
213
256
  async execute(args) {
214
- const scope = args.scope ?? "personal"
215
- if (scope === "personal") {
216
- const id = await put(memory, args)
217
- return `Saved to personal memory (id=${id}): [${args.type}] ${args.title}`
257
+ const action = String(args?.action ?? "")
258
+ if (!MEMORY_ACTIONS.includes(action)) {
259
+ throw new Error(`memory: unknown action "${action}" — expected one of: ${MEMORY_ACTIONS.join("/")}`)
218
260
  }
219
- if (scope === "project") {
220
- if (!projectDir) throw new Error("project scope unavailable: no project directory configured")
221
- const filename = await putMarkdown(memory, {
222
- layer: "project",
223
- dir: projectDir,
224
- type: args.type,
225
- title: args.title,
226
- content: args.content,
227
- tags: (args.tags ?? "").split(/\s+/).filter(Boolean),
228
- author: opts.author ?? "unknown",
229
- })
230
- return `Saved to project memory (${filename}): [${args.type}] ${args.title}`
261
+ switch (action) {
262
+ case "search": return execSearch(memory, args)
263
+ case "put": return execPut(memory, args, opts, dirs)
264
+ case "list": return execList(memory, args, dirs)
265
+ case "delete": return execDelete(memory, args, dirs)
266
+ case "clear": return execClear(memory, args)
231
267
  }
232
- if (!opts.team?.dir) {
233
- throw new Error("team scope not configured: set memory.team in ~/.thincoder/config.json")
234
- }
235
- const filename = await putMarkdown(memory, {
236
- layer: "team",
237
- dir: opts.team.dir,
238
- type: args.type,
239
- title: args.title,
240
- content: args.content,
241
- tags: (args.tags ?? "").split(/\s+/).filter(Boolean),
242
- author: opts.author ?? "unknown",
243
- })
244
- await commitAndPush(opts.team.dir, filename, `memory: [${args.type}] ${args.title}`)
245
- return `Saved to team memory and pushed (${filename}): [${args.type}] ${args.title}`
246
- },
247
- },
248
- {
249
- name: "memory_search",
250
- description:
251
- "Search long-term memory across all layers (personal/project/team) for relevant knowledge saved in previous sessions. Use the same language as the memories being searched.",
252
- parameters: {
253
- type: "object",
254
- properties: {
255
- query: { type: "string", description: "Natural language search query" },
256
- limit: { type: "number", description: "Max results (default 5)" },
257
- },
258
- required: ["query"],
259
- },
260
- readonly: true,
261
- async execute(args) {
262
- const results = await search(memory, args.query, { limit: args.limit ?? 5 })
263
- if (results.length === 0) return "(no matching memories)"
264
- return results.map((r) => `[${r.layer}][${r.type}] ${r.title}\n${r.content}`).join("\n\n")
265
268
  },
266
269
  },
267
270
  ]
268
271
  }
272
+
273
+ /** action search — the retired search tool surface (read-only, same output contract). */
274
+ async function execSearch(memory, args) {
275
+ const scope = args.scope
276
+ if (scope !== undefined && scope !== null && !MEMORY_SCOPES.includes(String(scope))) {
277
+ throw new Error(`memory search: invalid scope "${scope}"`)
278
+ }
279
+ const query = String(args.query ?? "").trim()
280
+ if (!query) return "(no matching memories)" // 空 query 短路——两端同语义(评审 code review #4)
281
+ const limit = normalizeLimit(args.limit, 5)
282
+ let results
283
+ if (!scope) {
284
+ results = await search(memory, query, { limit })
285
+ } else {
286
+ // scope filter: oversample then slice the requested layer (results keep global rank order).
287
+ // 窗口 = max(limit*4, 20) 是召回上限——大库 + 高 limit 时该层结果可能不足 limit(接受的取舍——评审 code review #3)
288
+ const wide = await search(memory, query, { limit: Math.max(limit * 4, 20) })
289
+ results = wide.filter((r) => r.layer === String(scope)).slice(0, limit)
290
+ }
291
+ if (results.length === 0) return "(no matching memories)"
292
+ return results.map((r) => `[${r.layer}][${r.type}] ${r.title} (id=${r.id})\n${r.content}`).join("\n\n")
293
+ }
294
+
295
+ /** action put — the retired put tool surface (side-effect gate, unchanged semantics). */
296
+ async function execPut(memory, args, opts, dirs) {
297
+ const scope = String(args.scope ?? "personal")
298
+ if (!MEMORY_SCOPES.includes(scope)) throw new Error(`memory put: invalid scope "${scope}"`)
299
+ if (scope === "personal") {
300
+ const id = await put(memory, { type: args.type, title: args.title, content: args.content, tags: args.tags ?? "" })
301
+ return `Saved to personal memory (id=personal:${id}): [${args.type}] ${args.title}`
302
+ }
303
+ if (scope === "project") {
304
+ if (!dirs.project) throw new Error("project scope unavailable: no project directory configured")
305
+ const filename = await putMarkdown(memory, {
306
+ layer: "project",
307
+ dir: dirs.project,
308
+ type: args.type,
309
+ title: args.title,
310
+ content: args.content,
311
+ tags: (args.tags ?? "").split(/\s+/).filter(Boolean),
312
+ author: opts.author ?? "unknown",
313
+ })
314
+ return `Saved to project memory (id=project:${dirs.project}:${filename}): [${args.type}] ${args.title}`
315
+ }
316
+ if (!dirs.team) {
317
+ throw new Error("team scope not configured: set memory.team in ~/.thincoder/config.json")
318
+ }
319
+ const filename = await putMarkdown(memory, {
320
+ layer: "team",
321
+ dir: dirs.team,
322
+ type: args.type,
323
+ title: args.title,
324
+ content: args.content,
325
+ tags: (args.tags ?? "").split(/\s+/).filter(Boolean),
326
+ author: opts.author ?? "unknown",
327
+ })
328
+ await commitAndPush(dirs.team, filename, `memory: [${args.type}] ${args.title}`)
329
+ return `Saved to team memory and pushed (id=team:${dirs.team}:${filename}): [${args.type}] ${args.title}`
330
+ }
331
+
332
+ /** action list — new inventory action (read-only): scope/type/keyword filters + limit truncation note. */
333
+ async function execList(memory, args, dirs) {
334
+ const scope = args.scope ?? null
335
+ if (scope && !MEMORY_SCOPES.includes(String(scope))) throw new Error(`memory list: invalid scope "${scope}"`)
336
+ const rows = await matchMemoryRows(memory, {
337
+ scope: scope ? String(scope) : null,
338
+ type: validateTypeFilter(args.type),
339
+ keyword: args.keyword ? String(args.keyword).trim() : null,
340
+ projectDir: dirs.project,
341
+ teamDir: dirs.team,
342
+ })
343
+ if (rows.length === 0) return "0 条匹配"
344
+ const limit = normalizeLimit(args.limit, 50)
345
+ const shown = rows.slice(0, limit)
346
+ const lines = shown.map(listRowLine)
347
+ if (rows.length > shown.length) lines.unshift(`${shown.length} 条——截断前 ${rows.length}`)
348
+ return lines.join("\n")
349
+ }
350
+
351
+ /** action delete — single ({ id, scope } — §0.1-era single-delete semantics) + batch (scope + type/keyword + confirm). */
352
+ async function execDelete(memory, args, dirs) {
353
+ const hasId = args.id !== undefined && args.id !== null && String(args.id) !== ""
354
+ if (hasId) return execDeleteSingle(memory, args, dirs)
355
+ // batch form
356
+ const scope = args.scope
357
+ if (!scope) throw new Error("batch delete requires scope plus type and/or keyword filter")
358
+ if (!MEMORY_SCOPES.includes(String(scope))) throw new Error(`memory delete: invalid scope "${scope}"`)
359
+ const type = validateTypeFilter(args.type)
360
+ const keyword = args.keyword ? String(args.keyword).trim() : null
361
+ if (!type && !keyword) {
362
+ throw new Error("batch delete requires type and/or keyword filter — a scope-wide wipe without filters is refused (personal full wipe is the clear action)")
363
+ }
364
+ if (scope === "project" && !dirs.project) throw new Error("project scope unavailable: no project directory configured")
365
+ if (scope === "team" && !dirs.team) throw new Error("team scope not configured: set memory.team in ~/.thincoder/config.json")
366
+ const filters = { scope: String(scope), type, keyword }
367
+ const rows = await matchMemoryRows(memory, { ...filters, projectDir: dirs.project, teamDir: dirs.team })
368
+ if (rows.length === 0) return "0 条匹配"
369
+ if (args.confirm !== true) {
370
+ const lines = [rows.length > 5 ? `将删 ${rows.length} 条:前 5 条预览` : `将删 ${rows.length} 条`]
371
+ lines.push(...rows.slice(0, 5).map(listRowLine))
372
+ if (rows.length > 5) lines.push(`5 条——截断前 ${rows.length}`)
373
+ lines.push("confirm:true required — re-send with it to execute the deletion")
374
+ return lines.join("\n")
375
+ }
376
+ const n = await deleteWhere(memory, filters, { dirs })
377
+ return `Deleted ${n} entries in scope ${scope}`
378
+ }
379
+
380
+ /** Single-entry delete — §0.1-era delete semantics (id + scope, NF2/NF3, direct-delete ruling). */
381
+ async function execDeleteSingle(memory, args, dirs) {
382
+ if (!args.scope) throw new Error("delete requires id + scope")
383
+ const uid = String(args.id)
384
+ const prefix = uid.split(":")[0]
385
+ const uidScope = prefix === "personal" || prefix === "project" || prefix === "team" ? prefix : /^\d+$/.test(prefix) ? "personal" : null
386
+ if (!uidScope) throw new Error(`invalid memory id: ${uid}`)
387
+ if (uidScope !== args.scope) throw new Error(`id prefix ${prefix}: 与 scope ${args.scope} 不匹配`)
388
+ const entry = await deleteByUid(memory, uid, { dirs })
389
+ return `Deleted ${entry.id}: ${entry.title}\n${(entry.content ?? "").slice(0, 500)}`
390
+ }
391
+
392
+ /** action clear — personal-only full wipe (scope + confirm:true gates; project/team refused). */
393
+ function execClear(memory, args) {
394
+ const scope = args.scope
395
+ if (!scope) throw new Error('clear requires scope "personal" — pass scope: "personal" plus confirm: true')
396
+ if (String(scope) !== "personal") {
397
+ if (!MEMORY_SCOPES.includes(String(scope))) throw new Error(`memory clear: invalid scope "${scope}"`)
398
+ throw new Error("shared layers don't support clear — use delete with type/keyword batch filters instead")
399
+ }
400
+ if (args.confirm !== true) throw new Error("clear requires confirm:true — this wipes ALL personal memory")
401
+ const n = clearPersonal(memory)
402
+ return `Cleared personal memory (${n} entries deleted)`
403
+ }
package/src/memory.mjs CHANGED
@@ -7,7 +7,7 @@
7
7
  export { createMemory, migrate, segmentCJK, VALID_TYPES, SCHEMA_VERSION, CODE_EXTS, DOC_EXTS, SKIP_DIRS, BIG_FILE_LINES } from "./memory/schema.mjs"
8
8
 
9
9
  // CRUD + search + ensureEmbeddings
10
- export { put, search, ftsSearch, fetchEntry, ensureEmbeddings, putMarkdown, syncDir, indexMarkdownFile, list, remove, buildFtsQuery } from "./memory/core.mjs"
10
+ export { put, search, ftsSearch, fetchEntry, ensureEmbeddings, putMarkdown, syncDir, indexMarkdownFile, list, remove, deleteByUid, matchMemoryRows, deleteWhere, clearPersonal, buildFtsQuery } from "./memory/core.mjs"
11
11
 
12
12
  // code chunking + markdown chunking
13
13
  export { detectLanguage, extractSymbols, extractPySymbols, chunkCode, extractLeadingDoc, yieldTick, _upsertCodeFile, chunkMarkdown, _upsertDocFile } from "./memory/code-index.mjs"
@@ -88,7 +88,13 @@ const MODEL_SPECS = [
88
88
  ]
89
89
  const DEFAULT_SPEC = { context: 128_000, maxOutput: 32_000, cacheMode: "none" }
90
90
 
91
- /** Look up spec by model name prefix (case-insensitive), conservative default for unknown models */
91
+ /** Look up spec by model name prefix (case-insensitive), conservative default for unknown models.
92
+ *
93
+ * Vendor-namespace prefix stripping (2026-09-04):第三方 token 市场(roapi/new-api/one-api/
94
+ * aiproxy 聚合网关)惯例在模型名前加厂商前缀(zhipu/glm-5.3、openai/gpt-4o)。完整名未命中
95
+ * 且含 "/" 时,剥掉第一个 "/" 前的 namespace 再按前缀匹配一次——ZHIPU/GLM-5.3 → glm-5.3 命中
96
+ * 真实规格,不再降级 128K 默认。kimi/kimi-k3 的显式 alias 行保留为文档锚(发送路径
97
+ * provider.core isRouter 依赖含 "/" 判定),通用机制已覆盖同类。 */
92
98
  const warnedModels = new Set() // warn once per model name — specForModel is a hot path (every request)
93
99
  // Pre-sorted once at module scope — specForModel runs on every request (agent, provider core,
94
100
  // context, auto-think, TUI rendering); re-sorting per call was wasteful.
@@ -98,6 +104,14 @@ export function specForModel(model) {
98
104
  for (const [prefix, spec] of SORTED_SPECS) {
99
105
  if (m.startsWith(prefix.toLowerCase())) return spec
100
106
  }
107
+ // Vendor-namespace strip: vendor/model — retry the prefix match on the bare model part.
108
+ const slash = m.indexOf("/")
109
+ if (slash > 0) {
110
+ const bare = m.slice(slash + 1)
111
+ for (const [prefix, spec] of SORTED_SPECS) {
112
+ if (bare.startsWith(prefix.toLowerCase())) return spec
113
+ }
114
+ }
101
115
  // Unknown model: warn ONCE (not per request) so a typo'd ID or a missing alias surfaces
102
116
  // instead of silently degrading to the 128K default (IK5VGJ).
103
117
  if (m && !warnedModels.has(m)) {
@@ -106,3 +120,26 @@ export function specForModel(model) {
106
120
  }
107
121
  return DEFAULT_SPEC
108
122
  }
123
+
124
+ /**
125
+ * providerSpec(provider) — spec with a provider-level context override (PROVIDER.md §15, 2026-09-02).
126
+ *
127
+ * providers[].context is configured in K units (128 = 128K = 131072 tokens) and overrides the
128
+ * MODEL_SPECS value for THIS provider only — the same model can have different real context
129
+ * windows on different endpoints (official vs local deployment). The ×1024 conversion happens
130
+ * HERE and nowhere else.
131
+ *
132
+ * Returns a COPY ({ ...spec, context }) — the shared spec object from the SORTED_SPECS lookup
133
+ * must never be mutated, or the override would leak across providers (T-C1).
134
+ *
135
+ * Validation is defensive (pure function): absent/invalid context falls back to the plain spec
136
+ * (config.mjs loadConfig already warns + strips invalid values; this guard covers direct callers
137
+ * and keeps the function total). specForModel stays a pure table lookup — callers without a
138
+ * provider keep using it.
139
+ */
140
+ export function providerSpec(provider) {
141
+ const spec = specForModel(provider?.model ?? "")
142
+ const k = Number(provider?.context) // Number() 接受数字字符串("128")——两端语义统一(code review #1)
143
+ if (Number.isInteger(k) && k > 0) return { ...spec, context: k * 1024 }
144
+ return spec
145
+ }