thincoder 0.12.61 → 0.12.63

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 (230) hide show
  1. package/CHANGELOG.md +34 -1
  2. package/README.md +13 -12
  3. package/bin/thincoder.mjs +63 -28
  4. package/package.json +6 -5
  5. package/src/acp/bridge.mjs +35 -15
  6. package/src/acp/client-caps.mjs +86 -0
  7. package/src/acp/ext.mjs +86 -0
  8. package/src/acp/handlers-session.mjs +240 -0
  9. package/src/acp/handlers-slots.mjs +196 -0
  10. package/src/acp/login.mjs +48 -0
  11. package/src/acp/session.mjs +6 -4
  12. package/src/acp.mjs +67 -371
  13. package/src/cli/distill-command.mjs +3 -3
  14. package/src/cli/make-agent.mjs +59 -17
  15. package/src/cli/memory-command.mjs +3 -3
  16. package/src/cli/permission.mjs +4 -48
  17. package/src/cli/setup-wizard.mjs +1 -1
  18. package/src/completions.mjs +3 -1
  19. package/src/crash-reports.mjs +32 -10
  20. package/src/distill.mjs +4 -4
  21. package/src/heap-watch.mjs +88 -0
  22. package/src/prompt-injections.mjs +20 -0
  23. package/src/tui/agent-turn.mjs +40 -9
  24. package/src/tui/cmd-advisor.mjs +5 -5
  25. package/src/tui/cmd-clear.mjs +2 -0
  26. package/src/tui/cmd-config.mjs +8 -8
  27. package/src/tui/cmd-eng.mjs +25 -9
  28. package/src/tui/cmd-mcp.mjs +9 -8
  29. package/src/tui/cmd-model.mjs +1 -1
  30. package/src/tui/cmd-new.mjs +10 -5
  31. package/src/tui/cmd-reindex.mjs +1 -1
  32. package/src/tui/cmd-restore.mjs +2 -2
  33. package/src/tui/cmd-session.mjs +31 -4
  34. package/src/tui/cmd-skills.mjs +1 -1
  35. package/src/tui/cmd-think.mjs +22 -9
  36. package/src/tui/config-helpers.mjs +1 -1
  37. package/src/tui/display-budget.mjs +206 -0
  38. package/src/tui/index.mjs +40 -12
  39. package/src/tui/interaction.mjs +16 -7
  40. package/src/tui/key-handler-search.mjs +9 -1
  41. package/src/tui/key-modes.mjs +9 -4
  42. package/src/tui/ledger-surface.mjs +85 -0
  43. package/src/tui/model-catalog.mjs +4 -4
  44. package/src/tui/model-picker.mjs +8 -7
  45. package/src/tui/mouse.mjs +11 -6
  46. package/src/tui/pickers.mjs +15 -2
  47. package/src/tui/render-conversation.mjs +1 -1
  48. package/src/tui/render-frame.mjs +17 -6
  49. package/src/tui/render-loop.mjs +1 -1
  50. package/src/tui/render-segments.mjs +3 -1
  51. package/src/tui/slash-commands.mjs +1 -1
  52. package/src/tui/startup.mjs +49 -17
  53. package/src/tui/subagent-blocks.mjs +21 -3
  54. package/src/tui/subagent-children.mjs +86 -14
  55. package/src/tui/subagent-freeze.mjs +80 -3
  56. package/src/tui/suspension-drive.mjs +48 -22
  57. package/src/tui/tool-args.mjs +5 -2
  58. package/src/tui/tool-display.mjs +16 -2
  59. package/src/tui/tool-events.mjs +64 -22
  60. package/src/tui/tui-lifecycle.mjs +9 -2
  61. package/src/tui/wizard.mjs +3 -3
  62. package/src/tui/wrapped-spawn.mjs +21 -5
  63. package/src/abort-provenance.mjs +0 -116
  64. package/src/advisor/citations.mjs +0 -139
  65. package/src/advisor/compaction.mjs +0 -174
  66. package/src/advisor/convergence.mjs +0 -80
  67. package/src/advisor/history.mjs +0 -77
  68. package/src/advisor/loop.mjs +0 -293
  69. package/src/advisor/messages.mjs +0 -299
  70. package/src/advisor/project-context.mjs +0 -194
  71. package/src/advisor/repos.mjs +0 -150
  72. package/src/advisor/run.mjs +0 -293
  73. package/src/advisor/truncate.mjs +0 -57
  74. package/src/advisor.mjs +0 -290
  75. package/src/agent/completion.mjs +0 -146
  76. package/src/agent/dispatch.mjs +0 -489
  77. package/src/agent/helpers.mjs +0 -384
  78. package/src/agent/post-turn.mjs +0 -70
  79. package/src/agent/record-results.mjs +0 -174
  80. package/src/agent/relay-prefix.mjs +0 -39
  81. package/src/agent/run-stages.mjs +0 -242
  82. package/src/agent/setup-reminders.mjs +0 -69
  83. package/src/agent/setup.mjs +0 -354
  84. package/src/agent/spawn-child.mjs +0 -228
  85. package/src/agent-tools/advisor-async.mjs +0 -346
  86. package/src/agent-tools/advisor-settle.mjs +0 -231
  87. package/src/agent-tools/advisor.mjs +0 -260
  88. package/src/agent-tools/async-settle.mjs +0 -191
  89. package/src/agent-tools/batch-segment.mjs +0 -195
  90. package/src/agent-tools/consult.mjs +0 -468
  91. package/src/agent-tools/design-token.mjs +0 -117
  92. package/src/agent-tools/digest-budget.mjs +0 -76
  93. package/src/agent-tools/eng.mjs +0 -67
  94. package/src/agent-tools/escalate-async.mjs +0 -289
  95. package/src/agent-tools/goal.mjs +0 -119
  96. package/src/agent-tools/plan.mjs +0 -81
  97. package/src/agent-tools/read-history.mjs +0 -294
  98. package/src/agent-tools/recent-changes.mjs +0 -24
  99. package/src/agent-tools/review-streak.mjs +0 -93
  100. package/src/agent-tools/settings.mjs +0 -265
  101. package/src/agent-tools/skill.mjs +0 -47
  102. package/src/agent-tools/subagent-actions.mjs +0 -479
  103. package/src/agent-tools/subagent-async.mjs +0 -434
  104. package/src/agent-tools/subagent-panel.mjs +0 -160
  105. package/src/agent-tools/subagent-run.mjs +0 -205
  106. package/src/agent-tools/subagent-scheduler.mjs +0 -392
  107. package/src/agent-tools/subagent-spawn.mjs +0 -453
  108. package/src/agent-tools/subagent.mjs +0 -404
  109. package/src/agent-tools/task.mjs +0 -87
  110. package/src/agent-tools/timer.mjs +0 -46
  111. package/src/agent-tools/verify.mjs +0 -271
  112. package/src/agent-tools.mjs +0 -17
  113. package/src/agent.mjs +0 -413
  114. package/src/auto-think.mjs +0 -115
  115. package/src/config-migrate.mjs +0 -70
  116. package/src/config.mjs +0 -496
  117. package/src/context.mjs +0 -381
  118. package/src/conventions.mjs +0 -223
  119. package/src/embedding.mjs +0 -120
  120. package/src/escape.mjs +0 -152
  121. package/src/expand-home.mjs +0 -16
  122. package/src/explore-distill.mjs +0 -155
  123. package/src/generate-title.mjs +0 -83
  124. package/src/git/checkpoint.mjs +0 -448
  125. package/src/git/gitmem.mjs +0 -100
  126. package/src/hooks.mjs +0 -97
  127. package/src/log.mjs +0 -195
  128. package/src/markdown.mjs +0 -106
  129. package/src/mcp/helpers.mjs +0 -51
  130. package/src/mcp/transport-http.mjs +0 -248
  131. package/src/mcp/transport-stdio.mjs +0 -140
  132. package/src/mcp/transport-ws.mjs +0 -122
  133. package/src/mcp.mjs +0 -295
  134. package/src/memory/code-index.mjs +0 -219
  135. package/src/memory/code-sync.mjs +0 -413
  136. package/src/memory/core.mjs +0 -300
  137. package/src/memory/delete.mjs +0 -236
  138. package/src/memory/docs.mjs +0 -417
  139. package/src/memory/file-walk.mjs +0 -109
  140. package/src/memory/schema.mjs +0 -452
  141. package/src/memory.mjs +0 -21
  142. package/src/model-ref.mjs +0 -66
  143. package/src/model-specs.mjs +0 -179
  144. package/src/peer-domains.mjs +0 -265
  145. package/src/peer-instances.mjs +0 -231
  146. package/src/prompt-overlays.mjs +0 -82
  147. package/src/prompts/advisor-design.md +0 -41
  148. package/src/prompts/advisor-round1.md +0 -41
  149. package/src/prompts/advisor-round2.md +0 -46
  150. package/src/prompts/advisor-round3.md +0 -42
  151. package/src/prompts/common.md +0 -115
  152. package/src/prompts/consult-base.md +0 -19
  153. package/src/prompts/discipline-engineering.md +0 -217
  154. package/src/prompts/discipline-normal.md +0 -179
  155. package/src/prompts/persona-coder.md +0 -21
  156. package/src/prompts/persona-eng-coder.md +0 -37
  157. package/src/prompts/persona-eng-designer.md +0 -55
  158. package/src/prompts/persona-engineering.md +0 -54
  159. package/src/prompts/persona-explore.md +0 -15
  160. package/src/prompts/persona-normal.md +0 -27
  161. package/src/prompts/persona-plan.md +0 -26
  162. package/src/provider/anthropic.mjs +0 -225
  163. package/src/provider/core.mjs +0 -476
  164. package/src/provider/errors.mjs +0 -101
  165. package/src/provider/google.mjs +0 -257
  166. package/src/provider/index.mjs +0 -7
  167. package/src/provider/list-models.mjs +0 -93
  168. package/src/provider/normalize.mjs +0 -81
  169. package/src/provider/rate.mjs +0 -108
  170. package/src/provider/responses.mjs +0 -495
  171. package/src/provider/retry.mjs +0 -88
  172. package/src/provider/sse.mjs +0 -264
  173. package/src/proxy.mjs +0 -261
  174. package/src/rules.mjs +0 -53
  175. package/src/session-gc.mjs +0 -214
  176. package/src/session-guard.mjs +0 -47
  177. package/src/session-migrate.mjs +0 -48
  178. package/src/session-rename.mjs +0 -38
  179. package/src/session-slots.mjs +0 -489
  180. package/src/session.mjs +0 -475
  181. package/src/skills.mjs +0 -153
  182. package/src/token-ttl.mjs +0 -274
  183. package/src/tools/apply_patch.md +0 -15
  184. package/src/tools/bash.md +0 -37
  185. package/src/tools/bash.mjs +0 -268
  186. package/src/tools/checklist-sync.mjs +0 -181
  187. package/src/tools/checklist.md +0 -13
  188. package/src/tools/checklist.mjs +0 -299
  189. package/src/tools/delete.md +0 -13
  190. package/src/tools/edit-batch.mjs +0 -191
  191. package/src/tools/edit-diff.mjs +0 -348
  192. package/src/tools/edit.md +0 -30
  193. package/src/tools/execute.md +0 -21
  194. package/src/tools/execute.mjs +0 -228
  195. package/src/tools/fetch.md +0 -12
  196. package/src/tools/file.mjs +0 -469
  197. package/src/tools/file_ops.md +0 -17
  198. package/src/tools/get_current_time.md +0 -8
  199. package/src/tools/git-checkpoint.mjs +0 -143
  200. package/src/tools/git-ext.mjs +0 -173
  201. package/src/tools/git.md +0 -54
  202. package/src/tools/git.mjs +0 -356
  203. package/src/tools/glob-dialect.mjs +0 -130
  204. package/src/tools/glob.md +0 -11
  205. package/src/tools/grep.md +0 -19
  206. package/src/tools/hashline_edit.md +0 -14
  207. package/src/tools/index.mjs +0 -36
  208. package/src/tools/insert_after.md +0 -15
  209. package/src/tools/lint.md +0 -10
  210. package/src/tools/linter.mjs +0 -128
  211. package/src/tools/ls.md +0 -12
  212. package/src/tools/lsp.md +0 -10
  213. package/src/tools/lsp.mjs +0 -316
  214. package/src/tools/ops.mjs +0 -299
  215. package/src/tools/patch.mjs +0 -282
  216. package/src/tools/process.md +0 -10
  217. package/src/tools/question.md +0 -16
  218. package/src/tools/question.mjs +0 -26
  219. package/src/tools/read.md +0 -20
  220. package/src/tools/read_image.md +0 -8
  221. package/src/tools/repomap.mjs +0 -314
  222. package/src/tools/search.mjs +0 -236
  223. package/src/tools/shared.mjs +0 -446
  224. package/src/tools/tree.md +0 -14
  225. package/src/tools/tree.mjs +0 -66
  226. package/src/tools/wait_for.md +0 -22
  227. package/src/tools/web.mjs +0 -224
  228. package/src/tools/websearch.md +0 -16
  229. package/src/tools/write.md +0 -11
  230. package/src/traces/trace-store.mjs +0 -224
@@ -1,417 +0,0 @@
1
- /**
2
- * memory/docs.mjs — doc index sync, retrieval, agent tool generation
3
- */
4
-
5
- import { readFile, stat } from "node:fs/promises"
6
- import { isAbsolute, join } from "node:path"
7
- import { embed, cosine, toBlob, fromBlob } from "../embedding.mjs"
8
- import { commitAndPush } from "../git/gitmem.mjs"
9
- import { MAX_DOC_FILE_BYTES } from "./schema.mjs"
10
- import { buildFtsQuery, put, search, putMarkdown, clearPersonal, EMBED_TEXT_MAX_LEN } from "./core.mjs"
11
- import { deleteByUid, matchMemoryRows, deleteWhere } from "./delete.mjs"
12
- import { _upsertDocFile, yieldTick } from "./code-index.mjs"
13
- import { markIndexedCommit, listProjectFiles, indexExtensions } from "./code-sync.mjs"
14
- import { logEvent } from "../log.mjs"
15
-
16
- const DOC_EMBED_BATCH = 64
17
-
18
- /**
19
- * Sync doc index: scan all .md/.mdc/.txt/.rst/.adoc under dir → chunk → upsert into doc_chunks.
20
- * Incremental by mtime.
21
- */
22
- export async function docSync(memory, dir, { onProgress } = {}) {
23
- const { entries, unlisted } = await listProjectFiles(dir, indexExtensions(dir).doc)
24
- const files = [] // { abs, rel, mtimeMs }
25
- let overSizeSkipped = 0
26
- for (const { abs, rel } of entries) {
27
- let st
28
- try { st = await stat(abs) } catch { continue }
29
- if (st.size > MAX_DOC_FILE_BYTES) { overSizeSkipped++; continue }
30
- files.push({ abs, rel, mtimeMs: Math.floor(st.mtimeMs) })
31
- }
32
-
33
- const indexed = new Map(
34
- memory.db.prepare(`SELECT path, mtime_ms FROM doc_chunks WHERE origin = ?`).all(dir).map((r) => [r.path, r.mtime_ms])
35
- )
36
- const seen = new Set()
37
-
38
- onProgress?.({ phase: "scan", total: files.length, overSizeSkipped })
39
-
40
- let updated = 0, removed = 0, skipped = 0, failed = 0
41
- const errors = []
42
- for (let i = 0; i < files.length; i++) {
43
- const { abs, rel, mtimeMs } = files[i]
44
- seen.add(rel)
45
-
46
- if (indexed.get(rel) === mtimeMs) {
47
- skipped++
48
- continue
49
- }
50
-
51
- try {
52
- const text = await readFile(abs, "utf8")
53
- const lines = text.split("\n")
54
- _upsertDocFile(memory, dir, rel, lines, mtimeMs)
55
- updated++
56
- } catch (e) {
57
- failed++
58
- if (errors.length < 5) errors.push(`${rel}: ${e.message}`)
59
- }
60
- await yieldTick()
61
-
62
- if (onProgress && i % 10 === 0) {
63
- onProgress({ phase: "index", current: i + 1, total: files.length, updated, removed, skipped, failed })
64
- }
65
- }
66
-
67
- for (const stale of indexed.keys()) {
68
- if (!seen.has(stale)) {
69
- memory.db.prepare(`DELETE FROM doc_chunks WHERE origin = ? AND path = ?`).run(dir, stale)
70
- removed++
71
- }
72
- }
73
-
74
- onProgress?.({ phase: "done", total: files.length, updated, removed, skipped, failed, overSizeSkipped })
75
- markIndexedCommit(memory, dir)
76
- if (unlisted.count > 0) {
77
- logEvent("index:unlisted", { dir, kind: "doc", count: unlisted.count, exts: unlisted.exts.map((e) => e.ext) })
78
- }
79
- return { updated, removed, skipped, failed, errors, total: files.length, overSizeSkipped, unlistedExts: unlisted }
80
- }
81
-
82
- /**
83
- * Doc search: FTS5(BM25) + optional vector cosine, RRF merged.
84
- * Falls back to pure FTS when no embedder; falls back to pure vector when ftsQuery is empty and embedder is present.
85
- */
86
- export async function docSearch(memory, query, { limit = 5 } = {}) {
87
- const ftsQuery = buildFtsQuery(query)
88
- if (!ftsQuery && !memory.embedder) return []
89
-
90
- const ftsOriginFilter = memory.codeOrigin ? `AND d.origin = ?` : ""
91
- const vecOriginFilter = memory.codeOrigin ? `AND origin = ?` : ""
92
- const originParams = memory.codeOrigin ? [memory.codeOrigin] : []
93
-
94
- const ftsList = ftsQuery ? memory.db.prepare(`
95
- SELECT d.rowid, d.path, d.language, d.heading, d.content, d.line_start, d.line_end, bm25(doc_chunks_fts) AS rank
96
- FROM doc_chunks_fts JOIN doc_chunks d ON d.rowid = doc_chunks_fts.rowid
97
- WHERE doc_chunks_fts MATCH ? ${ftsOriginFilter}
98
- ORDER BY rank LIMIT ?
99
- `).all(ftsQuery, ...originParams, Math.max(limit * 4, 20)) : []
100
-
101
- if (!memory.embedder) return ftsList.slice(0, limit)
102
-
103
- try { await ensureDocEmbeddings(memory) } catch (e) {
104
- console.error(`[docs] embedding ensure failed, falling back to FTS-only: ${e.message}`)
105
- return ftsList.slice(0, limit)
106
- }
107
- let qvec
108
- try { [qvec] = await embed(memory.embedder, [query]) } catch (e) {
109
- console.error(`[docs] query embedding failed, falling back to FTS-only: ${e.message}`)
110
- return ftsList.slice(0, limit)
111
- }
112
- const rows = memory.db.prepare(`SELECT rowid, embedding FROM doc_chunks WHERE embedding IS NOT NULL ${vecOriginFilter}`).all(...originParams)
113
- const vecList = rows
114
- .map((r) => ({ rowid: r.rowid, score: cosine(qvec, fromBlob(r.embedding)) }))
115
- .sort((a, b) => b.score - a.score)
116
- .slice(0, Math.max(limit * 4, 20))
117
-
118
- const K = 60
119
- const scores = new Map()
120
- ftsList.forEach((r, i) => scores.set(r.rowid, (scores.get(r.rowid) ?? 0) + 1 / (K + i + 1)))
121
- vecList.forEach((r, i) => scores.set(r.rowid, (scores.get(r.rowid) ?? 0) + 1 / (K + i + 1)))
122
-
123
- const fetchChunk = memory.db.prepare(`
124
- SELECT path, language, heading, content, line_start, line_end FROM doc_chunks WHERE rowid = ?
125
- `)
126
- const sorted = [...scores.entries()]
127
- .sort((a, b) => b[1] - a[1])
128
- .slice(0, limit)
129
- return sorted
130
- .map(([rowid, score]) => {
131
- const chunk = fetchChunk.get(rowid)
132
- if (!chunk) return null
133
- chunk._score = Math.round(score * 100) / 100
134
- return chunk
135
- })
136
- .filter(Boolean)
137
- }
138
-
139
- /** Lazily backfill missing vectors for doc_chunks. Guarded against concurrent calls. */
140
- let _docEmbedLock = null
141
- export function ensureDocEmbeddings(memory) {
142
- if (_docEmbedLock) return _docEmbedLock
143
- _docEmbedLock = _runEnsureDocEmbeddings(memory).finally(() => { _docEmbedLock = null })
144
- return _docEmbedLock
145
- }
146
-
147
- async function _runEnsureDocEmbeddings(memory) {
148
- if (!memory.embedder) return
149
- const modelKey = memory.embedder.model
150
- const stored = memory.db.prepare(`SELECT value FROM meta WHERE key = 'doc_embedding_model'`).get()?.value
151
- if (stored !== modelKey) {
152
- memory.db.prepare(`UPDATE doc_chunks SET embedding = NULL`).run()
153
- memory.db.prepare(`INSERT INTO meta (key, value) VALUES ('doc_embedding_model', ?)
154
- ON CONFLICT (key) DO UPDATE SET value = excluded.value`).run(modelKey)
155
- }
156
-
157
- const pending = memory.db.prepare(`SELECT rowid, path, heading, content FROM doc_chunks WHERE embedding IS NULL LIMIT ${DOC_EMBED_BATCH}`).all()
158
- if (pending.length === 0) return
159
-
160
- const texts = pending.map((r) => `${r.heading || r.path}\n${r.content.slice(0, EMBED_TEXT_MAX_LEN)}`)
161
- const vecs = await embed(memory.embedder, texts)
162
-
163
- const update = memory.db.prepare(`UPDATE doc_chunks SET embedding = ? WHERE rowid = ?`)
164
- pending.forEach((r, i) => update.run(toBlob(vecs[i]), r.rowid))
165
- }
166
-
167
- /** Generate the doc_search tool (read-only). */
168
- export function docSearchTool(memory) {
169
- return {
170
- name: "doc_search",
171
- description:
172
- "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. " +
173
- "Returns matching doc chunks: path, heading, line range, relevance score, content excerpt. " +
174
- "For what was said in sessions (conversation/chat history — decisions, rulings), use read_history.",
175
- parameters: {
176
- type: "object",
177
- properties: {
178
- query: { type: "string", description: "Natural language search query" },
179
- limit: { type: "number", description: "Max results (default 5)" },
180
- },
181
- required: ["query"],
182
- },
183
- readonly: true,
184
- async execute(args) {
185
- const results = await docSearch(memory, args.query, { limit: args.limit ?? 5 })
186
- if (results.length === 0) return "(no matching documentation)"
187
- return results.map((r) =>
188
- `${r.path}${r.heading ? ` > ${r.heading}` : ""} (L${r.line_start}-L${r.line_end}, relevance ${r._score?.toFixed(2) ?? "?"}):\n${r.content.slice(0, 2000)}`
189
- ).join("\n\n---\n\n")
190
- },
191
- }
192
- }
193
-
194
- // ---------------------------------------------------------------- agent tools
195
-
196
- /** §6 shared tool surface — action enum / parameter shapes / descriptions byte-identical
197
- * with thincoder-vscode/src/memory.mjs (MEMORY.md §6 D-M1/F-M6); layer VALUES per end
198
- * (VS Code has no team layer and rejects it with CLI guidance). */
199
- const MEMORY_ACTIONS = ["search", "put", "list", "delete", "clear"]
200
- const MEMORY_LAYERS = ["personal", "project", "team"]
201
- const MEMORY_TOOL_DESCRIPTION =
202
- "Manage long-term memory in ONE tool — the action parameter picks the operation:\n" +
203
- "- 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" +
204
- "- 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" +
205
- "- 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" +
206
- "- 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" +
207
- "- 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" +
208
- "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" +
209
- "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" +
210
- "Save bugs, conventions, and preferences here — they persist across sessions.\n" +
211
- "Session message history (what was said in this or past sessions) is NOT in memory — search session messages with read_history."
212
-
213
- function validateTypeFilter(type) {
214
- if (type === undefined || type === null || type === "") return null
215
- const t = String(type)
216
- if (!["rule", "knowledge", "decision", "pattern"].includes(t)) throw new Error(`Invalid memory type "${t}"; expected one of: rule, knowledge, decision, pattern`)
217
- return t
218
- }
219
-
220
- function normalizeLimit(limit, dflt) {
221
- const n = Number(limit)
222
- return Number.isFinite(n) && n > 0 ? Math.floor(n) : dflt
223
- }
224
-
225
- function fmtDate(ts) {
226
- return ts ? new Date(ts).toISOString().slice(0, 10) : "?"
227
- }
228
-
229
- const listRowLine = (r) => `[${r.layer}] ${r.id} [${r.type}] ${r.title}(${fmtDate(r.ts)})`
230
-
231
- /**
232
- * Generate the memory agent tool — ONE `memory` tool with five actions (MEMORY.md §6 D-M1).
233
- * search/list are read-only actions (planMode pass / no permission ask — dispatch classifies
234
- * them action-level, same as subagent check/status); put keeps its side-effect permission
235
- * gate; batch delete/clear gate on confirm:true + layer inside the tool (direct-delete
236
- * ruling — the confirm parameter IS the gate) and stay non-readonly like the retired tools.
237
- * opts: { cwd, projectDir, author, team: { dir, name } | null }
238
- */
239
- export function memoryTools(memory, opts = {}) {
240
- const projectDir = opts.projectDir ? (isAbsolute(opts.projectDir) ? opts.projectDir : join(opts.cwd ?? process.cwd(), opts.projectDir)) : null
241
- const dirs = { project: projectDir, team: opts.team?.dir ?? null }
242
- return [
243
- {
244
- name: "memory",
245
- description: MEMORY_TOOL_DESCRIPTION,
246
- parameters: {
247
- type: "object",
248
- properties: {
249
- action: { type: "string", enum: MEMORY_ACTIONS, description: "Operation to run (required)" },
250
- 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" },
251
- type: { type: "string", enum: ["rule", "knowledge", "decision", "pattern"], description: "Entry type: put = what to save; list/delete batch = filter by type" },
252
- title: { type: "string", description: "put: short title" },
253
- content: { type: "string", description: "put: full content to remember" },
254
- tags: { type: "string", description: "put: space-separated tags" },
255
- query: { type: "string", description: "search: natural-language query" },
256
- keyword: { type: "string", description: "list/delete batch: filter matching title/content" },
257
- id: { type: "string", description: "delete single: the entry id from put/search/list output" },
258
- limit: { type: "number", description: "Max rows: list 50 by default, search 5 by default" },
259
- confirm: { type: "boolean", description: "delete batch/clear: must be true — without it the tool refuses" },
260
- },
261
- required: ["action"],
262
- },
263
- readonly: false,
264
- async execute(args) {
265
- const action = String(args?.action ?? "")
266
- if (!MEMORY_ACTIONS.includes(action)) {
267
- throw new Error(`memory: unknown action "${action}" — expected one of: ${MEMORY_ACTIONS.join("/")}`)
268
- }
269
- switch (action) {
270
- case "search": return execSearch(memory, args)
271
- case "put": return execPut(memory, args, opts, dirs)
272
- case "list": return execList(memory, args, dirs)
273
- case "delete": return execDelete(memory, args, dirs)
274
- case "clear": return execClear(memory, args)
275
- }
276
- },
277
- },
278
- ]
279
- }
280
-
281
- /** action search — the retired search tool surface (read-only, same output contract). */
282
- async function execSearch(memory, args) {
283
- const layer = args.layer
284
- if (layer !== undefined && layer !== null && !MEMORY_LAYERS.includes(String(layer))) {
285
- throw new Error(`memory search: invalid layer "${layer}"`)
286
- }
287
- const query = String(args.query ?? "").trim()
288
- if (!query) return "(no matching memories)" // 空 query 短路——两端同语义(评审 code review #4)
289
- const limit = normalizeLimit(args.limit, 5)
290
- let results
291
- if (!layer) {
292
- results = await search(memory, query, { limit })
293
- } else {
294
- // layer filter: oversample then slice the requested layer (results keep global rank order).
295
- // 窗口 = max(limit*4, 20) 是召回上限——大库 + 高 limit 时该层结果可能不足 limit(接受的取舍——评审 code review #3)
296
- const wide = await search(memory, query, { limit: Math.max(limit * 4, 20) })
297
- results = wide.filter((r) => r.layer === String(layer)).slice(0, limit)
298
- }
299
- if (results.length === 0) return "(no matching memories)"
300
- return results.map((r) => `[${r.layer}][${r.type}] ${r.title} (id=${r.id})\n${r.content}`).join("\n\n")
301
- }
302
-
303
- /** action put — the retired put tool surface (side-effect gate, unchanged semantics). */
304
- async function execPut(memory, args, opts, dirs) {
305
- const layer = String(args.layer ?? "personal")
306
- if (!MEMORY_LAYERS.includes(layer)) throw new Error(`memory put: invalid layer "${layer}"`)
307
- if (layer === "personal") {
308
- const id = await put(memory, { type: args.type, title: args.title, content: args.content, tags: args.tags ?? "" })
309
- return `Saved to personal memory (id=personal:${id}): [${args.type}] ${args.title}`
310
- }
311
- if (layer === "project") {
312
- if (!dirs.project) throw new Error("project layer unavailable: no project directory configured")
313
- const filename = await putMarkdown(memory, {
314
- layer: "project",
315
- dir: dirs.project,
316
- type: args.type,
317
- title: args.title,
318
- content: args.content,
319
- tags: (args.tags ?? "").split(/\s+/).filter(Boolean),
320
- author: opts.author ?? "unknown",
321
- })
322
- return `Saved to project memory (id=project:${dirs.project}:${filename}): [${args.type}] ${args.title}`
323
- }
324
- if (!dirs.team) {
325
- throw new Error("team layer not configured: set memory.team in ~/.thincoder/config.json")
326
- }
327
- const filename = await putMarkdown(memory, {
328
- layer: "team",
329
- dir: dirs.team,
330
- type: args.type,
331
- title: args.title,
332
- content: args.content,
333
- tags: (args.tags ?? "").split(/\s+/).filter(Boolean),
334
- author: opts.author ?? "unknown",
335
- })
336
- await commitAndPush(dirs.team, filename, `memory: [${args.type}] ${args.title}`)
337
- return `Saved to team memory and pushed (id=team:${dirs.team}:${filename}): [${args.type}] ${args.title}`
338
- }
339
-
340
- /** action list — new inventory action (read-only): layer/type/keyword filters + limit truncation note. */
341
- async function execList(memory, args, dirs) {
342
- const layer = args.layer ?? null
343
- if (layer && !MEMORY_LAYERS.includes(String(layer))) throw new Error(`memory list: invalid layer "${layer}"`)
344
- const rows = await matchMemoryRows(memory, {
345
- layer: layer ? String(layer) : null,
346
- type: validateTypeFilter(args.type),
347
- keyword: args.keyword ? String(args.keyword).trim() : null,
348
- projectDir: dirs.project,
349
- teamDir: dirs.team,
350
- })
351
- if (rows.length === 0) return "0 条匹配"
352
- const limit = normalizeLimit(args.limit, 50)
353
- const shown = rows.slice(0, limit)
354
- const lines = shown.map(listRowLine)
355
- if (rows.length > shown.length) lines.unshift(`${shown.length} 条——截断前 ${rows.length}`)
356
- return lines.join("\n")
357
- }
358
-
359
- /** action delete — single ({ id, layer? } — MEMORY.md §6.2: layer OPTIONAL, validated when
360
- * passed, else the id prefix routes the delete) + batch (layer + type/keyword + confirm). */
361
- async function execDelete(memory, args, dirs) {
362
- const hasId = args.id !== undefined && args.id !== null && String(args.id) !== ""
363
- if (hasId) return execDeleteSingle(memory, args, dirs)
364
- // batch form
365
- const layer = args.layer
366
- if (!layer) throw new Error("batch delete requires layer plus type and/or keyword filter")
367
- if (!MEMORY_LAYERS.includes(String(layer))) throw new Error(`memory delete: invalid layer "${layer}"`)
368
- const type = validateTypeFilter(args.type)
369
- const keyword = args.keyword ? String(args.keyword).trim() : null
370
- if (!type && !keyword) {
371
- 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)")
372
- }
373
- if (layer === "project" && !dirs.project) throw new Error("project layer unavailable: no project directory configured")
374
- if (layer === "team" && !dirs.team) throw new Error("team layer not configured: set memory.team in ~/.thincoder/config.json")
375
- const filters = { layer: String(layer), type, keyword }
376
- const rows = await matchMemoryRows(memory, { ...filters, projectDir: dirs.project, teamDir: dirs.team })
377
- if (rows.length === 0) return "0 条匹配"
378
- if (args.confirm !== true) {
379
- const lines = [rows.length > 5 ? `将删 ${rows.length} 条:前 5 条预览` : `将删 ${rows.length} 条`]
380
- lines.push(...rows.slice(0, 5).map(listRowLine))
381
- if (rows.length > 5) lines.push(`5 条——截断前 ${rows.length}`)
382
- lines.push("confirm:true required — re-send with it to execute the deletion")
383
- return lines.join("\n")
384
- }
385
- const n = await deleteWhere(memory, filters, { dirs })
386
- return `Deleted ${n} entries in layer ${layer}`
387
- }
388
-
389
- /** Single-entry delete — MEMORY.md §6.2: layer is OPTIONAL. When passed it is validated
390
- * against the id prefix (mismatch refused — guards against deleting the wrong entry); when
391
- * omitted the delete routes by the id prefix alone, so any id search/list returned is
392
- * directly deletable (deleteByUid already resolves the layer from the uid prefix). */
393
- async function execDeleteSingle(memory, args, dirs) {
394
- const uid = String(args.id)
395
- const prefix = uid.split(":")[0]
396
- const uidLayer = prefix === "personal" || prefix === "project" || prefix === "team" ? prefix : /^\d+$/.test(prefix) ? "personal" : null
397
- if (!uidLayer) throw new Error(`invalid memory id: ${uid}`)
398
- const layer = args.layer
399
- if (layer !== undefined && layer !== null && String(layer) !== uidLayer) {
400
- throw new Error(`id prefix ${prefix}: 与 layer ${layer} 不匹配`)
401
- }
402
- const entry = await deleteByUid(memory, uid, { dirs })
403
- return `Deleted ${entry.id}: ${entry.title}\n${(entry.content ?? "").slice(0, 500)}`
404
- }
405
-
406
- /** action clear — personal-only full wipe (layer + confirm:true gates; project/team refused). */
407
- function execClear(memory, args) {
408
- const layer = args.layer
409
- if (!layer) throw new Error('clear requires layer "personal" — pass layer: "personal" plus confirm: true')
410
- if (String(layer) !== "personal") {
411
- if (!MEMORY_LAYERS.includes(String(layer))) throw new Error(`memory clear: invalid layer "${layer}"`)
412
- throw new Error("shared layers don't support clear — use delete with type/keyword batch filters instead")
413
- }
414
- if (args.confirm !== true) throw new Error("clear requires confirm:true — this wipes ALL personal memory")
415
- const n = clearPersonal(memory)
416
- return `Cleared personal memory (${n} entries deleted)`
417
- }
@@ -1,109 +0,0 @@
1
- /**
2
- * memory/file-walk.mjs — filesystem walk fallback + the shared file-listing predicates.
3
- *
4
- * Why this module (PORTABILITY FR15 / P8): the index was built from `git ls-files`
5
- * only — on a project that is not a git repository the code and doc indexes came
6
- * back EMPTY, silently (the reviewer/agent then "searched" an empty index). The
7
- * fallback traverses the filesystem instead, and the skip predicate lives here so
8
- * the git path, the walk and the single-file reindex cannot drift apart against
9
- * each other (they were three copies before).
10
- */
11
- import { readdir } from "node:fs/promises"
12
- import { join } from "node:path"
13
- import { SKIP_DIRS } from "./schema.mjs"
14
-
15
- /** Upper guard for the walk: stop after this many matched files (runaway trees). */
16
- export const MAX_WALK_FILES = 20000
17
-
18
- /**
19
- * Shared skip predicate for project-relative paths: any SKIP_DIRS basename, or any
20
- * dot-prefixed segment (`.git`, `.cache`, `.thincoder`…). Accepts both separators.
21
- */
22
- export function isSkippedRelPath(rel) {
23
- return String(rel ?? "")
24
- .replace(/\\/g, "/")
25
- .split("/")
26
- .some((seg) => seg !== "" && (seg.startsWith(".") || SKIP_DIRS.has(seg)))
27
- }
28
-
29
- /** Lower-cased ".ext" of a path, or "" when it has no extension. */
30
- export function extensionOf(p) {
31
- const s = String(p ?? "")
32
- const base = s.slice(Math.max(s.lastIndexOf("/"), s.lastIndexOf("\\")) + 1)
33
- const i = base.lastIndexOf(".")
34
- return i > 0 ? base.slice(i).toLowerCase() : ""
35
- }
36
-
37
- /**
38
- * Unlisted-extension tally (PORTABILITY PO-9 — visibility): counts files the index
39
- * will NOT pick up because their extension is in no list, so "my .xyz files are not
40
- * searchable" stops being invisible. `exts` is a capped sample (the count is the
41
- * signal; the list is the hint).
42
- * @param {Set<string>} knownExts — every extension that IS indexed (code ∪ doc ∪ declared)
43
- */
44
- export function createUnlistedTally(knownExts) {
45
- const counts = new Map()
46
- let count = 0
47
- return {
48
- note(rel) {
49
- const ext = extensionOf(rel)
50
- if (!ext || knownExts.has(ext)) return
51
- count++
52
- counts.set(ext, (counts.get(ext) ?? 0) + 1)
53
- },
54
- result(limit = 12) {
55
- const exts = [...counts.entries()]
56
- .sort((a, b) => b[1] - a[1])
57
- .slice(0, limit)
58
- .map(([ext, n]) => ({ ext, count: n }))
59
- return { count, exts }
60
- },
61
- }
62
- }
63
-
64
- /**
65
- * Recursive project walk — the no-git listing source. Symlinks are never followed
66
- * (loop/escape guard); skipped directories are pruned; only files whose extension
67
- * is in `exts` are returned. Hitting maxFiles is reported as `truncated`, never
68
- * silently dropped.
69
- * @param {string} dir — project root
70
- * @param {Set<string>} exts — extensions to keep (lower-case, leading dot)
71
- * @param {{maxFiles?: number, knownExts?: Set<string>|null}} [opts]
72
- * knownExts → also tally files whose extension is in NO index list
73
- * @returns {Promise<{files: {abs: string, rel: string}[], truncated: boolean,
74
- * unlisted: {count: number, exts: {ext: string, count: number}[]}}>}
75
- */
76
- export async function walkProjectFiles(dir, exts, { maxFiles = MAX_WALK_FILES, knownExts = null } = {}) {
77
- const files = []
78
- const tally = knownExts ? createUnlistedTally(knownExts) : null
79
- let truncated = false
80
- const stack = [{ abs: dir, rel: "" }]
81
- while (stack.length > 0) {
82
- const cur = stack.pop()
83
- let entries
84
- try {
85
- entries = await readdir(cur.abs, { withFileTypes: true })
86
- } catch { continue /* unreadable dir — skip, the walk must not fail the sync */ }
87
- for (const ent of entries) {
88
- const rel = cur.rel ? `${cur.rel}/${ent.name}` : ent.name
89
- if (ent.isSymbolicLink()) continue // never follow symlinks
90
- if (ent.isDirectory()) {
91
- if (!isSkippedRelPath(rel)) stack.push({ abs: join(cur.abs, ent.name), rel })
92
- continue
93
- }
94
- if (!ent.isFile() || isSkippedRelPath(rel)) continue
95
- const ext = extensionOf(ent.name)
96
- if (!ext || !exts.has(ext)) {
97
- tally?.note(rel)
98
- continue
99
- }
100
- if (files.length >= maxFiles) {
101
- truncated = true
102
- break
103
- }
104
- files.push({ abs: join(cur.abs, ent.name), rel })
105
- }
106
- if (truncated) break
107
- }
108
- return { files, truncated, unlisted: tally ? tally.result() : { count: 0, exts: [] } }
109
- }