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
@@ -7,7 +7,8 @@ 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, deleteByUid, matchMemoryRows, deleteWhere, clearPersonal, EMBED_TEXT_MAX_LEN } from "./core.mjs"
10
+ import { buildFtsQuery, put, search, putMarkdown, clearPersonal, EMBED_TEXT_MAX_LEN } from "./core.mjs"
11
+ import { deleteByUid, matchMemoryRows, deleteWhere } from "./delete.mjs"
11
12
  import { _upsertDocFile, yieldTick } from "./code-index.mjs"
12
13
  import { markIndexedCommit, listProjectFiles } from "./code-sync.mjs"
13
14
 
@@ -165,7 +166,8 @@ export function docSearchTool(memory) {
165
166
  name: "doc_search",
166
167
  description:
167
168
  "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.",
169
+ "Returns matching doc chunks: path, heading, line range, relevance score, content excerpt. " +
170
+ "For what was said in sessions (conversation/chat history — decisions, rulings), use read_history.",
169
171
  parameters: {
170
172
  type: "object",
171
173
  properties: {
@@ -188,19 +190,21 @@ export function docSearchTool(memory) {
188
190
  // ---------------------------------------------------------------- agent tools
189
191
 
190
192
  /** §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
193
+ * with thincoder-vscode/src/memory.mjs (MEMORY.md §6 D-M1/F-M6); layer VALUES per end
192
194
  * (VS Code has no team layer and rejects it with CLI guidance). */
193
195
  const MEMORY_ACTIONS = ["search", "put", "list", "delete", "clear"]
194
- const MEMORY_SCOPES = ["personal", "project", "team"]
196
+ const MEMORY_LAYERS = ["personal", "project", "team"]
195
197
  const MEMORY_TOOL_DESCRIPTION =
196
198
  "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 scopea 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" +
199
+ "- search — find knowledge saved in previous sessions (query, optional layer/limit); result rows start with a [layer] tag and carry the entry id (id prefix = the layer)\n" +
200
+ "- put — save a piece of knowledge for future sessions (type: rule = coding standards, knowledge = project facts, decision = architecture decisions, pattern = debugging/workflow patterns; title/content/tags; layer defaults to personal)\n" +
201
+ "- list — inventory what memory holds (optional layer/type/keyword filters, limit default 50); one row per entry: [layer] id [type] title (date); a truncated list notes the full count\n" +
202
+ "- delete — SINGLE: {id, layer} removes the entry shown in put/search/list output — layer is optional: when passed it is validated against the id prefix (a mismatch is refused — guards against deleting the wrong entry); when omitted the id prefix routes the delete, so any id search/list returned is directly deletable. BATCH (no id): {layer, type and/or keyword} removes every matching entry in that layerlayer and at least one of type/keyword are required, plus confirm:true (without confirm it returns the count plus a preview); layer-wide wipes without filters are refused on every layer\n" +
203
+ "- clear — {layer: \"personal\", confirm: true} wipes ALL personal memory entries. clear is personal-only: a missing layer or a project/team layer is refused (use delete batch filters on shared layers)\n" +
204
+ "Layer is the memory tier: personal (private), project (shared via this repo's .thincoder/memory/), team (CLI only, git-synced). The [layer] tag on search/list result rows, the row's id prefix, and the layer parameter are the same concept — pass a result row's [layer] as layer, or omit it on a single delete to auto-route by the id prefix.\n" +
202
205
  "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."
206
+ "Save bugs, conventions, and preferences here — they persist across sessions.\n" +
207
+ "Session message history (what was said in this or past sessions) is NOT in memory — search session messages with read_history."
204
208
 
205
209
  function validateTypeFilter(type) {
206
210
  if (type === undefined || type === null || type === "") return null
@@ -218,13 +222,13 @@ function fmtDate(ts) {
218
222
  return ts ? new Date(ts).toISOString().slice(0, 10) : "?"
219
223
  }
220
224
 
221
- const listRowLine = (r) => `${r.id} [${r.type}] ${r.title}(${fmtDate(r.ts)})`
225
+ const listRowLine = (r) => `[${r.layer}] ${r.id} [${r.type}] ${r.title}(${fmtDate(r.ts)})`
222
226
 
223
227
  /**
224
228
  * Generate the memory agent tool — ONE `memory` tool with five actions (MEMORY.md §6 D-M1).
225
229
  * search/list are read-only actions (planMode pass / no permission ask — dispatch classifies
226
230
  * 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
231
+ * gate; batch delete/clear gate on confirm:true + layer inside the tool (direct-delete
228
232
  * ruling — the confirm parameter IS the gate) and stay non-readonly like the retired tools.
229
233
  * opts: { cwd, projectDir, author, team: { dir, name } | null }
230
234
  */
@@ -239,7 +243,7 @@ export function memoryTools(memory, opts = {}) {
239
243
  type: "object",
240
244
  properties: {
241
245
  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" },
246
+ layer: { type: "string", enum: MEMORY_LAYERS, description: "The memory layer: personal (private), project (shared via this repo's .thincoder/memory/), team (CLI only). Same concept as the [layer] tag and the id prefix on search/list result rows. put/search/list: optional (put defaults to personal; search/list omit = all layers). single delete: optional (omit = route by id prefix). batch delete/clear: required" },
243
247
  type: { type: "string", enum: ["rule", "knowledge", "decision", "pattern"], description: "Entry type: put = what to save; list/delete batch = filter by type" },
244
248
  title: { type: "string", description: "put: short title" },
245
249
  content: { type: "string", description: "put: full content to remember" },
@@ -272,21 +276,21 @@ export function memoryTools(memory, opts = {}) {
272
276
 
273
277
  /** action search — the retired search tool surface (read-only, same output contract). */
274
278
  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}"`)
279
+ const layer = args.layer
280
+ if (layer !== undefined && layer !== null && !MEMORY_LAYERS.includes(String(layer))) {
281
+ throw new Error(`memory search: invalid layer "${layer}"`)
278
282
  }
279
283
  const query = String(args.query ?? "").trim()
280
284
  if (!query) return "(no matching memories)" // 空 query 短路——两端同语义(评审 code review #4)
281
285
  const limit = normalizeLimit(args.limit, 5)
282
286
  let results
283
- if (!scope) {
287
+ if (!layer) {
284
288
  results = await search(memory, query, { limit })
285
289
  } else {
286
- // scope filter: oversample then slice the requested layer (results keep global rank order).
290
+ // layer filter: oversample then slice the requested layer (results keep global rank order).
287
291
  // 窗口 = max(limit*4, 20) 是召回上限——大库 + 高 limit 时该层结果可能不足 limit(接受的取舍——评审 code review #3)
288
292
  const wide = await search(memory, query, { limit: Math.max(limit * 4, 20) })
289
- results = wide.filter((r) => r.layer === String(scope)).slice(0, limit)
293
+ results = wide.filter((r) => r.layer === String(layer)).slice(0, limit)
290
294
  }
291
295
  if (results.length === 0) return "(no matching memories)"
292
296
  return results.map((r) => `[${r.layer}][${r.type}] ${r.title} (id=${r.id})\n${r.content}`).join("\n\n")
@@ -294,14 +298,14 @@ async function execSearch(memory, args) {
294
298
 
295
299
  /** action put — the retired put tool surface (side-effect gate, unchanged semantics). */
296
300
  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") {
301
+ const layer = String(args.layer ?? "personal")
302
+ if (!MEMORY_LAYERS.includes(layer)) throw new Error(`memory put: invalid layer "${layer}"`)
303
+ if (layer === "personal") {
300
304
  const id = await put(memory, { type: args.type, title: args.title, content: args.content, tags: args.tags ?? "" })
301
305
  return `Saved to personal memory (id=personal:${id}): [${args.type}] ${args.title}`
302
306
  }
303
- if (scope === "project") {
304
- if (!dirs.project) throw new Error("project scope unavailable: no project directory configured")
307
+ if (layer === "project") {
308
+ if (!dirs.project) throw new Error("project layer unavailable: no project directory configured")
305
309
  const filename = await putMarkdown(memory, {
306
310
  layer: "project",
307
311
  dir: dirs.project,
@@ -314,7 +318,7 @@ async function execPut(memory, args, opts, dirs) {
314
318
  return `Saved to project memory (id=project:${dirs.project}:${filename}): [${args.type}] ${args.title}`
315
319
  }
316
320
  if (!dirs.team) {
317
- throw new Error("team scope not configured: set memory.team in ~/.thincoder/config.json")
321
+ throw new Error("team layer not configured: set memory.team in ~/.thincoder/config.json")
318
322
  }
319
323
  const filename = await putMarkdown(memory, {
320
324
  layer: "team",
@@ -329,12 +333,12 @@ async function execPut(memory, args, opts, dirs) {
329
333
  return `Saved to team memory and pushed (id=team:${dirs.team}:${filename}): [${args.type}] ${args.title}`
330
334
  }
331
335
 
332
- /** action list — new inventory action (read-only): scope/type/keyword filters + limit truncation note. */
336
+ /** action list — new inventory action (read-only): layer/type/keyword filters + limit truncation note. */
333
337
  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}"`)
338
+ const layer = args.layer ?? null
339
+ if (layer && !MEMORY_LAYERS.includes(String(layer))) throw new Error(`memory list: invalid layer "${layer}"`)
336
340
  const rows = await matchMemoryRows(memory, {
337
- scope: scope ? String(scope) : null,
341
+ layer: layer ? String(layer) : null,
338
342
  type: validateTypeFilter(args.type),
339
343
  keyword: args.keyword ? String(args.keyword).trim() : null,
340
344
  projectDir: dirs.project,
@@ -348,22 +352,23 @@ async function execList(memory, args, dirs) {
348
352
  return lines.join("\n")
349
353
  }
350
354
 
351
- /** action delete — single ({ id, scope } — §0.1-era single-delete semantics) + batch (scope + type/keyword + confirm). */
355
+ /** action delete — single ({ id, layer? } — MEMORY.md §6.2: layer OPTIONAL, validated when
356
+ * passed, else the id prefix routes the delete) + batch (layer + type/keyword + confirm). */
352
357
  async function execDelete(memory, args, dirs) {
353
358
  const hasId = args.id !== undefined && args.id !== null && String(args.id) !== ""
354
359
  if (hasId) return execDeleteSingle(memory, args, dirs)
355
360
  // 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}"`)
361
+ const layer = args.layer
362
+ if (!layer) throw new Error("batch delete requires layer plus type and/or keyword filter")
363
+ if (!MEMORY_LAYERS.includes(String(layer))) throw new Error(`memory delete: invalid layer "${layer}"`)
359
364
  const type = validateTypeFilter(args.type)
360
365
  const keyword = args.keyword ? String(args.keyword).trim() : null
361
366
  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)")
367
+ throw new Error("batch delete requires type and/or keyword filter — a layer-wide wipe without filters is refused (personal full wipe is the clear action)")
363
368
  }
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 }
369
+ if (layer === "project" && !dirs.project) throw new Error("project layer unavailable: no project directory configured")
370
+ if (layer === "team" && !dirs.team) throw new Error("team layer not configured: set memory.team in ~/.thincoder/config.json")
371
+ const filters = { layer: String(layer), type, keyword }
367
372
  const rows = await matchMemoryRows(memory, { ...filters, projectDir: dirs.project, teamDir: dirs.team })
368
373
  if (rows.length === 0) return "0 条匹配"
369
374
  if (args.confirm !== true) {
@@ -374,27 +379,32 @@ async function execDelete(memory, args, dirs) {
374
379
  return lines.join("\n")
375
380
  }
376
381
  const n = await deleteWhere(memory, filters, { dirs })
377
- return `Deleted ${n} entries in scope ${scope}`
382
+ return `Deleted ${n} entries in layer ${layer}`
378
383
  }
379
384
 
380
- /** Single-entry delete — §0.1-era delete semantics (id + scope, NF2/NF3, direct-delete ruling). */
385
+ /** Single-entry delete — MEMORY.md §6.2: layer is OPTIONAL. When passed it is validated
386
+ * against the id prefix (mismatch refused — guards against deleting the wrong entry); when
387
+ * omitted the delete routes by the id prefix alone, so any id search/list returned is
388
+ * directly deletable (deleteByUid already resolves the layer from the uid prefix). */
381
389
  async function execDeleteSingle(memory, args, dirs) {
382
- if (!args.scope) throw new Error("delete requires id + scope")
383
390
  const uid = String(args.id)
384
391
  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} 不匹配`)
392
+ const uidLayer = prefix === "personal" || prefix === "project" || prefix === "team" ? prefix : /^\d+$/.test(prefix) ? "personal" : null
393
+ if (!uidLayer) throw new Error(`invalid memory id: ${uid}`)
394
+ const layer = args.layer
395
+ if (layer !== undefined && layer !== null && String(layer) !== uidLayer) {
396
+ throw new Error(`id prefix ${prefix}: 与 layer ${layer} 不匹配`)
397
+ }
388
398
  const entry = await deleteByUid(memory, uid, { dirs })
389
399
  return `Deleted ${entry.id}: ${entry.title}\n${(entry.content ?? "").slice(0, 500)}`
390
400
  }
391
401
 
392
- /** action clear — personal-only full wipe (scope + confirm:true gates; project/team refused). */
402
+ /** action clear — personal-only full wipe (layer + confirm:true gates; project/team refused). */
393
403
  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}"`)
404
+ const layer = args.layer
405
+ if (!layer) throw new Error('clear requires layer "personal" — pass layer: "personal" plus confirm: true')
406
+ if (String(layer) !== "personal") {
407
+ if (!MEMORY_LAYERS.includes(String(layer))) throw new Error(`memory clear: invalid layer "${layer}"`)
398
408
  throw new Error("shared layers don't support clear — use delete with type/keyword batch filters instead")
399
409
  }
400
410
  if (args.confirm !== true) throw new Error("clear requires confirm:true — this wipes ALL personal memory")
package/src/memory.mjs CHANGED
@@ -7,7 +7,9 @@
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, deleteByUid, matchMemoryRows, deleteWhere, clearPersonal, buildFtsQuery } from "./memory/core.mjs"
10
+ // (delete.mjs owns the disk-truth row match + deletion family see its header)
11
+ export { put, search, ftsSearch, fetchEntry, ensureEmbeddings, putMarkdown, syncDir, indexMarkdownFile, list, clearPersonal, buildFtsQuery } from "./memory/core.mjs"
12
+ export { matchMemoryRows, deleteWhere, deleteByUid, remove } from "./memory/delete.mjs"
11
13
 
12
14
  // code chunking + markdown chunking
13
15
  export { detectLanguage, extractSymbols, extractPySymbols, chunkCode, extractLeadingDoc, yieldTick, _upsertCodeFile, chunkMarkdown, _upsertDocFile } from "./memory/code-index.mjs"
@@ -0,0 +1,265 @@
1
+ /**
2
+ * peer-domains.mjs — R10 多实例协作 L3 文件写域登记 + 冲突检测(MULTI-INSTANCE-COLLAB
3
+ * §2a.5——评审修正 #8:独立文件,防 300 行超限)。
4
+ *
5
+ * 登记存储(D-L3a):~/.thincoder/peers/{sessionId}.json——每实例单文件、单写者(本
6
+ * 进程——end marker 同型模式);内容 {sessionId, pid, end, cwd, domains[绝对路径],
7
+ * updatedAt}。写点 = 回合级:写工具钩子累积"本回合实际写过的文件"(D-L3b 同一钩子——
8
+ * 检测+记录一次完成)→ 回合结束 flush 整写一次(finalizeAgentTurn);本回合无写入则
9
+ * 不写(文件按自身 updatedAt 自然过期——hot 5 分钟窗口语义不被空回合提前清掉)。
10
+ *
11
+ * 冲突检测(D-L3b):结构化写工具(PEER_WRITE_TOOLS)执行前查 conflicts——命中他实例
12
+ * hot 域 → 工具结果附软提示(决策⑥ A:不阻止)。缓存(评审修正 #2):聚合结果按 peers
13
+ * 目录 mtime 惰性缓存——目录未变零扫描;单次写工具调用新增开销上限 = 目录 stat 一次
14
+ * (N3 度量)。崩溃残留:聚合时惰性清理(死 pid 文件删除——batchAlive 一次批量);
15
+ * 损坏文件按缺失降级(end marker NF2 同型——不删不炸,属主下次 flush 覆盖)。
16
+ */
17
+
18
+ import { statSync, readdirSync, readFileSync, unlinkSync } from "node:fs"
19
+ import { join, resolve, sep } from "node:path"
20
+ import { configDir } from "./config.mjs"
21
+ import { normalizeCwd, getSessionId, END, writeSessionFile } from "./session-slots.mjs"
22
+ import { batchAlive } from "./peer-instances.mjs"
23
+ import { FILE_MUTATORS } from "./agent/helpers.mjs"
24
+
25
+ /** hot 窗口:登记(回合足迹 flush)后 5 分钟内视为 hot(决策⑤ A——"刚写过"语义) */
26
+ export const HOT_WINDOW_MS = 5 * 60 * 1000
27
+
28
+ /** L3 覆盖的结构化写工具(D-L3b 清单)——既有 FILE_MUTATORS(write/edit/insert_after/
29
+ * hashline_edit/apply_patch/delete)∪ file_ops。insert_after/hashline_edit 亦为结构化
30
+ * 写工具(同一文件写入面),file_ops 在 FILE_MUTATORS 之外故显式并入。 */
31
+ export const PEER_WRITE_TOOLS = new Set([...FILE_MUTATORS, "file_ops"])
32
+
33
+ export function peersDir() {
34
+ return join(configDir, "peers")
35
+ }
36
+
37
+ export function peerFilePath(sessionId) {
38
+ return join(peersDir(), `${sessionId}.json`)
39
+ }
40
+
41
+ // 模块级测试注入缝(default null = 生产实现;测试注入 + finally 恢复)
42
+ let _testAliveFn = null
43
+
44
+ /** 注入聚合判活实现(批量判活计数/去真实 exec——T-L3b 死清理用例可用真死 pid 亦可注入)。 */
45
+ export function _setPeerDomainsTestImpl({ aliveFn = undefined } = {}) {
46
+ const prev = _testAliveFn
47
+ _testAliveFn = aliveFn ?? null
48
+ peersDirCache = null // 注入即环境变更——目录缓存失效(测试间不串)
49
+ return prev
50
+ }
51
+
52
+ export function _resetPeerDomainsTestImpl() {
53
+ _testAliveFn = null
54
+ peersDirCache = null
55
+ }
56
+
57
+ // peers 目录 mtime 惰性缓存(评审修正 #2)——单全局项(peers 目录全局唯一)
58
+ let peersDirCache = null // { mtimeMs, peers: [...] }
59
+
60
+ function statDirMtimeMs(dir) {
61
+ try {
62
+ return statSync(dir).mtimeMs
63
+ } catch {
64
+ return null // 目录缺失
65
+ }
66
+ }
67
+
68
+ /** 目标路径解析(工具 touchedPaths 优先——apply_patch/edit-batch 多文件;file_ops 无
69
+ * touchedPaths——source/dest 双算;其余 path 单参兜底)。相对路径按 cwd 解析为绝对
70
+ * 路径(resolve:绝对入参原样保留)。解析失败/畸形入参跳过(零目标 = 无检测无登记)。 */
71
+ export function peerWriteTargets(tool, args, cwd) {
72
+ const raw = tool?.touchedPaths
73
+ ? tool.touchedPaths(args ?? {})
74
+ : tool?.name === "file_ops"
75
+ ? [args?.source, args?.dest]
76
+ : [args?.path]
77
+ const out = []
78
+ for (const p of raw) {
79
+ if (typeof p !== "string" || p.length === 0) continue
80
+ try {
81
+ out.push(resolve(cwd, p))
82
+ } catch {
83
+ /* 畸形路径跳过——检测/登记尽力而为 */
84
+ }
85
+ }
86
+ return out
87
+ }
88
+
89
+ /** 路径重叠:a 与 b 相等或互为目录包含(file_ops 目录级操作/delete 整目录语义——
90
+ * 区分大小写按平台(Windows 文件系统不区分);分隔符归一(resolve 产物为原生分隔符,
91
+ * 入参可能混用 / 与 \)。 */
92
+ export function pathsOverlap(a, b) {
93
+ const norm = (p) => {
94
+ const n = process.platform === "win32" ? p.toLowerCase() : p
95
+ return (process.platform === "win32" ? n.replace(/\//g, "\\") : n.replace(/\\/g, "/")).replace(/[\\/]+$/, "")
96
+ }
97
+ const pa = norm(a)
98
+ const pb = norm(b)
99
+ if (pa === pb) return true
100
+ const sepN = process.platform === "win32" ? "\\" : "/"
101
+ const prefix = (p, q) => p === q || p.startsWith(q + sepN)
102
+ return prefix(pa, pb) || prefix(pb, pa)
103
+ }
104
+
105
+ /** 扫描 peers 目录:解析每文件 + 一次批量判活全部 pid + 死登记惰性清理(unlink——
106
+ * 崩溃残留);损坏文件按缺失降级(不删——属主下次 flush 覆盖)。返回活登记
107
+ * [{ sessionId, pid, end, cwd, domains, updatedAt }]。目录缺失/不可读 → []。 */
108
+ function scanPeersDir() {
109
+ let files = []
110
+ try {
111
+ files = readdirSync(peersDir(), { withFileTypes: true })
112
+ .filter((e) => e.isFile() && e.name.endsWith(".json"))
113
+ .map((e) => join(peersDir(), e.name))
114
+ } catch {
115
+ return [] // 目录缺失(从未 flush)→ 无登记
116
+ }
117
+ const parsed = []
118
+ for (const file of files) {
119
+ try {
120
+ const rec = JSON.parse(readFileSync(file, "utf8"))
121
+ if (rec && typeof rec === "object" && typeof rec.sessionId === "string" && rec.sessionId.length > 0
122
+ && Number.isInteger(Number(rec.pid)) && Number(rec.pid) > 0
123
+ && typeof rec.cwd === "string" && Array.isArray(rec.domains)) {
124
+ parsed.push({ file, rec })
125
+ }
126
+ // 结构非法 = 损坏 → 按缺失降级(不删——NF2 同型)
127
+ } catch {
128
+ /* 解析失败同损坏 */
129
+ }
130
+ }
131
+ if (parsed.length === 0) return []
132
+ const aliveFn = _testAliveFn ?? batchAlive
133
+ const alive = aliveFn([...new Set(parsed.map((p) => Number(p.rec.pid)))])
134
+ // 判活探测失败(batchAlive → null)≠ 死:不得据此执行死清理(unlink 是破坏性副作用)
135
+ // ——返回 null 信号:调用方不缓存空聚合(下轮调用重试探测)
136
+ if (!(alive instanceof Set)) return null
137
+ const live = []
138
+ for (const { file, rec } of parsed) {
139
+ if (!alive.has(Number(rec.pid))) {
140
+ try {
141
+ // 死登记惰性清理(崩溃残留——聚合时一次批量判活顺带删除)
142
+ unlinkSync(file)
143
+ } catch { /* unlink 失败不影响聚合(下次再试) */ }
144
+ continue
145
+ }
146
+ live.push({
147
+ sessionId: rec.sessionId,
148
+ pid: Number(rec.pid),
149
+ end: typeof rec.end === "string" ? rec.end : undefined,
150
+ cwd: rec.cwd,
151
+ domains: rec.domains.filter((d) => typeof d === "string"),
152
+ updatedAt: Number(rec.updatedAt) || 0,
153
+ })
154
+ }
155
+ return live
156
+ }
157
+
158
+ /** peers 目录 mtime 惰性缓存聚合(评审修正 #2——目录未变不重扫)。返回当前目录下全部
159
+ * 活登记(含他 cwd 实例——死清理跨 cwd 一次批量;cwd 过滤在 peerDomains)。 */
160
+ function cachedScan() {
161
+ const dir = peersDir()
162
+ const mtime = statDirMtimeMs(dir)
163
+ if (mtime == null) {
164
+ peersDirCache = null
165
+ return []
166
+ }
167
+ if (peersDirCache && peersDirCache.mtimeMs === mtime) return peersDirCache.peers
168
+ const peers = scanPeersDir()
169
+ if (peers === null) {
170
+ peersDirCache = null // 探测失败——不缓存空聚合(下轮调用重试探测——失败 ≠ 无登记)
171
+ return []
172
+ }
173
+ // 死清理删文件会改目录 mtime——以清理后的 mtime 缓存(下次 stat 命中即不重扫)
174
+ peersDirCache = { mtimeMs: statDirMtimeMs(dir) ?? mtime, peers }
175
+ return peers
176
+ }
177
+
178
+ /**
179
+ * 聚合(D-L3a):本 cwd(normalizeCwd 同构比较)的其他活实例登记(self 排除——
180
+ * sessionId === getSessionId())。返回 [{ sessionId, pid, end, cwd, domains, updatedAt }]。
181
+ * 纯只读;缓存命中 = 目录 stat 一次(N3)。
182
+ */
183
+ export function peerDomains(cwd) {
184
+ const normCwd = normalizeCwd(cwd)
185
+ const myId = getSessionId()
186
+ return cachedScan().filter((p) => p.sessionId !== myId && normalizeCwd(p.cwd) === normCwd)
187
+ }
188
+
189
+ /**
190
+ * 冲突检测(D-L3b——写前查询):targets(绝对路径)命中他实例 hot 域(updatedAt 在
191
+ * HOT_WINDOW_MS 内且域含 target/互为包含)→ 返回 [{ target, by: [{end, pid, sessionId}] }]。
192
+ * 无冲突/无目标 → []。纯只读、不抛(内部全降级——失败按零冲突)。
193
+ */
194
+ export function conflicts(cwd, targets, { now = Date.now() } = {}) {
195
+ if (!Array.isArray(targets) || targets.length === 0) return []
196
+ let peers = []
197
+ try {
198
+ peers = peerDomains(cwd)
199
+ } catch {
200
+ return []
201
+ }
202
+ const hot = peers.filter((p) => p.domains.length > 0 && now - p.updatedAt <= HOT_WINDOW_MS)
203
+ if (hot.length === 0) return []
204
+ const hits = []
205
+ for (const target of targets) {
206
+ const by = hot.filter((p) => p.domains.some((d) => pathsOverlap(target, d)))
207
+ if (by.length > 0) hits.push({ target, by: by.map((p) => ({ end: p.end, pid: p.pid, sessionId: p.sessionId })) })
208
+ }
209
+ return hits
210
+ }
211
+
212
+ /** dispatch 写工具钩子预检:命中他实例 hot 域 → 软提示文案(null = 无冲突/无目标/
213
+ * 降级——调用方零附加)。一次目录 stat(缓存命中零扫描——N3 度量)。 */
214
+ export function peerCollabNote(cwd, tool, args) {
215
+ try {
216
+ const targets = peerWriteTargets(tool, args, cwd)
217
+ if (targets.length === 0) return null
218
+ const hits = conflicts(cwd, targets)
219
+ if (hits.length === 0) return null
220
+ return hits.map((h) => {
221
+ const who = h.by.map((p) => (p.end ? `${p.end} pid=${p.pid}` : `pid=${p.pid}`)).join(", ")
222
+ return `[peer-collab] ${h.target} — another live instance (${who}) registered writing it within the last 5 minutes; concurrent edits may overwrite each other. Write not blocked — coordinate before proceeding.`
223
+ }).join("\n")
224
+ } catch {
225
+ return null // 感知失败绝不打扰工具执行
226
+ }
227
+ }
228
+
229
+ /** 记录本回合写足迹(D-L3a 累积——D-L3b 同一钩子执行后调用;仅成功写计入"实际写过")。
230
+ * agent._peerWritten: Set<绝对路径>(回合级——finalizeAgentTurn flush 时整写并清空)。 */
231
+ export function recordPeerWrites(agent, tool, args) {
232
+ try {
233
+ const targets = peerWriteTargets(tool, args, agent.cwd)
234
+ if (targets.length === 0) return
235
+ agent._peerWritten ??= new Set()
236
+ for (const t of targets) agent._peerWritten.add(t)
237
+ } catch {
238
+ /* 记录失败不影响工具结果 */
239
+ }
240
+ }
241
+
242
+ /**
243
+ * 回合末登记 flush(D-L3a——finalizeAgentTurn 调用):本回合足迹整写一次本实例文件
244
+ * (单写者;.tmp+rename 原子——writeSessionFile)。本回合无写入 → 不写(文件按自身
245
+ * updatedAt 自然过期——hot 窗口不被空回合提前清掉;低频 N3)。写失败容忍(NF2——end
246
+ * marker 同型);成功 flush 后清空回合集合。不抛(调用方回合收尾零风险)。
247
+ */
248
+ export function flushPeerDomains(agent) {
249
+ try {
250
+ const set = agent._peerWritten
251
+ if (!set || set.size === 0) return
252
+ const sessionId = getSessionId()
253
+ writeSessionFile(peerFilePath(sessionId), {
254
+ sessionId,
255
+ pid: process.pid,
256
+ end: END,
257
+ cwd: normalizeCwd(agent.cwd),
258
+ domains: [...set],
259
+ updatedAt: Date.now(),
260
+ })
261
+ set.clear()
262
+ } catch {
263
+ /* NF2:失败容忍——下次 flush 重写 */
264
+ }
265
+ }