@100xprompt/brain 0.0.0-stage → 0.1.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 (260) hide show
  1. package/LICENSE +48 -0
  2. package/README.md +235 -2
  3. package/package.json +48 -4
  4. package/src/adapters/aider/index.ts +8 -0
  5. package/src/adapters/aider/reader.ts +129 -0
  6. package/src/adapters/aider/records.ts +220 -0
  7. package/src/adapters/aider/writer.ts +269 -0
  8. package/src/adapters/amazon-q-cli/index.ts +8 -0
  9. package/src/adapters/amazon-q-cli/reader.ts +84 -0
  10. package/src/adapters/amazon-q-cli/records.ts +191 -0
  11. package/src/adapters/amazon-q-cli/writer.ts +370 -0
  12. package/src/adapters/amazon-q-ide/index.ts +9 -0
  13. package/src/adapters/amazon-q-ide/reader.ts +131 -0
  14. package/src/adapters/amazon-q-ide/records.ts +141 -0
  15. package/src/adapters/amazon-q-ide/writer.ts +325 -0
  16. package/src/adapters/antigravity/index.ts +9 -0
  17. package/src/adapters/antigravity/proto.ts +194 -0
  18. package/src/adapters/antigravity/reader.ts +424 -0
  19. package/src/adapters/antigravity/records.ts +236 -0
  20. package/src/adapters/antigravity/writer.ts +463 -0
  21. package/src/adapters/auggie/index.ts +36 -0
  22. package/src/adapters/claude-code/index.ts +8 -0
  23. package/src/adapters/claude-code/reader.ts +639 -0
  24. package/src/adapters/claude-code/records.ts +88 -0
  25. package/src/adapters/claude-code/writer.ts +329 -0
  26. package/src/adapters/cline/index.ts +12 -0
  27. package/src/adapters/cline/reader.ts +432 -0
  28. package/src/adapters/cline/records.ts +151 -0
  29. package/src/adapters/cline/vscdb.ts +56 -0
  30. package/src/adapters/cline/writer.ts +656 -0
  31. package/src/adapters/cline/xml.ts +177 -0
  32. package/src/adapters/cline4/index.ts +9 -0
  33. package/src/adapters/cline4/reader.ts +179 -0
  34. package/src/adapters/cline4/records.ts +92 -0
  35. package/src/adapters/cline4/writer.ts +361 -0
  36. package/src/adapters/codebuff/index.ts +8 -0
  37. package/src/adapters/codebuff/project.ts +207 -0
  38. package/src/adapters/codebuff/reader.ts +215 -0
  39. package/src/adapters/codebuff/records.ts +173 -0
  40. package/src/adapters/codebuff/writer.ts +383 -0
  41. package/src/adapters/codex/index.ts +8 -0
  42. package/src/adapters/codex/native.ts +239 -0
  43. package/src/adapters/codex/reader.ts +747 -0
  44. package/src/adapters/codex/records.ts +114 -0
  45. package/src/adapters/codex/writer.ts +479 -0
  46. package/src/adapters/continue/index.ts +9 -0
  47. package/src/adapters/continue/reader.ts +68 -0
  48. package/src/adapters/continue/records.ts +418 -0
  49. package/src/adapters/continue/writer.ts +162 -0
  50. package/src/adapters/copilot-cli/index.ts +9 -0
  51. package/src/adapters/copilot-cli/reader.ts +186 -0
  52. package/src/adapters/copilot-cli/records.ts +241 -0
  53. package/src/adapters/copilot-cli/writer.ts +353 -0
  54. package/src/adapters/crush/index.ts +8 -0
  55. package/src/adapters/crush/reader.ts +180 -0
  56. package/src/adapters/crush/records.ts +169 -0
  57. package/src/adapters/crush/writer.ts +333 -0
  58. package/src/adapters/cursor-cli/index.ts +405 -0
  59. package/src/adapters/cursor-ide/agentkv.ts +95 -0
  60. package/src/adapters/cursor-ide/index.ts +501 -0
  61. package/src/adapters/cursor-ide/transcripts.ts +122 -0
  62. package/src/adapters/cursor-ide/workspaces.ts +119 -0
  63. package/src/adapters/cursor-ide/writer.ts +170 -0
  64. package/src/adapters/devin/index.ts +8 -0
  65. package/src/adapters/devin/reader.ts +305 -0
  66. package/src/adapters/devin/records.ts +277 -0
  67. package/src/adapters/devin/writer.ts +523 -0
  68. package/src/adapters/factory-droid/index.ts +12 -0
  69. package/src/adapters/factory-droid/reader.ts +184 -0
  70. package/src/adapters/factory-droid/records.ts +79 -0
  71. package/src/adapters/factory-droid/writer.ts +350 -0
  72. package/src/adapters/gemini-cli/exchanges.ts +228 -0
  73. package/src/adapters/gemini-cli/index.ts +396 -0
  74. package/src/adapters/gemini-cli/records.ts +521 -0
  75. package/src/adapters/gemini-cli/writer.ts +354 -0
  76. package/src/adapters/goose/index.ts +8 -0
  77. package/src/adapters/goose/reader.ts +138 -0
  78. package/src/adapters/goose/records.ts +199 -0
  79. package/src/adapters/goose/writer.ts +340 -0
  80. package/src/adapters/grok-cli/image.ts +44 -0
  81. package/src/adapters/grok-cli/index.ts +8 -0
  82. package/src/adapters/grok-cli/reader.ts +302 -0
  83. package/src/adapters/grok-cli/records.ts +172 -0
  84. package/src/adapters/grok-cli/writer.ts +329 -0
  85. package/src/adapters/hermes/index.ts +8 -0
  86. package/src/adapters/hermes/reader.ts +249 -0
  87. package/src/adapters/hermes/records.ts +285 -0
  88. package/src/adapters/hermes/writer.ts +452 -0
  89. package/src/adapters/iflow-cli/index.ts +72 -0
  90. package/src/adapters/iflow-cli/records.ts +215 -0
  91. package/src/adapters/iflow-cli/writer.ts +204 -0
  92. package/src/adapters/index.ts +112 -0
  93. package/src/adapters/kilo-cli/index.ts +21 -0
  94. package/src/adapters/kilo-code/index.ts +11 -0
  95. package/src/adapters/kilo-code/writer.ts +92 -0
  96. package/src/adapters/kimi/index.ts +9 -0
  97. package/src/adapters/kimi/reader.ts +92 -0
  98. package/src/adapters/kimi/records.ts +221 -0
  99. package/src/adapters/kimi/writer.ts +367 -0
  100. package/src/adapters/kiro-cli/index.ts +8 -0
  101. package/src/adapters/kiro-cli/reader.ts +87 -0
  102. package/src/adapters/kiro-cli/records.ts +260 -0
  103. package/src/adapters/kiro-cli/writer.ts +305 -0
  104. package/src/adapters/kiro-ide/index.ts +8 -0
  105. package/src/adapters/kiro-ide/writer.ts +52 -0
  106. package/src/adapters/letta/index.ts +8 -0
  107. package/src/adapters/letta/reader.ts +177 -0
  108. package/src/adapters/letta/records.ts +119 -0
  109. package/src/adapters/letta/writer.ts +413 -0
  110. package/src/adapters/mastracode/index.ts +8 -0
  111. package/src/adapters/mastracode/reader.ts +323 -0
  112. package/src/adapters/mastracode/records.ts +336 -0
  113. package/src/adapters/mastracode/writer.ts +385 -0
  114. package/src/adapters/mistral-vibe/index.ts +9 -0
  115. package/src/adapters/mistral-vibe/reader.ts +62 -0
  116. package/src/adapters/mistral-vibe/records.ts +104 -0
  117. package/src/adapters/mistral-vibe/writer.ts +359 -0
  118. package/src/adapters/muse/index.ts +10 -0
  119. package/src/adapters/muse/reader.ts +332 -0
  120. package/src/adapters/muse/records.ts +104 -0
  121. package/src/adapters/muse/writer.ts +387 -0
  122. package/src/adapters/omp/index.ts +13 -0
  123. package/src/adapters/omp/reader.ts +314 -0
  124. package/src/adapters/omp/records.ts +153 -0
  125. package/src/adapters/omp/writer.ts +216 -0
  126. package/src/adapters/opencode/ids.ts +52 -0
  127. package/src/adapters/opencode/index.ts +31 -0
  128. package/src/adapters/opencode/note.ts +8 -0
  129. package/src/adapters/opencode/reader.ts +452 -0
  130. package/src/adapters/opencode/tools.ts +174 -0
  131. package/src/adapters/opencode/v2.ts +367 -0
  132. package/src/adapters/opencode/writer.ts +379 -0
  133. package/src/adapters/openhands/index.ts +8 -0
  134. package/src/adapters/openhands/reader.ts +222 -0
  135. package/src/adapters/openhands/records.ts +136 -0
  136. package/src/adapters/openhands/writer.ts +404 -0
  137. package/src/adapters/pearai/index.ts +10 -0
  138. package/src/adapters/pi/index.ts +28 -0
  139. package/src/adapters/pi/reader.ts +261 -0
  140. package/src/adapters/pi/records.ts +99 -0
  141. package/src/adapters/pi/writer.ts +362 -0
  142. package/src/adapters/qoder-cli/index.ts +8 -0
  143. package/src/adapters/qoder-cli/reader.ts +83 -0
  144. package/src/adapters/qoder-cli/records.ts +45 -0
  145. package/src/adapters/qoder-cli/writer.ts +72 -0
  146. package/src/adapters/qwen-code/index.ts +84 -0
  147. package/src/adapters/qwen-code/records.ts +194 -0
  148. package/src/adapters/qwen-code/writer.ts +224 -0
  149. package/src/adapters/tabnine-cli/index.ts +8 -0
  150. package/src/adapters/tabnine-cli/reader.ts +106 -0
  151. package/src/adapters/tabnine-cli/records.ts +33 -0
  152. package/src/adapters/tabnine-cli/writer.ts +156 -0
  153. package/src/adapters/types.ts +73 -0
  154. package/src/adapters/void/index.ts +9 -0
  155. package/src/adapters/void/reader.ts +48 -0
  156. package/src/adapters/void/records.ts +285 -0
  157. package/src/adapters/void/writer.ts +93 -0
  158. package/src/adapters/vscode-copilot/index.ts +9 -0
  159. package/src/adapters/vscode-copilot/reader.ts +212 -0
  160. package/src/adapters/vscode-copilot/records.ts +388 -0
  161. package/src/adapters/vscode-copilot/shared.ts +68 -0
  162. package/src/adapters/vscode-copilot/writer.ts +546 -0
  163. package/src/adapters/zed/index.ts +8 -0
  164. package/src/adapters/zed/reader.ts +198 -0
  165. package/src/adapters/zed/records.ts +87 -0
  166. package/src/adapters/zed/writer.ts +341 -0
  167. package/src/brief-writers.ts +71 -0
  168. package/src/brief.ts +238 -0
  169. package/src/cli.ts +351 -0
  170. package/src/dashboard/cli.ts +148 -0
  171. package/src/dashboard/conversions.ts +170 -0
  172. package/src/dashboard/report.ts +261 -0
  173. package/src/dashboard/send.ts +213 -0
  174. package/src/dashboard/state.ts +95 -0
  175. package/src/fsutil.ts +133 -0
  176. package/src/gateway/common.ts +232 -0
  177. package/src/gateway/connect.ts +847 -0
  178. package/src/gateway/connectors/aider.ts +356 -0
  179. package/src/gateway/connectors/amazon-q-cli.ts +6 -0
  180. package/src/gateway/connectors/amazon-q-ide.ts +6 -0
  181. package/src/gateway/connectors/antigravity.ts +6 -0
  182. package/src/gateway/connectors/auggie.ts +6 -0
  183. package/src/gateway/connectors/cline-sdk.ts +85 -0
  184. package/src/gateway/connectors/cline.ts +34 -0
  185. package/src/gateway/connectors/codebuff.ts +6 -0
  186. package/src/gateway/connectors/continue.ts +579 -0
  187. package/src/gateway/connectors/copilot-cli.ts +138 -0
  188. package/src/gateway/connectors/crush.ts +313 -0
  189. package/src/gateway/connectors/cursor-cli.ts +6 -0
  190. package/src/gateway/connectors/cursor.ts +6 -0
  191. package/src/gateway/connectors/devin.ts +6 -0
  192. package/src/gateway/connectors/factory-droid.ts +308 -0
  193. package/src/gateway/connectors/goose.ts +79 -0
  194. package/src/gateway/connectors/grok-cli.ts +82 -0
  195. package/src/gateway/connectors/hermes.ts +230 -0
  196. package/src/gateway/connectors/iflow-cli.ts +50 -0
  197. package/src/gateway/connectors/index.ts +86 -0
  198. package/src/gateway/connectors/kilo-cli.ts +434 -0
  199. package/src/gateway/connectors/kilo-code.ts +32 -0
  200. package/src/gateway/connectors/kimi-code.ts +241 -0
  201. package/src/gateway/connectors/kiro-cli.ts +6 -0
  202. package/src/gateway/connectors/kiro-ide.ts +6 -0
  203. package/src/gateway/connectors/letta-code.ts +170 -0
  204. package/src/gateway/connectors/mastracode.ts +91 -0
  205. package/src/gateway/connectors/mistral-vibe.ts +495 -0
  206. package/src/gateway/connectors/muse.ts +27 -0
  207. package/src/gateway/connectors/omp.ts +279 -0
  208. package/src/gateway/connectors/openhands.ts +296 -0
  209. package/src/gateway/connectors/pearai.ts +52 -0
  210. package/src/gateway/connectors/pi.ts +357 -0
  211. package/src/gateway/connectors/qoder-cli.ts +6 -0
  212. package/src/gateway/connectors/qwen-code.ts +411 -0
  213. package/src/gateway/connectors/roo.ts +43 -0
  214. package/src/gateway/connectors/tabnine-cli.ts +6 -0
  215. package/src/gateway/connectors/void.ts +6 -0
  216. package/src/gateway/connectors/vscode-copilot.ts +286 -0
  217. package/src/gateway/connectors/windsurf.ts +6 -0
  218. package/src/gateway/connectors/zed.ts +29 -0
  219. package/src/gateway/signin.ts +323 -0
  220. package/src/handoff.ts +104 -0
  221. package/src/hooks.ts +455 -0
  222. package/src/memory/audit.ts +514 -0
  223. package/src/memory/blobs.ts +94 -0
  224. package/src/memory/cli.ts +117 -0
  225. package/src/memory/distill.ts +381 -0
  226. package/src/memory/externalize.ts +435 -0
  227. package/src/memory/lookups.ts +70 -0
  228. package/src/memory/mcp-install.ts +401 -0
  229. package/src/memory/mcp-server.ts +296 -0
  230. package/src/memory/prepare.ts +179 -0
  231. package/src/memory/preview.ts +154 -0
  232. package/src/memory/project.ts +35 -0
  233. package/src/memory/redact.ts +72 -0
  234. package/src/memory/relevance.ts +187 -0
  235. package/src/memory/repomap.ts +516 -0
  236. package/src/memory/store.ts +270 -0
  237. package/src/memory/sync.ts +166 -0
  238. package/src/memory/tidy.ts +193 -0
  239. package/src/memory/types.ts +118 -0
  240. package/src/org/admin-ops.ts +96 -0
  241. package/src/org/auth.ts +112 -0
  242. package/src/org/backup.ts +115 -0
  243. package/src/org/cli.ts +438 -0
  244. package/src/org/client.ts +404 -0
  245. package/src/org/config.ts +44 -0
  246. package/src/org/crypto.ts +537 -0
  247. package/src/org/db.ts +435 -0
  248. package/src/org/graph.ts +318 -0
  249. package/src/org/oidc.ts +435 -0
  250. package/src/org/policy.ts +398 -0
  251. package/src/org/server.ts +920 -0
  252. package/src/org/sso.ts +316 -0
  253. package/src/org/tls.ts +66 -0
  254. package/src/org/types.ts +193 -0
  255. package/src/self.ts +68 -0
  256. package/src/session/attachments.ts +53 -0
  257. package/src/session/peekcache.ts +50 -0
  258. package/src/session/types.ts +104 -0
  259. package/src/session/util.ts +130 -0
  260. package/src/sqlite.ts +48 -0
@@ -0,0 +1,514 @@
1
+ // `brain tokens audit [--dir <folder>]`: where a session's tokens are spent BEFORE the first message, on every request.
2
+ // Static and read-only: it reads text files, parses configs as data and never starts, runs or evaluates anything it finds
3
+ // (an MCP server's `command` is never even looked at: only the server names are read). The one process it runs is
4
+ // `git ls-files` (with the fsmonitor hook switched off), and only to list tracked files; without git it walks the folder.
5
+ // Tokens are chars / 4 like everywhere else in the brain. A finding is a fact plus a one-line fix, worst first.
6
+
7
+ import { closeSync, lstatSync, openSync, readFileSync, readSync, readdirSync, realpathSync, statSync } from "node:fs"
8
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path"
9
+ import { LEAN_AUTO_TOKENS } from "./prepare.ts"
10
+ import { approxTokens } from "./types.ts"
11
+
12
+ export const FILE_WARN_TOKENS = 2_000
13
+ export const TOTAL_WARN_TOKENS = 6_000
14
+ export const LARGE_FILE_BYTES = 100 * 1024
15
+ /** A config or instruction file bigger than this is not read into memory (its size still counts). */
16
+ const MAX_TEXT_BYTES = 4 * 1024 * 1024
17
+ /** ~/.claude.json holds every project's history and gets big. */
18
+ const MAX_CONFIG_BYTES = 64 * 1024 * 1024
19
+
20
+ export interface InstructionFile {
21
+ path: string
22
+ scope: "project" | "user"
23
+ chars: number
24
+ tokens: number
25
+ /** Files pulled in by `@path` lines (Claude memory files only, one level). */
26
+ includes: Array<{ path: string; chars: number; tokens: number }>
27
+ /** tokens of the file plus its includes. */
28
+ totalTokens: number
29
+ /** Set when the file could not be read; it then counts as zero. */
30
+ unreadable?: string
31
+ }
32
+
33
+ export interface McpConfig {
34
+ tool: string
35
+ path: string
36
+ servers: string[]
37
+ error?: string
38
+ }
39
+
40
+ export interface LargeFile {
41
+ path: string
42
+ bytes: number
43
+ tokens: number
44
+ }
45
+
46
+ export interface Finding {
47
+ severity: 1 | 2 | 3
48
+ area: "instructions" | "mcp" | "ignore" | "files" | "brain"
49
+ message: string
50
+ fix: string
51
+ /** Tokens at stake, when there is a number. */
52
+ tokens?: number
53
+ }
54
+
55
+ export interface AuditReport {
56
+ dir: string
57
+ gitTracked: boolean
58
+ instructions: InstructionFile[]
59
+ instructionTokens: number
60
+ mcp: McpConfig[]
61
+ /** Most servers any one tool has configured (a session uses one tool). */
62
+ mcpServersMax: number
63
+ ignore: {
64
+ gitignore: boolean
65
+ agentIgnores: string[]
66
+ nodeModulesPresent: boolean
67
+ nodeModulesCovered: boolean
68
+ buildDirsUncovered: string[]
69
+ lockfilesUncovered: string[]
70
+ }
71
+ large: LargeFile[]
72
+ brain: { installedIn: string[]; leanThresholdTokens: number }
73
+ findings: Finding[]
74
+ /** Instruction-file tokens: what is certainly paid on every request. MCP tool definitions come on top and are not measured. */
75
+ reloadedTokens: number
76
+ }
77
+
78
+ // ---- reading ------------------------------------------------------------------------------------------------------
79
+
80
+ type Read = { ok: true; size: number; text?: string } | { ok: false; why: string }
81
+
82
+ /** A regular file's text (or, over `max`, only its size). Never throws. */
83
+ function readText(path: string, max = MAX_TEXT_BYTES): Read | undefined {
84
+ let st
85
+ try {
86
+ st = statSync(path)
87
+ } catch {
88
+ return undefined
89
+ }
90
+ if (!st.isFile()) return { ok: false, why: "not a regular file" }
91
+ if (st.size > max) return { ok: true, size: st.size }
92
+ try {
93
+ const text = readFileSync(path, "utf8")
94
+ return { ok: true, size: st.size, text }
95
+ } catch (e) {
96
+ return { ok: false, why: (e as NodeJS.ErrnoException).code ?? "unreadable" }
97
+ }
98
+ }
99
+
100
+ const real = (p: string): string => {
101
+ try {
102
+ return realpathSync(p)
103
+ } catch {
104
+ return p
105
+ }
106
+ }
107
+
108
+ const tokensOf = (r: { size: number; text?: string }): { chars: number; tokens: number } =>
109
+ r.text !== undefined ? { chars: r.text.length, tokens: approxTokens(r.text) } : { chars: r.size, tokens: Math.ceil(r.size / 4) }
110
+
111
+ // ---- instruction files --------------------------------------------------------------------------------------------
112
+
113
+ /** `@path` tokens outside code fences and inline code, as Claude memory files use them to include other files. */
114
+ export function includeTargets(text: string): string[] {
115
+ const out: string[] = []
116
+ let fenced = false
117
+ for (const raw of text.split(/\r?\n/)) {
118
+ if (/^\s*(```|~~~)/.test(raw)) {
119
+ fenced = !fenced
120
+ continue
121
+ }
122
+ if (fenced) continue
123
+ const line = raw.replace(/`[^`]*`/g, " ")
124
+ for (const m of line.matchAll(/(?:^|\s)@(\S+)/g)) {
125
+ const p = m[1]!.replace(/[.,;:!?)\]}'"]+$/, "")
126
+ if (p && (p.startsWith("~/") || p.startsWith("/") || p.startsWith("./") || p.startsWith("../") || /[/.]/.test(p)) && !out.includes(p)) out.push(p)
127
+ }
128
+ }
129
+ return out
130
+ }
131
+
132
+ const CLAUDE_MEMORY = /(^|\/)CLAUDE(\.local)?\.md$/
133
+
134
+ function instructionFile(path: string, scope: "project" | "user", home: string): InstructionFile | undefined {
135
+ const r = readText(path)
136
+ if (!r) return undefined
137
+ if (!r.ok) return { path, scope, chars: 0, tokens: 0, includes: [], totalTokens: 0, unreadable: r.why }
138
+ const own = tokensOf(r)
139
+ const includes: InstructionFile["includes"] = []
140
+ if (r.text !== undefined && CLAUDE_MEMORY.test(path)) {
141
+ const seen = new Set<string>([real(path)])
142
+ for (const t of includeTargets(r.text)) {
143
+ const target = t.startsWith("~/") ? join(home, t.slice(2)) : isAbsolute(t) ? t : resolve(dirname(path), t)
144
+ const key = real(target)
145
+ if (seen.has(key)) continue
146
+ const inc = readText(target)
147
+ if (!inc || !inc.ok) continue // an @word that is not a readable file is just text
148
+ seen.add(key)
149
+ // One level only: what the included file includes is not followed.
150
+ includes.push({ path: target, ...tokensOf(inc) })
151
+ }
152
+ }
153
+ const totalTokens = own.tokens + includes.reduce((n, i) => n + i.tokens, 0)
154
+ return { path, scope, chars: own.chars, tokens: own.tokens, includes, totalTokens }
155
+ }
156
+
157
+ function findInstructions(dir: string, home: string): InstructionFile[] {
158
+ const candidates: Array<[string, "project" | "user"]> = [
159
+ ...["CLAUDE.md", "CLAUDE.local.md", ".claude/CLAUDE.md", "AGENTS.md", ".cursorrules", "GEMINI.md", ".github/copilot-instructions.md"].map((p): [string, "project"] => [join(dir, p), "project"]),
160
+ [join(home, ".claude", "CLAUDE.md"), "user"],
161
+ [join(home, ".codex", "AGENTS.md"), "user"],
162
+ [join(home, ".gemini", "GEMINI.md"), "user"],
163
+ ]
164
+ const seen = new Set<string>()
165
+ const out: InstructionFile[] = []
166
+ for (const [path, scope] of candidates) {
167
+ const key = real(path)
168
+ if (seen.has(key)) continue // a symlink to a file already counted (AGENTS.md -> CLAUDE.md) is one file
169
+ const f = instructionFile(path, scope, home)
170
+ if (!f) continue
171
+ seen.add(key)
172
+ out.push(f)
173
+ }
174
+ return out
175
+ }
176
+
177
+ // ---- MCP servers (names only) -------------------------------------------------------------------------------------
178
+
179
+ const isObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v)
180
+
181
+ /** Names of the enabled servers in a `{name: {...}}` table. Only keys and the enabled flag are looked at. */
182
+ function serverNames(table: unknown): string[] {
183
+ if (!isObject(table)) return []
184
+ return Object.keys(table).filter((k) => {
185
+ const v = table[k]
186
+ return !(isObject(v) && (v.disabled === true || v.enabled === false))
187
+ })
188
+ }
189
+
190
+ function mcpConfigs(dir: string, home: string): McpConfig[] {
191
+ const out: McpConfig[] = []
192
+ const json = (tool: string, path: string, pick: (c: Record<string, unknown>) => string[]) => {
193
+ const r = readText(path, MAX_CONFIG_BYTES)
194
+ if (!r) return
195
+ if (!r.ok || r.text === undefined) return void out.push({ tool, path, servers: [], error: r.ok ? "too large to read" : r.why })
196
+ try {
197
+ const c: unknown = JSON.parse(r.text)
198
+ if (!isObject(c)) return void out.push({ tool, path, servers: [], error: "not a JSON object" })
199
+ out.push({ tool, path, servers: [...new Set(pick(c))] })
200
+ } catch {
201
+ out.push({ tool, path, servers: [], error: "not plain JSON" })
202
+ }
203
+ }
204
+ json("project", join(dir, ".mcp.json"), (c) => serverNames(c.mcpServers ?? c.servers))
205
+ json("claude-code", join(home, ".claude.json"), (c) => {
206
+ const names = serverNames(c.mcpServers)
207
+ const projects = isObject(c.projects) ? c.projects : {}
208
+ for (const key of new Set([dir, real(dir)])) {
209
+ const p = projects[key]
210
+ if (isObject(p)) names.push(...serverNames(p.mcpServers))
211
+ }
212
+ return names
213
+ })
214
+ json("cursor", join(home, ".cursor", "mcp.json"), (c) => serverNames(c.mcpServers))
215
+ json("gemini", join(home, ".gemini", "settings.json"), (c) => serverNames(c.mcpServers))
216
+ const toml = join(home, ".codex", "config.toml")
217
+ const r = readText(toml, MAX_CONFIG_BYTES)
218
+ if (r) {
219
+ if (!r.ok || r.text === undefined) out.push({ tool: "codex", path: toml, servers: [], error: r.ok ? "too large to read" : r.why })
220
+ else {
221
+ try {
222
+ const c = Bun.TOML.parse(r.text) as Record<string, unknown>
223
+ out.push({ tool: "codex", path: toml, servers: serverNames(c.mcp_servers) })
224
+ } catch {
225
+ out.push({ tool: "codex", path: toml, servers: [], error: "not valid TOML" })
226
+ }
227
+ }
228
+ }
229
+ return out
230
+ }
231
+
232
+ // ---- ignore files -------------------------------------------------------------------------------------------------
233
+
234
+ const AGENT_IGNORES = [".claudeignore", ".cursorignore", ".aiderignore"]
235
+ const BUILD_DIRS = ["dist", "build", "out", ".next", ".nuxt", ".output", "target", "coverage", ".turbo", "__pycache__"]
236
+ const LOCKFILES = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lock", "bun.lockb", "Cargo.lock", "poetry.lock", "Pipfile.lock", "composer.lock", "Gemfile.lock", "go.sum", "uv.lock"]
237
+
238
+ function ignorePatterns(path: string): string[] | undefined {
239
+ const r = readText(path, 1024 * 1024)
240
+ if (!r || !r.ok || r.text === undefined) return undefined
241
+ return r.text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith("#") && !l.startsWith("!"))
242
+ }
243
+
244
+ /** Whether a top-level name is covered by an ignore pattern (a plain name, a trailing-slash dir, a `**` prefix, or a simple glob). */
245
+ export function covers(patterns: string[], name: string): boolean {
246
+ for (const raw of patterns) {
247
+ const p = raw.replace(/^\/+/, "").replace(/^(\*\*\/)+/, "").replace(/\/+$/, "").replace(/\/\*\*$/, "")
248
+ if (!p || p.includes("/")) continue
249
+ if (p === name) return true
250
+ if (/[*?]/.test(p)) {
251
+ const re = new RegExp(`^${p.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}$`)
252
+ if (re.test(name)) return true
253
+ }
254
+ }
255
+ return false
256
+ }
257
+
258
+ const isDirPath = (p: string): boolean => {
259
+ try {
260
+ return statSync(p).isDirectory()
261
+ } catch {
262
+ return false
263
+ }
264
+ }
265
+ const isFilePath = (p: string): boolean => {
266
+ try {
267
+ return statSync(p).isFile()
268
+ } catch {
269
+ return false
270
+ }
271
+ }
272
+
273
+ function ignoreState(dir: string): AuditReport["ignore"] {
274
+ const git = ignorePatterns(join(dir, ".gitignore"))
275
+ const agent = AGENT_IGNORES.filter((f) => isFilePath(join(dir, f)))
276
+ const all = [...(git ?? []), ...agent.flatMap((f) => ignorePatterns(join(dir, f)) ?? [])]
277
+ const nodeModulesPresent = isDirPath(join(dir, "node_modules"))
278
+ return {
279
+ gitignore: git !== undefined,
280
+ agentIgnores: agent,
281
+ nodeModulesPresent,
282
+ nodeModulesCovered: covers(all, "node_modules"),
283
+ buildDirsUncovered: BUILD_DIRS.filter((d) => isDirPath(join(dir, d)) && !covers(all, d)),
284
+ lockfilesUncovered: LOCKFILES.filter((f) => isFilePath(join(dir, f)) && !covers(all, f)),
285
+ }
286
+ }
287
+
288
+ // ---- large tracked files ------------------------------------------------------------------------------------------
289
+
290
+ function gitFiles(dir: string): string[] | undefined {
291
+ try {
292
+ // fsmonitor is a config-defined command: switch it off so listing files can never run anything from the repo's config.
293
+ const p = Bun.spawnSync(["git", "-c", "core.fsmonitor=false", "-C", dir, "ls-files", "-z"], { stdout: "pipe", stderr: "ignore", env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" } })
294
+ if (p.exitCode !== 0) return undefined
295
+ // Paths are relative to `dir` already (ls-files lists the current directory when given -C).
296
+ return p.stdout.toString().split("\0").filter(Boolean)
297
+ } catch {
298
+ return undefined
299
+ }
300
+ }
301
+
302
+ const SKIP_DIRS = new Set(["node_modules", ...BUILD_DIRS])
303
+
304
+ function walk(dir: string): string[] {
305
+ const out: string[] = []
306
+ const go = (d: string, depth: number) => {
307
+ if (depth > 8 || out.length >= 50_000) return
308
+ let entries
309
+ try {
310
+ entries = readdirSync(d, { withFileTypes: true })
311
+ } catch {
312
+ return
313
+ }
314
+ for (const e of entries) {
315
+ if (out.length >= 50_000) return
316
+ if (e.isDirectory()) {
317
+ if (!e.name.startsWith(".") && !SKIP_DIRS.has(e.name)) go(join(d, e.name), depth + 1)
318
+ } else if (e.isFile()) out.push(relative(dir, join(d, e.name)))
319
+ }
320
+ }
321
+ go(dir, 0)
322
+ return out
323
+ }
324
+
325
+ function isText(path: string): boolean {
326
+ let fd: number | undefined
327
+ try {
328
+ fd = openSync(path, "r")
329
+ const buf = Buffer.alloc(8192)
330
+ const n = readSync(fd, buf, 0, buf.length, 0)
331
+ return !buf.subarray(0, n).includes(0)
332
+ } catch {
333
+ return false
334
+ } finally {
335
+ if (fd !== undefined) closeSync(fd)
336
+ }
337
+ }
338
+
339
+ function largeFiles(dir: string, files: string[]): LargeFile[] {
340
+ const sized: LargeFile[] = []
341
+ for (const f of files) {
342
+ try {
343
+ const st = lstatSync(join(dir, f))
344
+ if (st.isFile() && st.size > LARGE_FILE_BYTES) sized.push({ path: f, bytes: st.size, tokens: Math.ceil(st.size / 4) })
345
+ } catch {
346
+ // Listed by git but gone from the working tree.
347
+ }
348
+ }
349
+ sized.sort((a, b) => b.bytes - a.bytes || (a.path < b.path ? -1 : 1))
350
+ const out: LargeFile[] = []
351
+ for (const s of sized) {
352
+ if (out.length === 5) break
353
+ if (isText(join(dir, s.path))) out.push(s)
354
+ }
355
+ return out
356
+ }
357
+
358
+ // ---- the audit ----------------------------------------------------------------------------------------------------
359
+
360
+ const n = (x: number) => x.toLocaleString("en-US")
361
+
362
+ export function auditTokens(dirIn: string, home: string): AuditReport {
363
+ const dir = resolve(dirIn)
364
+ const findings: Finding[] = []
365
+
366
+ const instructions = findInstructions(dir, home)
367
+ const instructionTokens = instructions.reduce((s, f) => s + f.totalTokens, 0)
368
+ for (const f of instructions) {
369
+ if (f.unreadable) findings.push({ severity: 1, area: "instructions", message: `${f.path} could not be read (${f.unreadable}), so it is not counted`, fix: "fix its permissions or remove it if it is not an instruction file" })
370
+ else if (f.totalTokens > FILE_WARN_TOKENS)
371
+ findings.push({
372
+ severity: 3,
373
+ area: "instructions",
374
+ message: `${f.path} reloads every request and is ${n(f.totalTokens)} tokens${f.includes.length ? ` (${n(f.tokens)} itself + ${f.includes.length} included file${f.includes.length === 1 ? "" : "s"})` : ""}, over ${n(FILE_WARN_TOKENS)}`,
375
+ fix: "keep only what every task needs; move the rest to files the model reads on demand",
376
+ tokens: f.totalTokens,
377
+ })
378
+ }
379
+ if (instructionTokens > TOTAL_WARN_TOKENS)
380
+ findings.push({
381
+ severity: 2,
382
+ area: "instructions",
383
+ message: `instruction files total ${n(instructionTokens)} tokens, over ${n(TOTAL_WARN_TOKENS)} (counted across all tools; one tool loads only its own files)`,
384
+ fix: "delete duplicated rules between CLAUDE.md, AGENTS.md and the user-level files",
385
+ tokens: instructionTokens,
386
+ })
387
+
388
+ const mcp = mcpConfigs(dir, home)
389
+ for (const c of mcp) if (c.error) findings.push({ severity: 1, area: "mcp", message: `${c.path} could not be read (${c.error}); its servers are not counted`, fix: "fix the file or ignore this note" })
390
+ const perTool = new Map<string, Set<string>>()
391
+ for (const c of mcp) {
392
+ const tool = c.tool === "project" ? "project" : c.tool
393
+ const set = perTool.get(tool) ?? new Set<string>()
394
+ for (const s of c.servers) if (s !== "brain") set.add(s) // the brain's own three tool definitions are small and fixed: not a finding
395
+ perTool.set(tool, set)
396
+ }
397
+ // The project's .mcp.json is read by Claude Code (and others): count it with each tool that has servers of its own.
398
+ const project = perTool.get("project") ?? new Set<string>()
399
+ let mcpServersMax = project.size
400
+ for (const [tool, set] of perTool) if (tool !== "project") mcpServersMax = Math.max(mcpServersMax, new Set([...set, ...project]).size)
401
+ const allNames = [...new Set(mcp.flatMap((c) => c.servers))].filter((s) => s !== "brain")
402
+ if (mcpServersMax > 0)
403
+ findings.push({
404
+ severity: mcpServersMax >= 6 ? 3 : mcpServersMax >= 3 ? 2 : 1,
405
+ area: "mcp",
406
+ message: `${mcpServersMax} MCP server${mcpServersMax === 1 ? "" : "s"} configured for one tool (${allNames.join(", ")}): each server's tool definitions are paid on every request unless deferred (the size was not measured: the servers were not started)`,
407
+ fix: "remove servers this project does not use, or turn on your tool's tool-search/deferred loading so definitions load only when needed",
408
+ })
409
+
410
+ const ignore = ignoreState(dir)
411
+ const tracked = gitFiles(dir)
412
+ const files = tracked ?? walk(dir)
413
+ const large = largeFiles(dir, files)
414
+ if (ignore.nodeModulesPresent && !ignore.nodeModulesCovered)
415
+ findings.push({ severity: 3, area: "ignore", message: "node_modules is present and not covered by .gitignore or an agent ignore file", fix: "add `node_modules/` to .gitignore" })
416
+ if (!ignore.gitignore && (ignore.buildDirsUncovered.length || ignore.nodeModulesPresent))
417
+ findings.push({ severity: 2, area: "ignore", message: "no .gitignore, and the folder has dependency or build output", fix: "add a .gitignore listing node_modules/ and build output" })
418
+ if (ignore.buildDirsUncovered.length)
419
+ findings.push({ severity: 2, area: "ignore", message: `build output not ignored: ${ignore.buildDirsUncovered.join(", ")}`, fix: `add ${ignore.buildDirsUncovered.map((d) => `${d}/`).join(" ")} to .gitignore` })
420
+ if (ignore.lockfilesUncovered.length)
421
+ findings.push({ severity: 1, area: "ignore", message: `lockfiles an agent may read (${ignore.lockfilesUncovered.join(", ")}) are not excluded from its context`, fix: "list them in .cursorignore / .aiderignore (Claude Code: a permissions.deny Read rule); keep them in git" })
422
+ if (!ignore.agentIgnores.length && (ignore.lockfilesUncovered.length || large.length))
423
+ findings.push({ severity: 1, area: "ignore", message: "no agent ignore file (.claudeignore, .cursorignore or .aiderignore)", fix: "add one listing lockfiles, generated files and large data files" })
424
+ if (large.length) {
425
+ const sum = large.reduce((s, f) => s + f.tokens, 0)
426
+ findings.push({
427
+ severity: 2,
428
+ area: "files",
429
+ message: `${large.length} large text file${large.length === 1 ? "" : "s"} over ${LARGE_FILE_BYTES / 1024} KB an agent may read whole (${large.map((f) => `${f.path} ${n(f.tokens)} tokens`).join(", ")})`,
430
+ fix: "exclude them in an agent ignore file or move generated data out of the repo",
431
+ tokens: sum,
432
+ })
433
+ }
434
+
435
+ const installedIn = mcp.filter((c) => c.servers.includes("brain")).map((c) => c.tool)
436
+ if (!installedIn.length) findings.push({ severity: 1, area: "brain", message: "the brain MCP server is not installed in any tool, so digests of big tool output cannot be expanded", fix: "run `brain mcp install`" })
437
+
438
+ findings.sort((a, b) => b.severity - a.severity || (b.tokens ?? 0) - (a.tokens ?? 0) || (a.message < b.message ? -1 : a.message > b.message ? 1 : 0))
439
+ return {
440
+ dir,
441
+ gitTracked: tracked !== undefined,
442
+ instructions,
443
+ instructionTokens,
444
+ mcp,
445
+ mcpServersMax,
446
+ ignore,
447
+ large,
448
+ brain: { installedIn, leanThresholdTokens: LEAN_AUTO_TOKENS },
449
+ findings,
450
+ reloadedTokens: instructionTokens,
451
+ }
452
+ }
453
+
454
+ export function formatAudit(r: AuditReport): string[] {
455
+ const out: string[] = [`brain tokens audit: ${r.dir}`, ""]
456
+ const label = ["", "low", "medium", "high"]
457
+ if (!r.findings.length) out.push("findings: none")
458
+ else {
459
+ out.push(`findings (${r.findings.length}, worst first):`)
460
+ r.findings.forEach((f, i) => {
461
+ out.push(`${i + 1}. [${label[f.severity]}] ${f.message}`)
462
+ out.push(` fix: ${f.fix}`)
463
+ })
464
+ }
465
+ out.push("", `instruction files (reload every request): ${r.instructions.length}, ${n(r.instructionTokens)} tokens`)
466
+ for (const f of r.instructions) {
467
+ out.push(` ${f.scope === "user" ? "user " : "project"} ${f.path} ${f.unreadable ? `unreadable (${f.unreadable})` : `${n(f.tokens)} tokens`}`)
468
+ for (const i of f.includes) out.push(` @ ${i.path} ${n(i.tokens)} tokens`)
469
+ }
470
+ const servers = r.mcp.filter((c) => c.servers.length)
471
+ out.push("", `mcp servers: ${servers.reduce((s, c) => s + c.servers.length, 0)} across ${servers.length} config${servers.length === 1 ? "" : "s"} (names only, nothing started)`)
472
+ for (const c of servers) out.push(` ${c.tool}: ${c.servers.join(", ")}`)
473
+ const ig = r.ignore
474
+ out.push("", `ignore: .gitignore ${ig.gitignore ? "present" : "missing"}; agent ignore files: ${ig.agentIgnores.join(", ") || "none"}`)
475
+ out.push(`large files (${r.gitTracked ? "git-tracked" : "not a git repo: folder walk"}, text over ${LARGE_FILE_BYTES / 1024} KB): ${r.large.length ? "" : "none"}`.trimEnd())
476
+ for (const f of r.large) out.push(` ${f.path} ${n(f.tokens)} tokens`)
477
+ out.push("", `brain: MCP server ${r.brain.installedIn.length ? `installed (${r.brain.installedIn.join(", ")})` : "not installed"}; lean is automatic from ${n(r.brain.leanThresholdTokens)} tokens`)
478
+ out.push("", `tokens reloaded on every request: about ${n(r.reloadedTokens)} (instruction files)${r.mcpServersMax ? `, plus the tool definitions of ${r.mcpServersMax} MCP server${r.mcpServersMax === 1 ? "" : "s"} (not measured)` : ""}`)
479
+ return out
480
+ }
481
+
482
+ /** `brain tokens audit [--dir <folder>]`. Exit 0 when the audit ran, 1 on a usage error or a missing folder. */
483
+ export async function tokensCommand(args: string[], home: string, io: { out(line: string): void } = { out: (l) => console.log(l) }): Promise<number> {
484
+ const [sub, ...rest] = args
485
+ if (sub === "lookups") {
486
+ const { formatLookupRates, lookupRates } = await import("./lookups.ts")
487
+ for (const l of formatLookupRates(lookupRates(home))) io.out(l)
488
+ return 0
489
+ }
490
+ if (sub !== "audit" && sub !== undefined) {
491
+ io.out(`unknown subcommand "${sub}"; usage: brain tokens audit [--dir <folder>] | brain tokens lookups`)
492
+ return 1
493
+ }
494
+ let dir = process.cwd()
495
+ for (let i = 0; i < rest.length; i++) {
496
+ if (rest[i] === "--dir") {
497
+ const v = rest[++i]
498
+ if (!v) {
499
+ io.out("--dir needs a folder")
500
+ return 1
501
+ }
502
+ dir = v
503
+ } else {
504
+ io.out(`unexpected argument "${rest[i]}"; usage: brain tokens audit [--dir <folder>]`)
505
+ return 1
506
+ }
507
+ }
508
+ if (!isDirPath(resolve(dir))) {
509
+ io.out(`not a folder: ${dir}`)
510
+ return 1
511
+ }
512
+ for (const line of formatAudit(auditTokens(dir, home))) io.out(line)
513
+ return 0
514
+ }
@@ -0,0 +1,94 @@
1
+ // The blob store: full texts that externalize() replaced with digests, kept on the person's disk so they can come back
2
+ // on demand. `<home>/.brain/blobs/<id>.txt`, dir 0700, files 0600. The id is the first 16 hex of the sha256 of the text,
3
+ // so the same text is stored once and no index is needed. Never ingested into Chitta; stored as they are.
4
+ import { createHash } from "node:crypto"
5
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs"
6
+ import { join } from "node:path"
7
+ import type { BlobRef, BlobStore } from "./types.ts"
8
+
9
+ const ID = /^[0-9a-f]{16}$/
10
+
11
+ export const blobId = (text: string): string => createHash("sha256").update(text).digest("hex").slice(0, 16)
12
+
13
+ export function openBlobStore(home: string): BlobStore {
14
+ const dir = join(home, ".brain", "blobs")
15
+ const ensure = () => {
16
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
17
+ try {
18
+ chmodSync(dir, 0o700)
19
+ } catch {}
20
+ }
21
+ return {
22
+ dir,
23
+ put(text: string, meta?: { tool?: string; project?: string }): BlobRef {
24
+ const id = blobId(text)
25
+ const file = join(dir, `${id}.txt`)
26
+ // The tool kind, for the lookup-rate log (lookups.ts). A sidecar, so blobs stay plain text files.
27
+ const tag = () => {
28
+ if (!meta?.tool || !/^[a-z]{1,20}$/.test(meta.tool)) return
29
+ const m = join(dir, `${id}.kind`)
30
+ try {
31
+ if (!existsSync(m)) writeFileSync(m, meta.tool, { mode: 0o600 })
32
+ } catch {}
33
+ }
34
+ if (existsSync(file)) {
35
+ tag()
36
+ // Used again: refresh the mtime so prune() keeps what sessions still point at.
37
+ try {
38
+ const now = new Date()
39
+ utimesSync(file, now, now)
40
+ } catch {}
41
+ return { id, chars: text.length }
42
+ }
43
+ ensure()
44
+ const tmp = join(dir, `.${id}.${process.pid}.tmp`)
45
+ writeFileSync(tmp, text, { mode: 0o600 })
46
+ try {
47
+ chmodSync(tmp, 0o600)
48
+ } catch {}
49
+ renameSync(tmp, file)
50
+ tag()
51
+ return { id, chars: text.length }
52
+ },
53
+ kindOf(id: string): string | undefined {
54
+ if (!ID.test(id)) return undefined
55
+ try {
56
+ return readFileSync(join(dir, `${id}.kind`), "utf8").trim() || undefined
57
+ } catch {
58
+ return undefined
59
+ }
60
+ },
61
+ get(id: string): string | undefined {
62
+ if (!ID.test(id)) return undefined // also blocks path traversal
63
+ try {
64
+ return readFileSync(join(dir, `${id}.txt`), "utf8")
65
+ } catch {
66
+ return undefined
67
+ }
68
+ },
69
+ prune(olderThanMs: number): number {
70
+ let n = 0
71
+ let names: string[]
72
+ try {
73
+ names = readdirSync(dir)
74
+ } catch {
75
+ return 0
76
+ }
77
+ const cutoff = Date.now() - olderThanMs
78
+ for (const name of names) {
79
+ if (!/^[0-9a-f]{16}\.txt$/.test(name)) continue
80
+ const p = join(dir, name)
81
+ try {
82
+ if (statSync(p).mtimeMs < cutoff) {
83
+ unlinkSync(p)
84
+ try {
85
+ unlinkSync(p.replace(/\.txt$/, ".kind"))
86
+ } catch {}
87
+ n++
88
+ }
89
+ } catch {}
90
+ }
91
+ return n
92
+ },
93
+ }
94
+ }