@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.
- package/LICENSE +48 -0
- package/README.md +235 -2
- package/package.json +48 -4
- package/src/adapters/aider/index.ts +8 -0
- package/src/adapters/aider/reader.ts +129 -0
- package/src/adapters/aider/records.ts +220 -0
- package/src/adapters/aider/writer.ts +269 -0
- package/src/adapters/amazon-q-cli/index.ts +8 -0
- package/src/adapters/amazon-q-cli/reader.ts +84 -0
- package/src/adapters/amazon-q-cli/records.ts +191 -0
- package/src/adapters/amazon-q-cli/writer.ts +370 -0
- package/src/adapters/amazon-q-ide/index.ts +9 -0
- package/src/adapters/amazon-q-ide/reader.ts +131 -0
- package/src/adapters/amazon-q-ide/records.ts +141 -0
- package/src/adapters/amazon-q-ide/writer.ts +325 -0
- package/src/adapters/antigravity/index.ts +9 -0
- package/src/adapters/antigravity/proto.ts +194 -0
- package/src/adapters/antigravity/reader.ts +424 -0
- package/src/adapters/antigravity/records.ts +236 -0
- package/src/adapters/antigravity/writer.ts +463 -0
- package/src/adapters/auggie/index.ts +36 -0
- package/src/adapters/claude-code/index.ts +8 -0
- package/src/adapters/claude-code/reader.ts +639 -0
- package/src/adapters/claude-code/records.ts +88 -0
- package/src/adapters/claude-code/writer.ts +329 -0
- package/src/adapters/cline/index.ts +12 -0
- package/src/adapters/cline/reader.ts +432 -0
- package/src/adapters/cline/records.ts +151 -0
- package/src/adapters/cline/vscdb.ts +56 -0
- package/src/adapters/cline/writer.ts +656 -0
- package/src/adapters/cline/xml.ts +177 -0
- package/src/adapters/cline4/index.ts +9 -0
- package/src/adapters/cline4/reader.ts +179 -0
- package/src/adapters/cline4/records.ts +92 -0
- package/src/adapters/cline4/writer.ts +361 -0
- package/src/adapters/codebuff/index.ts +8 -0
- package/src/adapters/codebuff/project.ts +207 -0
- package/src/adapters/codebuff/reader.ts +215 -0
- package/src/adapters/codebuff/records.ts +173 -0
- package/src/adapters/codebuff/writer.ts +383 -0
- package/src/adapters/codex/index.ts +8 -0
- package/src/adapters/codex/native.ts +239 -0
- package/src/adapters/codex/reader.ts +747 -0
- package/src/adapters/codex/records.ts +114 -0
- package/src/adapters/codex/writer.ts +479 -0
- package/src/adapters/continue/index.ts +9 -0
- package/src/adapters/continue/reader.ts +68 -0
- package/src/adapters/continue/records.ts +418 -0
- package/src/adapters/continue/writer.ts +162 -0
- package/src/adapters/copilot-cli/index.ts +9 -0
- package/src/adapters/copilot-cli/reader.ts +186 -0
- package/src/adapters/copilot-cli/records.ts +241 -0
- package/src/adapters/copilot-cli/writer.ts +353 -0
- package/src/adapters/crush/index.ts +8 -0
- package/src/adapters/crush/reader.ts +180 -0
- package/src/adapters/crush/records.ts +169 -0
- package/src/adapters/crush/writer.ts +333 -0
- package/src/adapters/cursor-cli/index.ts +405 -0
- package/src/adapters/cursor-ide/agentkv.ts +95 -0
- package/src/adapters/cursor-ide/index.ts +501 -0
- package/src/adapters/cursor-ide/transcripts.ts +122 -0
- package/src/adapters/cursor-ide/workspaces.ts +119 -0
- package/src/adapters/cursor-ide/writer.ts +170 -0
- package/src/adapters/devin/index.ts +8 -0
- package/src/adapters/devin/reader.ts +305 -0
- package/src/adapters/devin/records.ts +277 -0
- package/src/adapters/devin/writer.ts +523 -0
- package/src/adapters/factory-droid/index.ts +12 -0
- package/src/adapters/factory-droid/reader.ts +184 -0
- package/src/adapters/factory-droid/records.ts +79 -0
- package/src/adapters/factory-droid/writer.ts +350 -0
- package/src/adapters/gemini-cli/exchanges.ts +228 -0
- package/src/adapters/gemini-cli/index.ts +396 -0
- package/src/adapters/gemini-cli/records.ts +521 -0
- package/src/adapters/gemini-cli/writer.ts +354 -0
- package/src/adapters/goose/index.ts +8 -0
- package/src/adapters/goose/reader.ts +138 -0
- package/src/adapters/goose/records.ts +199 -0
- package/src/adapters/goose/writer.ts +340 -0
- package/src/adapters/grok-cli/image.ts +44 -0
- package/src/adapters/grok-cli/index.ts +8 -0
- package/src/adapters/grok-cli/reader.ts +302 -0
- package/src/adapters/grok-cli/records.ts +172 -0
- package/src/adapters/grok-cli/writer.ts +329 -0
- package/src/adapters/hermes/index.ts +8 -0
- package/src/adapters/hermes/reader.ts +249 -0
- package/src/adapters/hermes/records.ts +285 -0
- package/src/adapters/hermes/writer.ts +452 -0
- package/src/adapters/iflow-cli/index.ts +72 -0
- package/src/adapters/iflow-cli/records.ts +215 -0
- package/src/adapters/iflow-cli/writer.ts +204 -0
- package/src/adapters/index.ts +112 -0
- package/src/adapters/kilo-cli/index.ts +21 -0
- package/src/adapters/kilo-code/index.ts +11 -0
- package/src/adapters/kilo-code/writer.ts +92 -0
- package/src/adapters/kimi/index.ts +9 -0
- package/src/adapters/kimi/reader.ts +92 -0
- package/src/adapters/kimi/records.ts +221 -0
- package/src/adapters/kimi/writer.ts +367 -0
- package/src/adapters/kiro-cli/index.ts +8 -0
- package/src/adapters/kiro-cli/reader.ts +87 -0
- package/src/adapters/kiro-cli/records.ts +260 -0
- package/src/adapters/kiro-cli/writer.ts +305 -0
- package/src/adapters/kiro-ide/index.ts +8 -0
- package/src/adapters/kiro-ide/writer.ts +52 -0
- package/src/adapters/letta/index.ts +8 -0
- package/src/adapters/letta/reader.ts +177 -0
- package/src/adapters/letta/records.ts +119 -0
- package/src/adapters/letta/writer.ts +413 -0
- package/src/adapters/mastracode/index.ts +8 -0
- package/src/adapters/mastracode/reader.ts +323 -0
- package/src/adapters/mastracode/records.ts +336 -0
- package/src/adapters/mastracode/writer.ts +385 -0
- package/src/adapters/mistral-vibe/index.ts +9 -0
- package/src/adapters/mistral-vibe/reader.ts +62 -0
- package/src/adapters/mistral-vibe/records.ts +104 -0
- package/src/adapters/mistral-vibe/writer.ts +359 -0
- package/src/adapters/muse/index.ts +10 -0
- package/src/adapters/muse/reader.ts +332 -0
- package/src/adapters/muse/records.ts +104 -0
- package/src/adapters/muse/writer.ts +387 -0
- package/src/adapters/omp/index.ts +13 -0
- package/src/adapters/omp/reader.ts +314 -0
- package/src/adapters/omp/records.ts +153 -0
- package/src/adapters/omp/writer.ts +216 -0
- package/src/adapters/opencode/ids.ts +52 -0
- package/src/adapters/opencode/index.ts +31 -0
- package/src/adapters/opencode/note.ts +8 -0
- package/src/adapters/opencode/reader.ts +452 -0
- package/src/adapters/opencode/tools.ts +174 -0
- package/src/adapters/opencode/v2.ts +367 -0
- package/src/adapters/opencode/writer.ts +379 -0
- package/src/adapters/openhands/index.ts +8 -0
- package/src/adapters/openhands/reader.ts +222 -0
- package/src/adapters/openhands/records.ts +136 -0
- package/src/adapters/openhands/writer.ts +404 -0
- package/src/adapters/pearai/index.ts +10 -0
- package/src/adapters/pi/index.ts +28 -0
- package/src/adapters/pi/reader.ts +261 -0
- package/src/adapters/pi/records.ts +99 -0
- package/src/adapters/pi/writer.ts +362 -0
- package/src/adapters/qoder-cli/index.ts +8 -0
- package/src/adapters/qoder-cli/reader.ts +83 -0
- package/src/adapters/qoder-cli/records.ts +45 -0
- package/src/adapters/qoder-cli/writer.ts +72 -0
- package/src/adapters/qwen-code/index.ts +84 -0
- package/src/adapters/qwen-code/records.ts +194 -0
- package/src/adapters/qwen-code/writer.ts +224 -0
- package/src/adapters/tabnine-cli/index.ts +8 -0
- package/src/adapters/tabnine-cli/reader.ts +106 -0
- package/src/adapters/tabnine-cli/records.ts +33 -0
- package/src/adapters/tabnine-cli/writer.ts +156 -0
- package/src/adapters/types.ts +73 -0
- package/src/adapters/void/index.ts +9 -0
- package/src/adapters/void/reader.ts +48 -0
- package/src/adapters/void/records.ts +285 -0
- package/src/adapters/void/writer.ts +93 -0
- package/src/adapters/vscode-copilot/index.ts +9 -0
- package/src/adapters/vscode-copilot/reader.ts +212 -0
- package/src/adapters/vscode-copilot/records.ts +388 -0
- package/src/adapters/vscode-copilot/shared.ts +68 -0
- package/src/adapters/vscode-copilot/writer.ts +546 -0
- package/src/adapters/zed/index.ts +8 -0
- package/src/adapters/zed/reader.ts +198 -0
- package/src/adapters/zed/records.ts +87 -0
- package/src/adapters/zed/writer.ts +341 -0
- package/src/brief-writers.ts +71 -0
- package/src/brief.ts +238 -0
- package/src/cli.ts +351 -0
- package/src/dashboard/cli.ts +148 -0
- package/src/dashboard/conversions.ts +170 -0
- package/src/dashboard/report.ts +261 -0
- package/src/dashboard/send.ts +213 -0
- package/src/dashboard/state.ts +95 -0
- package/src/fsutil.ts +133 -0
- package/src/gateway/common.ts +232 -0
- package/src/gateway/connect.ts +847 -0
- package/src/gateway/connectors/aider.ts +356 -0
- package/src/gateway/connectors/amazon-q-cli.ts +6 -0
- package/src/gateway/connectors/amazon-q-ide.ts +6 -0
- package/src/gateway/connectors/antigravity.ts +6 -0
- package/src/gateway/connectors/auggie.ts +6 -0
- package/src/gateway/connectors/cline-sdk.ts +85 -0
- package/src/gateway/connectors/cline.ts +34 -0
- package/src/gateway/connectors/codebuff.ts +6 -0
- package/src/gateway/connectors/continue.ts +579 -0
- package/src/gateway/connectors/copilot-cli.ts +138 -0
- package/src/gateway/connectors/crush.ts +313 -0
- package/src/gateway/connectors/cursor-cli.ts +6 -0
- package/src/gateway/connectors/cursor.ts +6 -0
- package/src/gateway/connectors/devin.ts +6 -0
- package/src/gateway/connectors/factory-droid.ts +308 -0
- package/src/gateway/connectors/goose.ts +79 -0
- package/src/gateway/connectors/grok-cli.ts +82 -0
- package/src/gateway/connectors/hermes.ts +230 -0
- package/src/gateway/connectors/iflow-cli.ts +50 -0
- package/src/gateway/connectors/index.ts +86 -0
- package/src/gateway/connectors/kilo-cli.ts +434 -0
- package/src/gateway/connectors/kilo-code.ts +32 -0
- package/src/gateway/connectors/kimi-code.ts +241 -0
- package/src/gateway/connectors/kiro-cli.ts +6 -0
- package/src/gateway/connectors/kiro-ide.ts +6 -0
- package/src/gateway/connectors/letta-code.ts +170 -0
- package/src/gateway/connectors/mastracode.ts +91 -0
- package/src/gateway/connectors/mistral-vibe.ts +495 -0
- package/src/gateway/connectors/muse.ts +27 -0
- package/src/gateway/connectors/omp.ts +279 -0
- package/src/gateway/connectors/openhands.ts +296 -0
- package/src/gateway/connectors/pearai.ts +52 -0
- package/src/gateway/connectors/pi.ts +357 -0
- package/src/gateway/connectors/qoder-cli.ts +6 -0
- package/src/gateway/connectors/qwen-code.ts +411 -0
- package/src/gateway/connectors/roo.ts +43 -0
- package/src/gateway/connectors/tabnine-cli.ts +6 -0
- package/src/gateway/connectors/void.ts +6 -0
- package/src/gateway/connectors/vscode-copilot.ts +286 -0
- package/src/gateway/connectors/windsurf.ts +6 -0
- package/src/gateway/connectors/zed.ts +29 -0
- package/src/gateway/signin.ts +323 -0
- package/src/handoff.ts +104 -0
- package/src/hooks.ts +455 -0
- package/src/memory/audit.ts +514 -0
- package/src/memory/blobs.ts +94 -0
- package/src/memory/cli.ts +117 -0
- package/src/memory/distill.ts +381 -0
- package/src/memory/externalize.ts +435 -0
- package/src/memory/lookups.ts +70 -0
- package/src/memory/mcp-install.ts +401 -0
- package/src/memory/mcp-server.ts +296 -0
- package/src/memory/prepare.ts +179 -0
- package/src/memory/preview.ts +154 -0
- package/src/memory/project.ts +35 -0
- package/src/memory/redact.ts +72 -0
- package/src/memory/relevance.ts +187 -0
- package/src/memory/repomap.ts +516 -0
- package/src/memory/store.ts +270 -0
- package/src/memory/sync.ts +166 -0
- package/src/memory/tidy.ts +193 -0
- package/src/memory/types.ts +118 -0
- package/src/org/admin-ops.ts +96 -0
- package/src/org/auth.ts +112 -0
- package/src/org/backup.ts +115 -0
- package/src/org/cli.ts +438 -0
- package/src/org/client.ts +404 -0
- package/src/org/config.ts +44 -0
- package/src/org/crypto.ts +537 -0
- package/src/org/db.ts +435 -0
- package/src/org/graph.ts +318 -0
- package/src/org/oidc.ts +435 -0
- package/src/org/policy.ts +398 -0
- package/src/org/server.ts +920 -0
- package/src/org/sso.ts +316 -0
- package/src/org/tls.ts +66 -0
- package/src/org/types.ts +193 -0
- package/src/self.ts +68 -0
- package/src/session/attachments.ts +53 -0
- package/src/session/peekcache.ts +50 -0
- package/src/session/types.ts +104 -0
- package/src/session/util.ts +130 -0
- 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
|
+
}
|