@thincoder/core 0.9.1

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 (203) hide show
  1. package/abort-provenance.mjs +116 -0
  2. package/advisor/citations.mjs +139 -0
  3. package/advisor/compaction.mjs +174 -0
  4. package/advisor/convergence.mjs +80 -0
  5. package/advisor/history.mjs +77 -0
  6. package/advisor/loop.mjs +288 -0
  7. package/advisor/messages.mjs +299 -0
  8. package/advisor/notice.mjs +141 -0
  9. package/advisor/project-context.mjs +197 -0
  10. package/advisor/repos.mjs +150 -0
  11. package/advisor/run.mjs +190 -0
  12. package/advisor/truncate.mjs +57 -0
  13. package/advisor.mjs +281 -0
  14. package/agent/child-marks.mjs +24 -0
  15. package/agent/completion.mjs +145 -0
  16. package/agent/dispatch.mjs +493 -0
  17. package/agent/family-tools.mjs +174 -0
  18. package/agent/helpers.mjs +412 -0
  19. package/agent/post-turn.mjs +70 -0
  20. package/agent/record-results.mjs +174 -0
  21. package/agent/relay-prefix.mjs +39 -0
  22. package/agent/run-stages.mjs +244 -0
  23. package/agent/setup-reminders.mjs +199 -0
  24. package/agent/setup.mjs +234 -0
  25. package/agent/spawn-child.mjs +258 -0
  26. package/agent/suspension.mjs +240 -0
  27. package/agent/write-gate.mjs +87 -0
  28. package/agent-tools/advisor-async.mjs +481 -0
  29. package/agent-tools/advisor-settle.mjs +240 -0
  30. package/agent-tools/advisor.mjs +280 -0
  31. package/agent-tools/async-discard.mjs +143 -0
  32. package/agent-tools/async-settle.mjs +299 -0
  33. package/agent-tools/batch-segment.mjs +265 -0
  34. package/agent-tools/child-permission.mjs +45 -0
  35. package/agent-tools/consult.mjs +471 -0
  36. package/agent-tools/design-token.mjs +117 -0
  37. package/agent-tools/digest-budget.mjs +76 -0
  38. package/agent-tools/eng.mjs +102 -0
  39. package/agent-tools/escalate-async.mjs +302 -0
  40. package/agent-tools/goal.mjs +119 -0
  41. package/agent-tools/panel-blocks.mjs +24 -0
  42. package/agent-tools/parent-channel.mjs +231 -0
  43. package/agent-tools/plan.mjs +86 -0
  44. package/agent-tools/read-history.mjs +309 -0
  45. package/agent-tools/recent-changes.mjs +24 -0
  46. package/agent-tools/review-facts.mjs +31 -0
  47. package/agent-tools/settings.mjs +268 -0
  48. package/agent-tools/skill.mjs +63 -0
  49. package/agent-tools/spawn-gates.mjs +109 -0
  50. package/agent-tools/subagent-actions.mjs +496 -0
  51. package/agent-tools/subagent-async.mjs +456 -0
  52. package/agent-tools/subagent-panel.mjs +160 -0
  53. package/agent-tools/subagent-run.mjs +208 -0
  54. package/agent-tools/subagent-scheduler.mjs +446 -0
  55. package/agent-tools/subagent-spawn.mjs +478 -0
  56. package/agent-tools/subagent.mjs +419 -0
  57. package/agent-tools/task.mjs +87 -0
  58. package/agent-tools/timer.mjs +46 -0
  59. package/agent-tools/verify.mjs +295 -0
  60. package/agent-tools.mjs +23 -0
  61. package/agent.mjs +430 -0
  62. package/auto-think.mjs +115 -0
  63. package/compress-form.mjs +24 -0
  64. package/config-io.mjs +277 -0
  65. package/config-migrate.mjs +178 -0
  66. package/config-presets.mjs +49 -0
  67. package/config.mjs +419 -0
  68. package/context.mjs +495 -0
  69. package/conventions.mjs +223 -0
  70. package/embedding.mjs +120 -0
  71. package/escape.mjs +152 -0
  72. package/expand-home.mjs +16 -0
  73. package/explore-distill.mjs +152 -0
  74. package/generate-title.mjs +123 -0
  75. package/git/checkpoint.mjs +448 -0
  76. package/git/gitmem.mjs +100 -0
  77. package/history-window.mjs +179 -0
  78. package/hooks.mjs +108 -0
  79. package/i18n.mjs +106 -0
  80. package/index-bin.mjs +48 -0
  81. package/index-discover.mjs +176 -0
  82. package/ledger-cmd.mjs +209 -0
  83. package/ledger-db.mjs +86 -0
  84. package/ledger-surface.mjs +76 -0
  85. package/ledger.mjs +202 -0
  86. package/log.mjs +195 -0
  87. package/manifest.mjs +338 -0
  88. package/markdown.mjs +106 -0
  89. package/mcp/helpers.mjs +51 -0
  90. package/mcp/transport-http.mjs +248 -0
  91. package/mcp/transport-stdio.mjs +140 -0
  92. package/mcp/transport-ws.mjs +122 -0
  93. package/mcp.mjs +295 -0
  94. package/memory/code-index.mjs +219 -0
  95. package/memory/code-sync.mjs +427 -0
  96. package/memory/core.mjs +318 -0
  97. package/memory/delete.mjs +242 -0
  98. package/memory/docs.mjs +431 -0
  99. package/memory/file-walk.mjs +109 -0
  100. package/memory/origin.mjs +24 -0
  101. package/memory/scan.mjs +177 -0
  102. package/memory/schema.mjs +460 -0
  103. package/memory.mjs +21 -0
  104. package/model-ref.mjs +66 -0
  105. package/model-specs.mjs +277 -0
  106. package/package.json +32 -0
  107. package/peer-domains.mjs +265 -0
  108. package/peer-instances.mjs +178 -0
  109. package/permission.mjs +79 -0
  110. package/process-probe.mjs +315 -0
  111. package/prompt-files.mjs +113 -0
  112. package/prompt-overlays.mjs +78 -0
  113. package/prompts/advisor-design.md +43 -0
  114. package/prompts/advisor-round1.md +41 -0
  115. package/prompts/advisor-round2.md +46 -0
  116. package/prompts/advisor-round3.md +42 -0
  117. package/prompts/common.md +158 -0
  118. package/prompts/consult-base.md +19 -0
  119. package/prompts/discipline-engineering.md +123 -0
  120. package/prompts/discipline-normal.md +206 -0
  121. package/prompts/persona-coder.md +21 -0
  122. package/prompts/persona-eng-coder.md +41 -0
  123. package/prompts/persona-eng-designer.md +80 -0
  124. package/prompts/persona-engineering.md +160 -0
  125. package/prompts/persona-explore.md +15 -0
  126. package/prompts/persona-normal.md +35 -0
  127. package/prompts/persona-plan.md +27 -0
  128. package/provider/anthropic.mjs +225 -0
  129. package/provider/core.mjs +491 -0
  130. package/provider/errors.mjs +101 -0
  131. package/provider/google.mjs +257 -0
  132. package/provider/index.mjs +7 -0
  133. package/provider/list-models.mjs +163 -0
  134. package/provider/normalize.mjs +81 -0
  135. package/provider/rate.mjs +168 -0
  136. package/provider/responses.mjs +495 -0
  137. package/provider/retry.mjs +88 -0
  138. package/provider/sse.mjs +264 -0
  139. package/provider/wait-status.mjs +59 -0
  140. package/proxy.mjs +274 -0
  141. package/rules.mjs +53 -0
  142. package/session-gc.mjs +248 -0
  143. package/session-guard.mjs +59 -0
  144. package/session-lifecycle.mjs +305 -0
  145. package/session-migrate.mjs +48 -0
  146. package/session-rename.mjs +38 -0
  147. package/session-segments.mjs +99 -0
  148. package/session-slot-write.mjs +168 -0
  149. package/session-slots-manifest.mjs +264 -0
  150. package/session-slots.mjs +298 -0
  151. package/session-store.mjs +441 -0
  152. package/session.mjs +244 -0
  153. package/skills.mjs +234 -0
  154. package/text-budget.mjs +79 -0
  155. package/token-ttl.mjs +285 -0
  156. package/tool-docs/apply_patch.md +15 -0
  157. package/tool-docs/bash.md +38 -0
  158. package/tool-docs/delete.md +13 -0
  159. package/tool-docs/edit.md +30 -0
  160. package/tool-docs/execute.md +21 -0
  161. package/tool-docs/fetch.md +12 -0
  162. package/tool-docs/file_ops.md +17 -0
  163. package/tool-docs/get_current_time.md +8 -0
  164. package/tool-docs/git.md +54 -0
  165. package/tool-docs/glob.md +11 -0
  166. package/tool-docs/grep.md +19 -0
  167. package/tool-docs/hashline_edit.md +14 -0
  168. package/tool-docs/insert_after.md +15 -0
  169. package/tool-docs/lint.md +10 -0
  170. package/tool-docs/ls.md +12 -0
  171. package/tool-docs/lsp.md +10 -0
  172. package/tool-docs/process.md +10 -0
  173. package/tool-docs/question.md +16 -0
  174. package/tool-docs/read.md +20 -0
  175. package/tool-docs/read_image.md +8 -0
  176. package/tool-docs/tree.md +14 -0
  177. package/tool-docs/wait_for.md +22 -0
  178. package/tool-docs/websearch.md +16 -0
  179. package/tool-docs/write.md +11 -0
  180. package/tools/bash.mjs +276 -0
  181. package/tools/edit-batch.mjs +204 -0
  182. package/tools/edit-diff.mjs +388 -0
  183. package/tools/exec-run.mjs +43 -0
  184. package/tools/execute.mjs +243 -0
  185. package/tools/file.mjs +464 -0
  186. package/tools/git-checkpoint.mjs +143 -0
  187. package/tools/git-ext.mjs +173 -0
  188. package/tools/git.mjs +415 -0
  189. package/tools/glob-dialect.mjs +130 -0
  190. package/tools/index.mjs +76 -0
  191. package/tools/linter.mjs +120 -0
  192. package/tools/lsp.mjs +335 -0
  193. package/tools/ops.mjs +293 -0
  194. package/tools/patch.mjs +290 -0
  195. package/tools/question.mjs +26 -0
  196. package/tools/repomap.mjs +314 -0
  197. package/tools/search.mjs +248 -0
  198. package/tools/shared.mjs +467 -0
  199. package/tools/tree.mjs +81 -0
  200. package/tools/web.mjs +224 -0
  201. package/tools/write-path.mjs +191 -0
  202. package/traces/trace-store.mjs +303 -0
  203. package/undo-stack.mjs +47 -0
@@ -0,0 +1,431 @@
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 { scanVectors, createTopK } from "./scan.mjs"
9
+ import { normalizeOrigin } from "./origin.mjs"
10
+ import { commitAndPush } from "../git/gitmem.mjs"
11
+ import { MAX_DOC_FILE_BYTES } from "./schema.mjs"
12
+ import { buildFtsQuery, put, search, putMarkdown, clearPersonal, EMBED_TEXT_MAX_LEN } from "./core.mjs"
13
+ import { deleteByUid, matchMemoryRows, deleteWhere } from "./delete.mjs"
14
+ import { _upsertDocFile, yieldTick } from "./code-index.mjs"
15
+ import { markIndexedCommit, listProjectFiles, indexExtensions } from "./code-sync.mjs"
16
+ import { logEvent } from "../log.mjs"
17
+ import { safeSliceUTF16 } from "../text-budget.mjs"
18
+
19
+ const DOC_EMBED_BATCH = 64
20
+
21
+ /**
22
+ * Sync doc index: scan all .md/.mdc/.txt/.rst/.adoc under dir → chunk → upsert into doc_chunks.
23
+ * Incremental by mtime.
24
+ */
25
+ export async function docSync(memory, dir, { onProgress } = {}) {
26
+ const origin = normalizeOrigin(dir) // §6.11 写缝归一(目录/git I/O 用原样 dir;库面 origin 一律归一值)
27
+ const { entries, unlisted } = await listProjectFiles(dir, indexExtensions(dir).doc)
28
+ const files = [] // { abs, rel, mtimeMs }
29
+ let overSizeSkipped = 0
30
+ for (const { abs, rel } of entries) {
31
+ let st
32
+ try { st = await stat(abs) } catch { continue }
33
+ if (st.size > MAX_DOC_FILE_BYTES) { overSizeSkipped++; continue }
34
+ files.push({ abs, rel, mtimeMs: Math.floor(st.mtimeMs) })
35
+ }
36
+
37
+ const indexed = new Map(
38
+ memory.db.prepare(`SELECT path, mtime_ms FROM doc_chunks WHERE origin = ?`).all(origin).map((r) => [r.path, r.mtime_ms])
39
+ )
40
+ const seen = new Set()
41
+
42
+ onProgress?.({ phase: "scan", total: files.length, overSizeSkipped })
43
+
44
+ let updated = 0, removed = 0, skipped = 0, failed = 0
45
+ const errors = []
46
+ for (let i = 0; i < files.length; i++) {
47
+ const { abs, rel, mtimeMs } = files[i]
48
+ seen.add(rel)
49
+
50
+ if (indexed.get(rel) === mtimeMs) {
51
+ skipped++
52
+ continue
53
+ }
54
+
55
+ try {
56
+ const text = await readFile(abs, "utf8")
57
+ const lines = text.split("\n")
58
+ _upsertDocFile(memory, origin, rel, lines, mtimeMs)
59
+ updated++
60
+ } catch (e) {
61
+ failed++
62
+ if (errors.length < 5) errors.push(`${rel}: ${e.message}`)
63
+ }
64
+ await yieldTick()
65
+
66
+ if (onProgress && i % 10 === 0) {
67
+ onProgress({ phase: "index", current: i + 1, total: files.length, updated, removed, skipped, failed })
68
+ }
69
+ }
70
+
71
+ for (const stale of indexed.keys()) {
72
+ if (!seen.has(stale)) {
73
+ memory.db.prepare(`DELETE FROM doc_chunks WHERE origin = ? AND path = ?`).run(origin, stale)
74
+ removed++
75
+ }
76
+ }
77
+
78
+ onProgress?.({ phase: "done", total: files.length, updated, removed, skipped, failed, overSizeSkipped })
79
+ markIndexedCommit(memory, dir)
80
+ if (unlisted.count > 0) {
81
+ logEvent("index:unlisted", { dir, kind: "doc", count: unlisted.count, exts: unlisted.exts.map((e) => e.ext) })
82
+ }
83
+ return { updated, removed, skipped, failed, errors, total: files.length, overSizeSkipped, unlistedExts: unlisted }
84
+ }
85
+
86
+ /**
87
+ * Doc search: FTS5(BM25) + optional vector cosine, RRF merged.
88
+ * Falls back to pure FTS when no embedder; falls back to pure vector when ftsQuery is empty and embedder is present.
89
+ */
90
+ export async function docSearch(memory, query, { limit = 5 } = {}) {
91
+ const ftsQuery = buildFtsQuery(query)
92
+ if (!ftsQuery && !memory.embedder) return []
93
+
94
+ // §6.11 读缝归一(单点取名——函数体内一律用归一值)
95
+ const codeOrigin = normalizeOrigin(memory.codeOrigin)
96
+ const ftsOriginFilter = codeOrigin ? `AND d.origin = ?` : ""
97
+ const vecOriginFilter = codeOrigin ? `AND origin = ?` : ""
98
+ const originParams = codeOrigin ? [codeOrigin] : []
99
+
100
+ const ftsList = ftsQuery ? memory.db.prepare(`
101
+ SELECT d.rowid, d.path, d.language, d.heading, d.content, d.line_start, d.line_end, bm25(doc_chunks_fts) AS rank
102
+ FROM doc_chunks_fts JOIN doc_chunks d ON d.rowid = doc_chunks_fts.rowid
103
+ WHERE doc_chunks_fts MATCH ? ${ftsOriginFilter}
104
+ ORDER BY rank LIMIT ?
105
+ `).all(ftsQuery, ...originParams, Math.max(limit * 4, 20)) : []
106
+
107
+ if (!memory.embedder) return ftsList.slice(0, limit)
108
+
109
+ try { await ensureDocEmbeddings(memory) } catch (e) {
110
+ console.error(`[docs] embedding ensure failed, falling back to FTS-only: ${e.message}`)
111
+ return ftsList.slice(0, limit)
112
+ }
113
+ let qvec
114
+ try { [qvec] = await embed(memory.embedder, [query]) } catch (e) {
115
+ console.error(`[docs] query embedding failed, falling back to FTS-only: ${e.message}`)
116
+ return ftsList.slice(0, limit)
117
+ }
118
+ // TUI-OOM-ROOTCAUSE(MEMORY.md §10.3):分块扫描 + 有界 top-K(原全表 .all()——峰值 = 块 + K)
119
+ // TUI 假死批(§6.10 修法 A1/A2):游标 = PK 去等值过滤前缀列(有 origin 过滤 ⇒ 2 元组;
120
+ // 无过滤 ⇒ 全 PK);scanVectors = async(让出)。SELECT 须携键列(游标值源)。
121
+ const cursorKey = codeOrigin ? ["path", "line_start"] : ["origin", "path", "line_start"]
122
+ const keyCols = cursorKey.join(", ")
123
+ const top = createTopK(Math.max(limit * 4, 20))
124
+ await scanVectors(memory.db, `SELECT rowid, ${keyCols}, embedding FROM doc_chunks WHERE embedding IS NOT NULL ${vecOriginFilter}`, originParams, {
125
+ cursorKey,
126
+ onRow: (r) => top.push({ id: r.rowid, rowid: r.rowid, score: cosine(qvec, fromBlob(r.embedding)) }),
127
+ })
128
+ const vecList = top.list().map((c) => ({ rowid: c.id, score: c.score }))
129
+
130
+ const K = 60
131
+ const scores = new Map()
132
+ ftsList.forEach((r, i) => scores.set(r.rowid, (scores.get(r.rowid) ?? 0) + 1 / (K + i + 1)))
133
+ vecList.forEach((r, i) => scores.set(r.rowid, (scores.get(r.rowid) ?? 0) + 1 / (K + i + 1)))
134
+
135
+ const fetchChunk = memory.db.prepare(`
136
+ SELECT path, language, heading, content, line_start, line_end FROM doc_chunks WHERE rowid = ?
137
+ `)
138
+ const sorted = [...scores.entries()]
139
+ .sort((a, b) => b[1] - a[1])
140
+ .slice(0, limit)
141
+ return sorted
142
+ .map(([rowid, score]) => {
143
+ const chunk = fetchChunk.get(rowid)
144
+ if (!chunk) return null
145
+ chunk._score = Math.round(score * 100) / 100
146
+ return chunk
147
+ })
148
+ .filter(Boolean)
149
+ }
150
+
151
+ /** Lazily backfill missing vectors for doc_chunks. Guarded against concurrent calls. */
152
+ let _docEmbedLock = null
153
+ export function ensureDocEmbeddings(memory) {
154
+ if (_docEmbedLock) return _docEmbedLock
155
+ _docEmbedLock = _runEnsureDocEmbeddings(memory).finally(() => { _docEmbedLock = null })
156
+ return _docEmbedLock
157
+ }
158
+
159
+ async function _runEnsureDocEmbeddings(memory) {
160
+ if (!memory.embedder) return
161
+ const modelKey = memory.embedder.model
162
+ const stored = memory.db.prepare(`SELECT value FROM meta WHERE key = 'doc_embedding_model'`).get()?.value
163
+ if (stored !== modelKey) {
164
+ memory.db.prepare(`UPDATE doc_chunks SET embedding = NULL`).run()
165
+ memory.db.prepare(`INSERT INTO meta (key, value) VALUES ('doc_embedding_model', ?)
166
+ ON CONFLICT (key) DO UPDATE SET value = excluded.value`).run(modelKey)
167
+ }
168
+
169
+ const pending = memory.db.prepare(`SELECT rowid, path, heading, content FROM doc_chunks WHERE embedding IS NULL LIMIT ${DOC_EMBED_BATCH}`).all()
170
+ if (pending.length === 0) return
171
+
172
+ const texts = pending.map((r) => `${r.heading || r.path}\n${safeSliceUTF16(r.content, EMBED_TEXT_MAX_LEN)}`)
173
+ const vecs = await embed(memory.embedder, texts)
174
+
175
+ const update = memory.db.prepare(`UPDATE doc_chunks SET embedding = ? WHERE rowid = ?`)
176
+ pending.forEach((r, i) => update.run(toBlob(vecs[i]), r.rowid))
177
+ }
178
+
179
+ /** Generate the doc_search tool (read-only). */
180
+ export function docSearchTool(memory) {
181
+ return {
182
+ name: "doc_search",
183
+ description:
184
+ "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. " +
185
+ "Returns matching doc chunks: path, heading, line range, relevance score, content excerpt. " +
186
+ "For what was said in sessions (conversation/chat history — decisions, rulings), use read_history.",
187
+ parameters: {
188
+ type: "object",
189
+ properties: {
190
+ query: { type: "string", description: "Natural language search query" },
191
+ limit: { type: "number", description: "Max results (default 5)" },
192
+ },
193
+ required: ["query"],
194
+ },
195
+ readonly: true,
196
+ async execute(args) {
197
+ const results = await docSearch(memory, args.query, { limit: args.limit ?? 5 })
198
+ if (results.length === 0) return "(no matching documentation)"
199
+ return results.map((r) =>
200
+ `${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)}`
201
+ ).join("\n\n---\n\n")
202
+ },
203
+ }
204
+ }
205
+
206
+ // ---------------------------------------------------------------- agent tools
207
+
208
+ /** §6 shared tool surface — action enum / parameter shapes / descriptions byte-identical
209
+ * with thincoder-vscode/src/memory.mjs (MEMORY.md §6 D-M1/F-M6); layer VALUES per end
210
+ * (VS Code has no team layer and rejects it with CLI guidance). */
211
+ const MEMORY_ACTIONS = ["search", "put", "list", "delete", "clear"]
212
+ const MEMORY_LAYERS = ["personal", "project", "team"]
213
+ const MEMORY_TOOL_DESCRIPTION =
214
+ "Manage long-term memory in ONE tool — the action parameter picks the operation:\n" +
215
+ "- 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" +
216
+ "- 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" +
217
+ "- 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" +
218
+ "- 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" +
219
+ "- 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" +
220
+ "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" +
221
+ "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" +
222
+ "Save bugs, conventions, and preferences here — they persist across sessions.\n" +
223
+ "Session message history (what was said in this or past sessions) is NOT in memory — search session messages with read_history."
224
+
225
+ function validateTypeFilter(type) {
226
+ if (type === undefined || type === null || type === "") return null
227
+ const t = String(type)
228
+ if (!["rule", "knowledge", "decision", "pattern"].includes(t)) throw new Error(`Invalid memory type "${t}"; expected one of: rule, knowledge, decision, pattern`)
229
+ return t
230
+ }
231
+
232
+ function normalizeLimit(limit, dflt) {
233
+ const n = Number(limit)
234
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : dflt
235
+ }
236
+
237
+ function fmtDate(ts) {
238
+ return ts ? new Date(ts).toISOString().slice(0, 10) : "?"
239
+ }
240
+
241
+ const listRowLine = (r) => `[${r.layer}] ${r.id} [${r.type}] ${r.title}(${fmtDate(r.ts)})`
242
+
243
+ /**
244
+ * Generate the memory agent tool — ONE `memory` tool with five actions (MEMORY.md §6 D-M1).
245
+ * search/list are read-only actions (planMode pass / no permission ask — dispatch classifies
246
+ * them action-level, same as subagent check/status); put keeps its side-effect permission
247
+ * gate; batch delete/clear gate on confirm:true + layer inside the tool (direct-delete
248
+ * ruling — the confirm parameter IS the gate) and stay non-readonly like the retired tools.
249
+ * opts: { cwd, projectDir, author, team: { dir, name } | null }
250
+ */
251
+ export function memoryTools(memory, opts = {}) {
252
+ const projectDir = opts.projectDir ? (isAbsolute(opts.projectDir) ? opts.projectDir : join(opts.cwd ?? process.cwd(), opts.projectDir)) : null
253
+ // §6.11:dirs 保持原样(目录 I/O 基准)——归一落在各公共入口内(写缝 syncDir / indexMarkdownFile、
254
+ // 删缝 deleteByUid / matchMemoryRows、读缝 search / fetchEntry)⇒ 逐入口一行,非工具层预归一。
255
+ const dirs = { project: projectDir, team: opts.team?.dir ?? null }
256
+ return [
257
+ {
258
+ name: "memory",
259
+ description: MEMORY_TOOL_DESCRIPTION,
260
+ parameters: {
261
+ type: "object",
262
+ properties: {
263
+ action: { type: "string", enum: MEMORY_ACTIONS, description: "Operation to run (required)" },
264
+ 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" },
265
+ type: { type: "string", enum: ["rule", "knowledge", "decision", "pattern"], description: "Entry type: put = what to save; list/delete batch = filter by type" },
266
+ title: { type: "string", description: "put: short title" },
267
+ content: { type: "string", description: "put: full content to remember" },
268
+ tags: { type: "string", description: "put: space-separated tags" },
269
+ query: { type: "string", description: "search: natural-language query" },
270
+ keyword: { type: "string", description: "list/delete batch: filter matching title/content" },
271
+ id: { type: "string", description: "delete single: the entry id from put/search/list output" },
272
+ limit: { type: "number", description: "Max rows: list 50 by default, search 5 by default" },
273
+ confirm: { type: "boolean", description: "delete batch/clear: must be true — without it the tool refuses" },
274
+ },
275
+ required: ["action"],
276
+ },
277
+ readonly: false,
278
+ async execute(args) {
279
+ const action = String(args?.action ?? "")
280
+ if (!MEMORY_ACTIONS.includes(action)) {
281
+ throw new Error(`memory: unknown action "${action}" — expected one of: ${MEMORY_ACTIONS.join("/")}`)
282
+ }
283
+ switch (action) {
284
+ case "search": return execSearch(memory, args)
285
+ case "put": return execPut(memory, args, opts, dirs)
286
+ case "list": return execList(memory, args, dirs)
287
+ case "delete": return execDelete(memory, args, dirs)
288
+ case "clear": return execClear(memory, args)
289
+ }
290
+ },
291
+ },
292
+ ]
293
+ }
294
+
295
+ /** action search — the retired search tool surface (read-only, same output contract). */
296
+ async function execSearch(memory, args) {
297
+ const layer = args.layer
298
+ if (layer !== undefined && layer !== null && !MEMORY_LAYERS.includes(String(layer))) {
299
+ throw new Error(`memory search: invalid layer "${layer}"`)
300
+ }
301
+ const query = String(args.query ?? "").trim()
302
+ if (!query) return "(no matching memories)" // 空 query 短路——两端同语义(评审 code review #4)
303
+ const limit = normalizeLimit(args.limit, 5)
304
+ let results
305
+ if (!layer) {
306
+ results = await search(memory, query, { limit })
307
+ } else {
308
+ // layer filter: oversample then slice the requested layer (results keep global rank order).
309
+ // 窗口 = max(limit*4, 20) 是召回上限——大库 + 高 limit 时该层结果可能不足 limit(接受的取舍——评审 code review #3)
310
+ const wide = await search(memory, query, { limit: Math.max(limit * 4, 20) })
311
+ results = wide.filter((r) => r.layer === String(layer)).slice(0, limit)
312
+ }
313
+ if (results.length === 0) return "(no matching memories)"
314
+ return results.map((r) => `[${r.layer}][${r.type}] ${r.title} (id=${r.id})\n${r.content}`).join("\n\n")
315
+ }
316
+
317
+ /** action put — the retired put tool surface (side-effect gate, unchanged semantics). */
318
+ async function execPut(memory, args, opts, dirs) {
319
+ const layer = String(args.layer ?? "personal")
320
+ if (!MEMORY_LAYERS.includes(layer)) throw new Error(`memory put: invalid layer "${layer}"`)
321
+ if (layer === "personal") {
322
+ const id = await put(memory, { type: args.type, title: args.title, content: args.content, tags: args.tags ?? "" })
323
+ return `Saved to personal memory (id=personal:${id}): [${args.type}] ${args.title}`
324
+ }
325
+ if (layer === "project") {
326
+ if (!dirs.project) throw new Error("project layer unavailable: no project directory configured")
327
+ const filename = await putMarkdown(memory, {
328
+ layer: "project",
329
+ dir: dirs.project,
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
+ return `Saved to project memory (id=project:${dirs.project}:${filename}): [${args.type}] ${args.title}`
337
+ }
338
+ if (!dirs.team) {
339
+ throw new Error("team layer not configured: set memory.team in ~/.thincoder/config.json")
340
+ }
341
+ const filename = await putMarkdown(memory, {
342
+ layer: "team",
343
+ dir: dirs.team,
344
+ type: args.type,
345
+ title: args.title,
346
+ content: args.content,
347
+ tags: (args.tags ?? "").split(/\s+/).filter(Boolean),
348
+ author: opts.author ?? "unknown",
349
+ })
350
+ await commitAndPush(dirs.team, filename, `memory: [${args.type}] ${args.title}`)
351
+ return `Saved to team memory and pushed (id=team:${dirs.team}:${filename}): [${args.type}] ${args.title}`
352
+ }
353
+
354
+ /** action list — new inventory action (read-only): layer/type/keyword filters + limit truncation note. */
355
+ async function execList(memory, args, dirs) {
356
+ const layer = args.layer ?? null
357
+ if (layer && !MEMORY_LAYERS.includes(String(layer))) throw new Error(`memory list: invalid layer "${layer}"`)
358
+ const rows = await matchMemoryRows(memory, {
359
+ layer: layer ? String(layer) : null,
360
+ type: validateTypeFilter(args.type),
361
+ keyword: args.keyword ? String(args.keyword).trim() : null,
362
+ projectDir: dirs.project,
363
+ teamDir: dirs.team,
364
+ })
365
+ if (rows.length === 0) return "0 条匹配"
366
+ const limit = normalizeLimit(args.limit, 50)
367
+ const shown = rows.slice(0, limit)
368
+ const lines = shown.map(listRowLine)
369
+ if (rows.length > shown.length) lines.unshift(`${shown.length} 条——截断前 ${rows.length}`)
370
+ return lines.join("\n")
371
+ }
372
+
373
+ /** action delete — single ({ id, layer? } — MEMORY.md §6.2: layer OPTIONAL, validated when
374
+ * passed, else the id prefix routes the delete) + batch (layer + type/keyword + confirm). */
375
+ async function execDelete(memory, args, dirs) {
376
+ const hasId = args.id !== undefined && args.id !== null && String(args.id) !== ""
377
+ if (hasId) return execDeleteSingle(memory, args, dirs)
378
+ // batch form
379
+ const layer = args.layer
380
+ if (!layer) throw new Error("batch delete requires layer plus type and/or keyword filter")
381
+ if (!MEMORY_LAYERS.includes(String(layer))) throw new Error(`memory delete: invalid layer "${layer}"`)
382
+ const type = validateTypeFilter(args.type)
383
+ const keyword = args.keyword ? String(args.keyword).trim() : null
384
+ if (!type && !keyword) {
385
+ 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)")
386
+ }
387
+ if (layer === "project" && !dirs.project) throw new Error("project layer unavailable: no project directory configured")
388
+ if (layer === "team" && !dirs.team) throw new Error("team layer not configured: set memory.team in ~/.thincoder/config.json")
389
+ const filters = { layer: String(layer), type, keyword }
390
+ const rows = await matchMemoryRows(memory, { ...filters, projectDir: dirs.project, teamDir: dirs.team })
391
+ if (rows.length === 0) return "0 条匹配"
392
+ if (args.confirm !== true) {
393
+ const lines = [rows.length > 5 ? `将删 ${rows.length} 条:前 5 条预览` : `将删 ${rows.length} 条`]
394
+ lines.push(...rows.slice(0, 5).map(listRowLine))
395
+ if (rows.length > 5) lines.push(`5 条——截断前 ${rows.length}`)
396
+ lines.push("confirm:true required — re-send with it to execute the deletion")
397
+ return lines.join("\n")
398
+ }
399
+ const n = await deleteWhere(memory, filters, { dirs })
400
+ return `Deleted ${n} entries in layer ${layer}`
401
+ }
402
+
403
+ /** Single-entry delete — MEMORY.md §6.2: layer is OPTIONAL. When passed it is validated
404
+ * against the id prefix (mismatch refused — guards against deleting the wrong entry); when
405
+ * omitted the delete routes by the id prefix alone, so any id search/list returned is
406
+ * directly deletable (deleteByUid already resolves the layer from the uid prefix). */
407
+ async function execDeleteSingle(memory, args, dirs) {
408
+ const uid = String(args.id)
409
+ const prefix = uid.split(":")[0]
410
+ const uidLayer = prefix === "personal" || prefix === "project" || prefix === "team" ? prefix : /^\d+$/.test(prefix) ? "personal" : null
411
+ if (!uidLayer) throw new Error(`invalid memory id: ${uid}`)
412
+ const layer = args.layer
413
+ if (layer !== undefined && layer !== null && String(layer) !== uidLayer) {
414
+ throw new Error(`id prefix ${prefix}: 与 layer ${layer} 不匹配`)
415
+ }
416
+ const entry = await deleteByUid(memory, uid, { dirs })
417
+ return `Deleted ${entry.id}: ${entry.title}\n${(entry.content ?? "").slice(0, 500)}`
418
+ }
419
+
420
+ /** action clear — personal-only full wipe (layer + confirm:true gates; project/team refused). */
421
+ function execClear(memory, args) {
422
+ const layer = args.layer
423
+ if (!layer) throw new Error('clear requires layer "personal" — pass layer: "personal" plus confirm: true')
424
+ if (String(layer) !== "personal") {
425
+ if (!MEMORY_LAYERS.includes(String(layer))) throw new Error(`memory clear: invalid layer "${layer}"`)
426
+ throw new Error("shared layers don't support clear — use delete with type/keyword batch filters instead")
427
+ }
428
+ if (args.confirm !== true) throw new Error("clear requires confirm:true — this wipes ALL personal memory")
429
+ const n = clearPersonal(memory)
430
+ return `Cleared personal memory (${n} entries deleted)`
431
+ }
@@ -0,0 +1,109 @@
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
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * memory/origin.mjs — origin 键归一(纯函数叶档 · MEMORY.md §6.11 · TUI 假死批 2026-09-18)。
3
+ *
4
+ * 病灶(源头):origin 键 = **未归一的目录字符串**(`memory.codeOrigin = cwd`——Windows 盘符
5
+ * 大小写随启动拼写、分隔符 / 尾斜杠同理)⇒ 同一棵树两份索引(实测 `D:\teamcode` 71,266 +
6
+ * `d:\teamcode` 69,748),且检索按 origin **等值**过滤 ⇒ 某一拼写启动时只看得到自己那半行。
7
+ *
8
+ * 契约(三变换,逐条对应 §6.11 修法):分隔符归一 `\`→`/` · Windows 盘符统一大写 ·
9
+ * 去尾斜杠(根除外);非字符串 / 空串 **原样透传**(类型护栏——调用点 `memory.codeOrigin`
10
+ * 未设判据零变化)。幂等:`normalizeOrigin(normalizeOrigin(x)) === normalizeOrigin(x)`。
11
+ *
12
+ * 不做(§6.11「不做」):`realpath` / 符号链接解析(改 origin 语义 + 破跨机可移植性,同
13
+ * D-MEM10「不用 resolve」口径);**非盘符段的大小写折叠**(POSIX 大小写敏感;origin 语义 =
14
+ * 用户启动目录的原样拼写);别名路径(subst / junction / 8.3 短名——§8.3 已知限制)。
15
+ */
16
+
17
+ /** origin 键归一:非字符串 / 空串透传;`\`→`/`;盘符大写;去尾斜杠(`C:/` · `/` 根保留)。 */
18
+ export function normalizeOrigin(p) {
19
+ if (typeof p !== "string" || p.length === 0) return p
20
+ let s = p.replaceAll("\\", "/")
21
+ if (s.length > 1 && /^[a-z]:/.test(s)) s = s[0].toUpperCase() + s.slice(1)
22
+ while (s.length > 1 && s.endsWith("/") && !/^[A-Za-z]:\/$/.test(s)) s = s.slice(0, -1)
23
+ return s
24
+ }