thincoder 0.12.58 → 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 (158) hide show
  1. package/CHANGELOG.md +78 -2
  2. package/README.md +3 -3
  3. package/bin/thincoder.mjs +88 -19
  4. package/package.json +4 -3
  5. package/src/acp/bridge.mjs +135 -26
  6. package/src/advisor/messages.mjs +57 -4
  7. package/src/advisor/run.mjs +119 -79
  8. package/src/advisor.mjs +34 -7
  9. package/src/agent/completion.mjs +17 -11
  10. package/src/agent/dispatch.mjs +182 -22
  11. package/src/agent/helpers.mjs +71 -4
  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 +107 -20
  16. package/src/agent/spawn-child.mjs +54 -4
  17. package/src/agent-tools/advisor-async.mjs +456 -0
  18. package/src/agent-tools/advisor.mjs +133 -109
  19. package/src/agent-tools/async-settle.mjs +191 -0
  20. package/src/agent-tools/consult.mjs +154 -104
  21. package/src/agent-tools/design-token.mjs +104 -0
  22. package/src/agent-tools/eng.mjs +26 -30
  23. package/src/agent-tools/escalate-async.mjs +286 -0
  24. package/src/agent-tools/goal.mjs +11 -1
  25. package/src/agent-tools/read-history.mjs +284 -0
  26. package/src/agent-tools/recent-changes.mjs +2 -1
  27. package/src/agent-tools/settings.mjs +152 -0
  28. package/src/agent-tools/skill.mjs +2 -1
  29. package/src/agent-tools/subagent-actions.mjs +470 -0
  30. package/src/agent-tools/subagent-async.mjs +382 -0
  31. package/src/agent-tools/subagent-panel.mjs +153 -0
  32. package/src/agent-tools/subagent-run.mjs +202 -0
  33. package/src/agent-tools/subagent-scheduler.mjs +343 -0
  34. package/src/agent-tools/subagent-spawn.mjs +406 -0
  35. package/src/agent-tools/subagent.mjs +203 -377
  36. package/src/agent-tools/task.mjs +4 -3
  37. package/src/agent-tools/timer.mjs +9 -4
  38. package/src/agent-tools/verify.mjs +198 -238
  39. package/src/agent-tools.mjs +1 -0
  40. package/src/agent.mjs +145 -242
  41. package/src/auto-think.mjs +14 -0
  42. package/src/cli/distill-command.mjs +10 -4
  43. package/src/cli/make-agent.mjs +4 -1
  44. package/src/cli/memory-command.mjs +2 -1
  45. package/src/cli/permission.mjs +8 -1
  46. package/src/cli/setup-wizard.mjs +17 -12
  47. package/src/config.mjs +61 -8
  48. package/src/context.mjs +81 -163
  49. package/src/crash-reports.mjs +123 -0
  50. package/src/distill.mjs +30 -12
  51. package/src/escape.mjs +6 -4
  52. package/src/explore-distill.mjs +155 -0
  53. package/src/log.mjs +195 -0
  54. package/src/memory/code-sync.mjs +2 -1
  55. package/src/memory/core.mjs +11 -72
  56. package/src/memory/delete.mjs +234 -0
  57. package/src/memory/docs.mjs +206 -87
  58. package/src/memory.mjs +3 -1
  59. package/src/model-specs.mjs +15 -1
  60. package/src/peer-domains.mjs +265 -0
  61. package/src/peer-instances.mjs +231 -0
  62. package/src/prompt-overlays.mjs +25 -0
  63. package/src/prompts/advisor-design.md +18 -39
  64. package/src/prompts/advisor-round1.md +20 -32
  65. package/src/prompts/advisor-round2.md +16 -16
  66. package/src/prompts/advisor-round3.md +16 -16
  67. package/src/prompts/coder.md +7 -28
  68. package/src/prompts/consult-base.md +4 -11
  69. package/src/prompts/discipline.md +31 -44
  70. package/src/prompts/eng-coder.md +9 -34
  71. package/src/prompts/engineering-sub.md +10 -8
  72. package/src/prompts/engineering.md +61 -264
  73. package/src/prompts/explore.md +4 -14
  74. package/src/prompts/main.md +18 -35
  75. package/src/prompts/methodology-template.md +32 -38
  76. package/src/prompts/plan.md +2 -9
  77. package/src/prompts/system.md +18 -35
  78. package/src/provider/core.mjs +62 -69
  79. package/src/provider/errors.mjs +76 -0
  80. package/src/provider/retry.mjs +8 -45
  81. package/src/session-gc.mjs +214 -0
  82. package/src/session-guard.mjs +47 -0
  83. package/src/session-rename.mjs +38 -0
  84. package/src/session-slots.mjs +181 -58
  85. package/src/session.mjs +48 -89
  86. package/src/token-ttl.mjs +273 -0
  87. package/src/tools/apply_patch.md +3 -1
  88. package/src/tools/bash.md +1 -1
  89. package/src/tools/checklist-sync.mjs +181 -0
  90. package/src/tools/checklist.mjs +52 -39
  91. package/src/tools/delete.md +1 -0
  92. package/src/tools/edit-batch.mjs +131 -44
  93. package/src/tools/edit-diff.mjs +348 -0
  94. package/src/tools/edit.md +20 -13
  95. package/src/tools/execute.md +7 -7
  96. package/src/tools/execute.mjs +55 -24
  97. package/src/tools/file.mjs +25 -70
  98. package/src/tools/file_ops.md +2 -1
  99. package/src/tools/get_current_time.md +3 -1
  100. package/src/tools/git.mjs +14 -6
  101. package/src/tools/glob-dialect.mjs +130 -0
  102. package/src/tools/glob.md +3 -3
  103. package/src/tools/grep.md +1 -1
  104. package/src/tools/hashline_edit.md +2 -0
  105. package/src/tools/index.mjs +3 -3
  106. package/src/tools/insert_after.md +2 -1
  107. package/src/tools/lint.md +2 -0
  108. package/src/tools/lsp.md +4 -1
  109. package/src/tools/ops.mjs +175 -3
  110. package/src/tools/patch.mjs +84 -13
  111. package/src/tools/question.md +5 -1
  112. package/src/tools/repomap.mjs +1 -1
  113. package/src/tools/shared.mjs +18 -25
  114. package/src/tools/system.mjs +50 -30
  115. package/src/tools/tree.md +2 -1
  116. package/src/tools/wait_for.md +22 -0
  117. package/src/tools/web.mjs +5 -3
  118. package/src/tools/websearch.md +2 -1
  119. package/src/tools/write.md +2 -0
  120. package/src/traces/trace-store.mjs +224 -0
  121. package/src/tui/agent-turn.mjs +179 -27
  122. package/src/tui/clipboard.mjs +15 -4
  123. package/src/tui/cmd-config.mjs +77 -16
  124. package/src/tui/cmd-eng.mjs +20 -16
  125. package/src/tui/cmd-extract.mjs +1 -1
  126. package/src/tui/cmd-mcp.mjs +17 -2
  127. package/src/tui/cmd-new.mjs +3 -2
  128. package/src/tui/cmd-session.mjs +19 -4
  129. package/src/tui/cmd-think.mjs +11 -11
  130. package/src/tui/cmd-upgrade.mjs +19 -4
  131. package/src/tui/config-helpers.mjs +28 -16
  132. package/src/tui/distill-cmd.mjs +1 -1
  133. package/src/tui/index.mjs +31 -96
  134. package/src/tui/interaction.mjs +13 -2
  135. package/src/tui/key-handler.mjs +105 -155
  136. package/src/tui/key-modes.mjs +215 -0
  137. package/src/tui/layout.mjs +22 -1
  138. package/src/tui/mouse.mjs +46 -0
  139. package/src/tui/pickers.mjs +51 -25
  140. package/src/tui/render-conversation.mjs +13 -161
  141. package/src/tui/render-frame.mjs +27 -10
  142. package/src/tui/render-loop.mjs +4 -1
  143. package/src/tui/render-segments.mjs +182 -0
  144. package/src/tui/startup.mjs +40 -0
  145. package/src/tui/subagent-blocks.mjs +272 -262
  146. package/src/tui/subagent-children.mjs +176 -0
  147. package/src/tui/subagent-freeze.mjs +172 -0
  148. package/src/tui/subagent-panel.mjs +125 -12
  149. package/src/tui/suspension-drive.mjs +351 -0
  150. package/src/tui/tool-args.mjs +10 -2
  151. package/src/tui/tool-display.mjs +142 -0
  152. package/src/tui/tool-events.mjs +127 -231
  153. package/src/tui/tui-lifecycle.mjs +29 -0
  154. package/src/tui/update-notice.mjs +76 -0
  155. package/src/tui/wizard.mjs +48 -12
  156. package/src/agent-tools/escalate.mjs +0 -179
  157. package/src/agent-tools/subagent-check.mjs +0 -107
  158. package/src/tools/exec-prelude.mjs +0 -84
@@ -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, 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
 
@@ -164,7 +165,9 @@ export function docSearchTool(memory) {
164
165
  return {
165
166
  name: "doc_search",
166
167
  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.",
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. " +
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.",
168
171
  parameters: {
169
172
  type: "object",
170
173
  properties: {
@@ -186,9 +189,47 @@ export function docSearchTool(memory) {
186
189
 
187
190
  // ---------------------------------------------------------------- agent tools
188
191
 
192
+ /** §6 shared tool surface — action enum / parameter shapes / descriptions byte-identical
193
+ * with thincoder-vscode/src/memory.mjs (MEMORY.md §6 D-M1/F-M6); layer VALUES per end
194
+ * (VS Code has no team layer and rejects it with CLI guidance). */
195
+ const MEMORY_ACTIONS = ["search", "put", "list", "delete", "clear"]
196
+ const MEMORY_LAYERS = ["personal", "project", "team"]
197
+ const MEMORY_TOOL_DESCRIPTION =
198
+ "Manage long-term memory in ONE tool — the action parameter picks the operation:\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 layer — layer 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" +
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" +
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."
208
+
209
+ function validateTypeFilter(type) {
210
+ if (type === undefined || type === null || type === "") return null
211
+ const t = String(type)
212
+ if (!["rule", "knowledge", "decision", "pattern"].includes(t)) throw new Error(`Invalid memory type "${t}"; expected one of: rule, knowledge, decision, pattern`)
213
+ return t
214
+ }
215
+
216
+ function normalizeLimit(limit, dflt) {
217
+ const n = Number(limit)
218
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : dflt
219
+ }
220
+
221
+ function fmtDate(ts) {
222
+ return ts ? new Date(ts).toISOString().slice(0, 10) : "?"
223
+ }
224
+
225
+ const listRowLine = (r) => `[${r.layer}] ${r.id} [${r.type}] ${r.title}(${fmtDate(r.ts)})`
226
+
189
227
  /**
190
- * Generate the three memory-related agent tools (following the tools.mjs tool shape).
191
- * memory_put and memory_delete are side-effecting (need permission confirmation), memory_search is read-only.
228
+ * Generate the memory agent tool ONE `memory` tool with five actions (MEMORY.md §6 D-M1).
229
+ * search/list are read-only actions (planMode pass / no permission ask dispatch classifies
230
+ * them action-level, same as subagent check/status); put keeps its side-effect permission
231
+ * gate; batch delete/clear gate on confirm:true + layer inside the tool (direct-delete
232
+ * ruling — the confirm parameter IS the gate) and stay non-readonly like the retired tools.
192
233
  * opts: { cwd, projectDir, author, team: { dir, name } | null }
193
234
  */
194
235
  export function memoryTools(memory, opts = {}) {
@@ -196,99 +237,177 @@ export function memoryTools(memory, opts = {}) {
196
237
  const dirs = { project: projectDir, team: opts.team?.dir ?? null }
197
238
  return [
198
239
  {
199
- name: "memory_put",
200
- description:
201
- "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).",
240
+ name: "memory",
241
+ description: MEMORY_TOOL_DESCRIPTION,
202
242
  parameters: {
203
243
  type: "object",
204
244
  properties: {
205
- type: { type: "string", enum: ["rule", "knowledge", "decision", "pattern"] },
206
- title: { type: "string", description: "Short title" },
207
- content: { type: "string", description: "Full content to remember" },
208
- tags: { type: "string", description: "Space-separated tags" },
209
- scope: { type: "string", enum: ["personal", "project", "team"], description: "Where to save (default personal)" },
245
+ action: { type: "string", enum: MEMORY_ACTIONS, description: "Operation to run (required)" },
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" },
247
+ type: { type: "string", enum: ["rule", "knowledge", "decision", "pattern"], description: "Entry type: put = what to save; list/delete batch = filter by type" },
248
+ title: { type: "string", description: "put: short title" },
249
+ content: { type: "string", description: "put: full content to remember" },
250
+ tags: { type: "string", description: "put: space-separated tags" },
251
+ query: { type: "string", description: "search: natural-language query" },
252
+ keyword: { type: "string", description: "list/delete batch: filter matching title/content" },
253
+ id: { type: "string", description: "delete single: the entry id from put/search/list output" },
254
+ limit: { type: "number", description: "Max rows: list 50 by default, search 5 by default" },
255
+ confirm: { type: "boolean", description: "delete batch/clear: must be true — without it the tool refuses" },
210
256
  },
211
- required: ["type", "title", "content"],
257
+ required: ["action"],
212
258
  },
213
259
  readonly: false,
214
260
  async execute(args) {
215
- const scope = args.scope ?? "personal"
216
- if (scope === "personal") {
217
- const id = await put(memory, args)
218
- return `Saved to personal memory (id=personal:${id}): [${args.type}] ${args.title}`
219
- }
220
- if (scope === "project") {
221
- if (!projectDir) throw new Error("project scope unavailable: no project directory configured")
222
- const filename = await putMarkdown(memory, {
223
- layer: "project",
224
- dir: projectDir,
225
- type: args.type,
226
- title: args.title,
227
- content: args.content,
228
- tags: (args.tags ?? "").split(/\s+/).filter(Boolean),
229
- author: opts.author ?? "unknown",
230
- })
231
- return `Saved to project memory (id=project:${projectDir}:${filename}): [${args.type}] ${args.title}`
261
+ const action = String(args?.action ?? "")
262
+ if (!MEMORY_ACTIONS.includes(action)) {
263
+ throw new Error(`memory: unknown action "${action}" — expected one of: ${MEMORY_ACTIONS.join("/")}`)
232
264
  }
233
- if (!opts.team?.dir) {
234
- throw new Error("team scope not configured: set memory.team in ~/.thincoder/config.json")
265
+ switch (action) {
266
+ case "search": return execSearch(memory, args)
267
+ case "put": return execPut(memory, args, opts, dirs)
268
+ case "list": return execList(memory, args, dirs)
269
+ case "delete": return execDelete(memory, args, dirs)
270
+ case "clear": return execClear(memory, args)
235
271
  }
236
- const filename = await putMarkdown(memory, {
237
- layer: "team",
238
- dir: opts.team.dir,
239
- type: args.type,
240
- title: args.title,
241
- content: args.content,
242
- tags: (args.tags ?? "").split(/\s+/).filter(Boolean),
243
- author: opts.author ?? "unknown",
244
- })
245
- await commitAndPush(opts.team.dir, filename, `memory: [${args.type}] ${args.title}`)
246
- return `Saved to team memory and pushed (id=team:${opts.team.dir}:${filename}): [${args.type}] ${args.title}`
247
- },
248
- },
249
- {
250
- name: "memory_search",
251
- description:
252
- "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.",
253
- parameters: {
254
- type: "object",
255
- properties: {
256
- query: { type: "string", description: "Natural language search query" },
257
- limit: { type: "number", description: "Max results (default 5)" },
258
- },
259
- required: ["query"],
260
- },
261
- readonly: true,
262
- async execute(args) {
263
- const results = await search(memory, args.query, { limit: args.limit ?? 5 })
264
- if (results.length === 0) return "(no matching memories)"
265
- return results.map((r) => `[${r.layer}][${r.type}] ${r.title} (id=${r.id})\n${r.content}`).join("\n\n")
266
- },
267
- },
268
- {
269
- name: "memory_delete",
270
- description:
271
- "Delete a memory entry by its id (as returned by memory_put / memory_search) and scope. " +
272
- "Scope is required and must match the id prefix — this prevents accidental deletion in another scope. " +
273
- "Returns the deleted entry's title and content so the deletion is auditable and recoverable.",
274
- parameters: {
275
- type: "object",
276
- properties: {
277
- id: { type: "string", description: "Entry id: personal:<n> / project:<origin>:<path> / team:<origin>:<path>" },
278
- scope: { type: "string", enum: ["personal", "project", "team"], description: "Scope of the entry to delete (required)" },
279
- },
280
- required: ["id", "scope"],
281
- },
282
- readonly: false,
283
- async execute(args) {
284
- const uid = String(args.id)
285
- const prefix = uid.split(":")[0]
286
- const uidScope = prefix === "personal" || prefix === "project" || prefix === "team" ? prefix : /^\d+$/.test(prefix) ? "personal" : null
287
- if (!uidScope) throw new Error(`invalid memory id: ${uid}`)
288
- if (uidScope !== args.scope) throw new Error(`id prefix ${prefix}: 与 scope ${args.scope} 不匹配`)
289
- const entry = await deleteByUid(memory, uid, { dirs })
290
- return `Deleted ${entry.id}: ${entry.title}\n${(entry.content ?? "").slice(0, 500)}`
291
272
  },
292
273
  },
293
274
  ]
294
275
  }
276
+
277
+ /** action search — the retired search tool surface (read-only, same output contract). */
278
+ async function execSearch(memory, args) {
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}"`)
282
+ }
283
+ const query = String(args.query ?? "").trim()
284
+ if (!query) return "(no matching memories)" // 空 query 短路——两端同语义(评审 code review #4)
285
+ const limit = normalizeLimit(args.limit, 5)
286
+ let results
287
+ if (!layer) {
288
+ results = await search(memory, query, { limit })
289
+ } else {
290
+ // layer filter: oversample then slice the requested layer (results keep global rank order).
291
+ // 窗口 = max(limit*4, 20) 是召回上限——大库 + 高 limit 时该层结果可能不足 limit(接受的取舍——评审 code review #3)
292
+ const wide = await search(memory, query, { limit: Math.max(limit * 4, 20) })
293
+ results = wide.filter((r) => r.layer === String(layer)).slice(0, limit)
294
+ }
295
+ if (results.length === 0) return "(no matching memories)"
296
+ return results.map((r) => `[${r.layer}][${r.type}] ${r.title} (id=${r.id})\n${r.content}`).join("\n\n")
297
+ }
298
+
299
+ /** action put — the retired put tool surface (side-effect gate, unchanged semantics). */
300
+ async function execPut(memory, args, opts, dirs) {
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") {
304
+ const id = await put(memory, { type: args.type, title: args.title, content: args.content, tags: args.tags ?? "" })
305
+ return `Saved to personal memory (id=personal:${id}): [${args.type}] ${args.title}`
306
+ }
307
+ if (layer === "project") {
308
+ if (!dirs.project) throw new Error("project layer unavailable: no project directory configured")
309
+ const filename = await putMarkdown(memory, {
310
+ layer: "project",
311
+ dir: dirs.project,
312
+ type: args.type,
313
+ title: args.title,
314
+ content: args.content,
315
+ tags: (args.tags ?? "").split(/\s+/).filter(Boolean),
316
+ author: opts.author ?? "unknown",
317
+ })
318
+ return `Saved to project memory (id=project:${dirs.project}:${filename}): [${args.type}] ${args.title}`
319
+ }
320
+ if (!dirs.team) {
321
+ throw new Error("team layer not configured: set memory.team in ~/.thincoder/config.json")
322
+ }
323
+ const filename = await putMarkdown(memory, {
324
+ layer: "team",
325
+ dir: dirs.team,
326
+ type: args.type,
327
+ title: args.title,
328
+ content: args.content,
329
+ tags: (args.tags ?? "").split(/\s+/).filter(Boolean),
330
+ author: opts.author ?? "unknown",
331
+ })
332
+ await commitAndPush(dirs.team, filename, `memory: [${args.type}] ${args.title}`)
333
+ return `Saved to team memory and pushed (id=team:${dirs.team}:${filename}): [${args.type}] ${args.title}`
334
+ }
335
+
336
+ /** action list — new inventory action (read-only): layer/type/keyword filters + limit truncation note. */
337
+ async function execList(memory, args, dirs) {
338
+ const layer = args.layer ?? null
339
+ if (layer && !MEMORY_LAYERS.includes(String(layer))) throw new Error(`memory list: invalid layer "${layer}"`)
340
+ const rows = await matchMemoryRows(memory, {
341
+ layer: layer ? String(layer) : null,
342
+ type: validateTypeFilter(args.type),
343
+ keyword: args.keyword ? String(args.keyword).trim() : null,
344
+ projectDir: dirs.project,
345
+ teamDir: dirs.team,
346
+ })
347
+ if (rows.length === 0) return "0 条匹配"
348
+ const limit = normalizeLimit(args.limit, 50)
349
+ const shown = rows.slice(0, limit)
350
+ const lines = shown.map(listRowLine)
351
+ if (rows.length > shown.length) lines.unshift(`${shown.length} 条——截断前 ${rows.length}`)
352
+ return lines.join("\n")
353
+ }
354
+
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). */
357
+ async function execDelete(memory, args, dirs) {
358
+ const hasId = args.id !== undefined && args.id !== null && String(args.id) !== ""
359
+ if (hasId) return execDeleteSingle(memory, args, dirs)
360
+ // batch form
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}"`)
364
+ const type = validateTypeFilter(args.type)
365
+ const keyword = args.keyword ? String(args.keyword).trim() : null
366
+ if (!type && !keyword) {
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)")
368
+ }
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 }
372
+ const rows = await matchMemoryRows(memory, { ...filters, projectDir: dirs.project, teamDir: dirs.team })
373
+ if (rows.length === 0) return "0 条匹配"
374
+ if (args.confirm !== true) {
375
+ const lines = [rows.length > 5 ? `将删 ${rows.length} 条:前 5 条预览` : `将删 ${rows.length} 条`]
376
+ lines.push(...rows.slice(0, 5).map(listRowLine))
377
+ if (rows.length > 5) lines.push(`5 条——截断前 ${rows.length}`)
378
+ lines.push("confirm:true required — re-send with it to execute the deletion")
379
+ return lines.join("\n")
380
+ }
381
+ const n = await deleteWhere(memory, filters, { dirs })
382
+ return `Deleted ${n} entries in layer ${layer}`
383
+ }
384
+
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). */
389
+ async function execDeleteSingle(memory, args, dirs) {
390
+ const uid = String(args.id)
391
+ const prefix = uid.split(":")[0]
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
+ }
398
+ const entry = await deleteByUid(memory, uid, { dirs })
399
+ return `Deleted ${entry.id}: ${entry.title}\n${(entry.content ?? "").slice(0, 500)}`
400
+ }
401
+
402
+ /** action clear — personal-only full wipe (layer + confirm:true gates; project/team refused). */
403
+ function execClear(memory, args) {
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}"`)
408
+ throw new Error("shared layers don't support clear — use delete with type/keyword batch filters instead")
409
+ }
410
+ if (args.confirm !== true) throw new Error("clear requires confirm:true — this wipes ALL personal memory")
411
+ const n = clearPersonal(memory)
412
+ return `Cleared personal memory (${n} entries deleted)`
413
+ }
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, 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"
@@ -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)) {
@@ -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
+ }