@100xprompt/brain 0.0.0-stage → 0.1.0

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 (213) hide show
  1. package/LICENSE +48 -0
  2. package/README.md +172 -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 +326 -0
  170. package/src/fsutil.ts +133 -0
  171. package/src/gateway/connect.ts +845 -0
  172. package/src/gateway/signin.ts +323 -0
  173. package/src/handoff.ts +104 -0
  174. package/src/hooks.ts +455 -0
  175. package/src/memory/audit.ts +514 -0
  176. package/src/memory/blobs.ts +94 -0
  177. package/src/memory/cli.ts +117 -0
  178. package/src/memory/distill.ts +381 -0
  179. package/src/memory/externalize.ts +435 -0
  180. package/src/memory/lookups.ts +70 -0
  181. package/src/memory/mcp-install.ts +401 -0
  182. package/src/memory/mcp-server.ts +296 -0
  183. package/src/memory/prepare.ts +179 -0
  184. package/src/memory/preview.ts +154 -0
  185. package/src/memory/project.ts +35 -0
  186. package/src/memory/redact.ts +72 -0
  187. package/src/memory/relevance.ts +187 -0
  188. package/src/memory/repomap.ts +516 -0
  189. package/src/memory/store.ts +270 -0
  190. package/src/memory/sync.ts +166 -0
  191. package/src/memory/tidy.ts +193 -0
  192. package/src/memory/types.ts +118 -0
  193. package/src/org/admin-ops.ts +96 -0
  194. package/src/org/auth.ts +112 -0
  195. package/src/org/backup.ts +115 -0
  196. package/src/org/cli.ts +438 -0
  197. package/src/org/client.ts +404 -0
  198. package/src/org/config.ts +44 -0
  199. package/src/org/crypto.ts +537 -0
  200. package/src/org/db.ts +435 -0
  201. package/src/org/graph.ts +318 -0
  202. package/src/org/oidc.ts +435 -0
  203. package/src/org/policy.ts +398 -0
  204. package/src/org/server.ts +920 -0
  205. package/src/org/sso.ts +316 -0
  206. package/src/org/tls.ts +66 -0
  207. package/src/org/types.ts +193 -0
  208. package/src/self.ts +68 -0
  209. package/src/session/attachments.ts +53 -0
  210. package/src/session/peekcache.ts +50 -0
  211. package/src/session/types.ts +104 -0
  212. package/src/session/util.ts +130 -0
  213. package/src/sqlite.ts +48 -0
@@ -0,0 +1,435 @@
1
+ // externalize: replace big tool calls/results/patches with a one-line digest and a pointer; the full text lives in the blob
2
+ // store and comes back with `brain_expand`. Pure over the session (the input is not modified; untouched turns are shared by
3
+ // reference), deterministic, no model. Stubs keep id/callId/ok/toolKind/name, so call/result pairing never breaks.
4
+ import type { Session, ToolKind, Turn } from "../session/types.ts"
5
+ import { asText } from "../session/util.ts"
6
+ import { hasSecret, redact } from "./redact.ts"
7
+ import { tidyOutput } from "./tidy.ts"
8
+ import { previewOf } from "./preview.ts"
9
+ import { STUB_PREFIX, approxTokens, type BlobRef, type BlobStore, type ExternalizeOptions, type ExternalizeResult } from "./types.ts"
10
+
11
+ /** ExternalizeOptions plus what the tidy and budget steps add (types.ts is not edited here; fold these in there when convenient). */
12
+ export interface ExternalizeOpts extends ExternalizeOptions {
13
+ /** Tidy the tool results that stay in the conversation (lossless in meaning, see tidy.ts). Default true. */
14
+ tidy?: boolean
15
+ /** The newest tool turns are protected only while their total stays at or under this many tokens (besides `keepRecent`). */
16
+ keepRecentTokens?: number
17
+ /** Stop replacing (oldest first) once the session is estimated at or under this many tokens. */
18
+ stopAtTokens?: number
19
+ /** Add a preview (outline, head/tail, error lines, first matches) under each result's stub. Default true. */
20
+ preview?: boolean
21
+ }
22
+
23
+ /** ExternalizeResult plus `tidied`: characters saved by tidy on results that stayed. */
24
+ export interface ExternalizeOutcome extends ExternalizeResult {
25
+ tidied: number
26
+ /** Replaced tool results by tool kind (for the lookup-rate log). */
27
+ byKind: Partial<Record<ToolKind, number>>
28
+ }
29
+
30
+ type Call = Extract<Turn, { kind: "tool_call" }>
31
+ type Result = Extract<Turn, { kind: "tool_result" }>
32
+
33
+ const ID_IN_STUB = /^\[brain: output stored[^\]]*?\bid=([0-9a-f]{16})\b/
34
+
35
+ /** True when the text is (or starts with) a stub written by externalize. */
36
+ export const isStub = (text: string): boolean => typeof text === "string" && text.trimStart().startsWith(STUB_PREFIX)
37
+
38
+ /** The blob id inside a stub, or undefined when the text is not a stub. */
39
+ export function expandStub(text: string): string | undefined {
40
+ if (typeof text !== "string") return undefined
41
+ return ID_IN_STUB.exec(text.trimStart())?.[1]
42
+ }
43
+
44
+ const clipLine = (s: string, n: number): string => {
45
+ const t = s.replace(/\s+/g, " ").trim()
46
+ return t.length > n ? `${t.slice(0, n - 1)}…` : t
47
+ }
48
+ const lines = (s: string): string[] => s.split("\n")
49
+ const nonEmpty = (s: string): string[] => lines(s).filter((l) => l.trim())
50
+ const firstMeaningful = (s: string): string => nonEmpty(s).find((l) => /[\p{L}\p{N}]/u.test(l)) ?? ""
51
+
52
+ function field(input: unknown, ...keys: string[]): string | undefined {
53
+ if (!input || typeof input !== "object") return undefined
54
+ const o = input as Record<string, unknown>
55
+ for (const k of keys) {
56
+ const v = o[k]
57
+ if (typeof v === "string" && v) return v
58
+ if (Array.isArray(v) && v.every((x) => typeof x === "string") && v.length) return v.join(" ")
59
+ }
60
+ return undefined
61
+ }
62
+ const pathOf = (input: unknown) => field(input, "file_path", "path", "filePath", "target_file", "file", "filename", "notebook_path")
63
+ const commandOf = (input: unknown) => (typeof input === "string" ? input : field(input, "command", "cmd", "script"))
64
+
65
+ /** A one-line summary of a test run when a common runner format is found (bun, jest, pytest, go, cargo). */
66
+ export function testSummary(out: string): string | undefined {
67
+ const bunPass = /^\s*(\d+) pass\b/m.exec(out)
68
+ const bunFail = /^\s*(\d+) fail\b/m.exec(out)
69
+ if (bunPass || bunFail) return `${bunPass?.[1] ?? 0} pass, ${bunFail?.[1] ?? 0} fail`
70
+ const jest = /^Tests:\s+(.+)$/m.exec(out)
71
+ if (jest) return clipLine(jest[1]!, 80)
72
+ const py = /^=+ (.*\b(?:passed|failed|error)[^=]*?) =+\s*$/m.exec(out)
73
+ if (py) return clipLine(py[1]!, 80)
74
+ const cargo = [...out.matchAll(/^test result: (\w+)\. (\d+) passed; (\d+) failed/gm)]
75
+ if (cargo.length) {
76
+ const p = cargo.reduce((a, m) => a + Number(m[2]), 0)
77
+ const f = cargo.reduce((a, m) => a + Number(m[3]), 0)
78
+ return `${p} passed, ${f} failed`
79
+ }
80
+ const goOk = (out.match(/^ok\s+\S+/gm) ?? []).length
81
+ const goFail = (out.match(/^(?:FAIL\s+\S+|--- FAIL)/gm) ?? []).length
82
+ if (goOk || goFail) return `go: ${goOk} ok, ${goFail} fail`
83
+ return undefined
84
+ }
85
+
86
+ function exitStatus(out: string, ok: boolean): string {
87
+ const m = /(?:Exit code|Process exited with code|exit code|exited with code)[:\s]+(-?\d+)/i.exec(out)
88
+ if (m) return `exit ${m[1]}`
89
+ return ok ? "exit 0" : "failed"
90
+ }
91
+
92
+ const sizeOf = (n: number) => (n >= 10_000 ? `${Math.round(n / 1000)}K chars` : `${n} chars`)
93
+
94
+ /**
95
+ * A short, deterministic description of a tool call or result. `call` is the call a result answers (for the path or
96
+ * command, which the result alone does not carry). Never contains text matching hasSecret.
97
+ */
98
+ export function digestOf(turn: Turn, kindHint?: ToolKind, call?: Call): string {
99
+ const raw = digestRaw(turn, kindHint, call)
100
+ let d = redact(raw)
101
+ if (hasSecret(d)) d = "[redacted]"
102
+ return d
103
+ }
104
+
105
+ function digestRaw(turn: Turn, kindHint: ToolKind | undefined, call: Call | undefined): string {
106
+ if (turn.kind === "file_edit") {
107
+ const p = turn.patch ?? ""
108
+ const first = lines(p).find((l) => /^[+-](?![+-])/.test(l))
109
+ return `edit ${turn.path} (${sizeOf(p.length)})${first ? `, first change: ${clipLine(first, 80)}` : ""}`
110
+ }
111
+ if (turn.kind === "tool_call") {
112
+ const kind = kindHint ?? turn.toolKind
113
+ const p = pathOf(turn.input)
114
+ const size = sizeOf(asText(turn.input).length)
115
+ if (kind === "edit" || kind === "write") {
116
+ const body = field(turn.input, "new_string", "content", "file_text", "new_str", "text", "patch", "diff", "contents") ?? ""
117
+ const first = firstMeaningful(body)
118
+ return `${kind} ${p ?? turn.name} (${size})${first ? `, first line: ${clipLine(first, 80)}` : ""}`
119
+ }
120
+ if (kind === "shell") return `${turn.name}: ${clipLine(firstMeaningfulOr(commandOf(turn.input) ?? asText(turn.input)), 100)} (${size})`
121
+ return `${turn.name}${p ? ` ${p}` : ""} (${size})`
122
+ }
123
+ if (turn.kind !== "tool_result") return clipLine(firstMeaningful(asText((turn as { text?: string }).text)), 100)
124
+
125
+ const kind = kindHint ?? call?.toolKind ?? "other"
126
+ const out = turn.output
127
+ const n = nonEmpty(out).length
128
+ const size = sizeOf(out.length)
129
+ switch (kind) {
130
+ case "read": {
131
+ const p = call ? pathOf(call.input) : undefined
132
+ const first = firstMeaningful(out)
133
+ return `read${p ? ` ${p}` : ""}: ${n} lines${first ? `, starts: ${clipLine(first, 80)}` : ""}`
134
+ }
135
+ case "shell": {
136
+ const cmd = call ? commandOf(call.input) : undefined
137
+ const t = testSummary(out)
138
+ return `${cmd ? `$ ${clipLine(firstMeaningfulOr(cmd), 100)}: ` : ""}${exitStatus(out, turn.ok)}, ${n} lines${t ? `, tests: ${t}` : ""}`
139
+ }
140
+ case "search": {
141
+ const m = nonEmpty(out)
142
+ return `${m.length} matches${m[0] ? `: ${clipLine(m[0], 70)}` : ""}${m[1] ? ` | ${clipLine(m[1], 70)}` : ""}`
143
+ }
144
+ case "list":
145
+ return `${n} entries`
146
+ case "web": {
147
+ const url = (call && field(call.input, "url", "uri", "query")) || /https?:\/\/\S+/.exec(out)?.[0]
148
+ return `web${url ? ` ${clipLine(url, 100)}` : ""}: ${size}`
149
+ }
150
+ default: {
151
+ const first = firstMeaningful(out)
152
+ return `${first ? clipLine(first, 100) : "output"} (${size})`
153
+ }
154
+ }
155
+ }
156
+ const firstMeaningfulOr = (s: string): string => nonEmpty(s)[0] ?? s
157
+
158
+ function stubText(chars: number, id: string, digest: string): string {
159
+ return `${STUB_PREFIX}, ${chars} chars, id=${id}: ${clipLine(digest, 200)} | brain_expand id=${id} (grep= for matching lines)]`
160
+ }
161
+
162
+ /** Characters a turn carries (the text the measurements count). */
163
+ function charsOf(t: Turn): number {
164
+ switch (t.kind) {
165
+ case "user":
166
+ case "assistant":
167
+ case "reasoning":
168
+ return t.text.length
169
+ case "tool_call":
170
+ return asText(t.input).length
171
+ case "tool_result":
172
+ return t.output.length
173
+ case "file_edit":
174
+ return (t.patch ?? "").length
175
+ case "compaction":
176
+ return t.summary.length
177
+ default:
178
+ return 0
179
+ }
180
+ }
181
+
182
+ const FIELD_MIN = 300
183
+
184
+ /** Replaces long strings anywhere inside a tool input with stubs; returns the new input and how many were replaced. */
185
+ function stubInput(input: unknown, call: Call, store: BlobStore, depth = 0): { value: unknown; n: number } {
186
+ if (typeof input === "string") {
187
+ if (input.length <= FIELD_MIN || isStub(input)) return { value: input, n: 0 }
188
+ const ref = store.put(input)
189
+ return { value: stubText(input.length, ref.id, digestOf({ ...call, input }, call.toolKind)), n: 1 }
190
+ }
191
+ if (depth > 6 || !input || typeof input !== "object") return { value: input, n: 0 }
192
+ let n = 0
193
+ if (Array.isArray(input)) {
194
+ const out = input.map((v) => {
195
+ const r = stubInput(v, call, store, depth + 1)
196
+ n += r.n
197
+ return r.value
198
+ })
199
+ return { value: n ? out : input, n }
200
+ }
201
+ const out: Record<string, unknown> = {}
202
+ for (const [k, v] of Object.entries(input as Record<string, unknown>)) {
203
+ if (typeof v === "string" && v.length > FIELD_MIN && !isStub(v)) {
204
+ const ref = store.put(v)
205
+ const first = firstMeaningful(v)
206
+ out[k] = stubText(v.length, ref.id, `${call.name} ${pathOf(input) ?? ""} ${k}${first ? `: ${clipLine(first, 70)}` : ""}`.replace(/\s+/g, " "))
207
+ n++
208
+ } else {
209
+ const r = stubInput(v, call, store, depth + 1)
210
+ n += r.n
211
+ out[k] = r.value
212
+ }
213
+ }
214
+ return { value: n ? out : input, n }
215
+ }
216
+
217
+ export function externalize(session: Session, store: BlobStore, opts: ExternalizeOpts = {}): ExternalizeOutcome {
218
+ const minChars = opts.minChars ?? 1500
219
+ const keepRecent = opts.keepRecent ?? 8
220
+ const doTidy = opts.tidy !== false
221
+ const doPreview = opts.preview !== false
222
+ const byKind: Partial<Record<ToolKind, number>> = {}
223
+
224
+ // Units: a call with its result and file edits; other file edits stand alone. The newest keepRecent units are protected.
225
+ const unitOf = new Map<number, number>()
226
+ const byCall = new Map<string, number>()
227
+ const calls = new Map<string, Call>()
228
+ let units = 0
229
+ session.turns.forEach((t, i) => {
230
+ if (t.kind === "tool_call") {
231
+ calls.set(t.id, t)
232
+ byCall.set(t.id, units)
233
+ unitOf.set(i, units++)
234
+ } else if (t.kind === "tool_result" || (t.kind === "file_edit" && t.callId)) {
235
+ const u = byCall.get(t.kind === "tool_result" ? t.callId : t.callId!)
236
+ unitOf.set(i, u ?? units++)
237
+ } else if (t.kind === "file_edit") unitOf.set(i, units++)
238
+ })
239
+ let protectedFrom = units - keepRecent
240
+ if (opts.keepRecentTokens !== undefined) {
241
+ // Also a token cap: walk the newest units back while their total stays within it.
242
+ const unitChars = new Array<number>(units).fill(0)
243
+ session.turns.forEach((t, i) => {
244
+ const u = unitOf.get(i)
245
+ if (u !== undefined) unitChars[u]! += charsOf(t)
246
+ })
247
+ let acc = 0
248
+ let from = units
249
+ for (let u = units - 1; u >= Math.max(0, units - keepRecent); u--) {
250
+ acc += unitChars[u]!
251
+ if (acc / 4 > opts.keepRecentTokens) break
252
+ from = u
253
+ }
254
+ protectedFrom = Math.max(protectedFrom, from)
255
+ }
256
+
257
+ // Tidy first (on a view; the blob always gets the original text): what stays in the conversation is the tidied text.
258
+ const tidyOf = new Map<number, { text: string; saved: number }>()
259
+ let before = 0
260
+ let tidySaved = 0
261
+ session.turns.forEach((t, i) => {
262
+ before += charsOf(t)
263
+ if (doTidy && t.kind === "tool_result" && !isStub(t.output)) {
264
+ const r = tidyOutput(t.output)
265
+ if (r.saved) {
266
+ tidyOf.set(i, r)
267
+ tidySaved += r.saved
268
+ }
269
+ }
270
+ })
271
+ const stopAt = opts.stopAtTokens !== undefined ? opts.stopAtTokens * 4 : undefined
272
+ let projected = before - tidySaved
273
+
274
+ let after = 0
275
+ let replaced = 0
276
+ let tidied = 0
277
+ const turns = session.turns.map((t, i): Turn => {
278
+ const td = tidyOf.get(i)
279
+ const cur: Turn = td && t.kind === "tool_result" ? { ...t, output: td.text } : t
280
+ const b = charsOf(cur)
281
+ const u = unitOf.get(i)
282
+ const done = stopAt !== undefined && projected <= stopAt
283
+ if (u === undefined || u >= protectedFrom || done) {
284
+ after += b
285
+ if (td) tidied += td.saved
286
+ return cur
287
+ }
288
+ let next = cur
289
+ if (t.kind === "tool_result") {
290
+ if (t.output.length > minChars && !isStub(t.output)) {
291
+ const call = calls.get(t.callId)
292
+ const kind: ToolKind = call?.toolKind ?? "other"
293
+ const ref = store.put(t.output, { tool: kind })
294
+ const stub = stubText(t.output.length, ref.id, digestOf(t, undefined, call))
295
+ let text = stub
296
+ // A preview is worth keeping only while the stub stays well under the output it replaces (at most half of it);
297
+ // otherwise (short outputs at the deeper --budget levels) the plain stub is used, as before previews existed.
298
+ const pv = doPreview ? previewOf(t.output, kind, { ok: t.ok, ...(call && pathOf(call.input) ? { path: pathOf(call.input)! } : {}) }) : ""
299
+ if (pv && stub.length + 1 + pv.length <= b / 2) {
300
+ text = `${stub}\n${pv}`
301
+ } else if (!t.ok) {
302
+ const err = nonEmpty(t.output)
303
+ .slice(0, 4)
304
+ .map((l) => {
305
+ const r = clipLine(redact(l), 200)
306
+ return hasSecret(r) ? "[line withheld: secret]" : r
307
+ })
308
+ .join("\n")
309
+ text = `${stub}\n${err}`
310
+ }
311
+ if (text.length < b) {
312
+ next = { ...t, output: text }
313
+ replaced++
314
+ byKind[kind] = (byKind[kind] ?? 0) + 1
315
+ }
316
+ }
317
+ } else if (t.kind === "tool_call") {
318
+ if (b > minChars) {
319
+ const r = stubInput(t.input, t, store)
320
+ if (r.n) {
321
+ next = { ...t, input: r.value }
322
+ replaced++
323
+ }
324
+ }
325
+ } else if (t.kind === "file_edit") {
326
+ const p = t.patch
327
+ if (p && p.length > minChars && !isStub(p)) {
328
+ const ref = store.put(p)
329
+ const stub = stubText(p.length, ref.id, digestOf(t))
330
+ if (stub.length < p.length) {
331
+ next = { ...t, patch: stub }
332
+ replaced++
333
+ }
334
+ }
335
+ }
336
+ if (next === cur && td) tidied += td.saved
337
+ const nb = charsOf(next)
338
+ projected -= b - nb
339
+ after += nb
340
+ return next
341
+ })
342
+ return { session: { ...session, header: { ...session.header }, turns }, replaced, before, after, tidied, byKind }
343
+ }
344
+
345
+ /** Blobs counted but not kept: trial runs and dry runs. */
346
+ export function countingBlobs(): BlobStore {
347
+ const m = new Map<string, string>()
348
+ return {
349
+ dir: "(not stored)",
350
+ put(text: string): BlobRef {
351
+ const id = Bun.hash(text).toString(16).padStart(16, "0").slice(0, 16)
352
+ m.set(id, text)
353
+ return { id, chars: text.length }
354
+ },
355
+ get: (id) => m.get(id),
356
+ prune: () => 0,
357
+ }
358
+ }
359
+
360
+ /** The tokens in a session's text-bearing parts (chars / 4): the same estimate the lean notes and the measurements use. */
361
+ export function estimateTokens(s: Session): number {
362
+ let chars = 0
363
+ for (const t of s.turns) {
364
+ switch (t.kind) {
365
+ case "user":
366
+ case "assistant":
367
+ case "reasoning":
368
+ chars += t.text.length
369
+ break
370
+ case "tool_call":
371
+ chars += JSON.stringify(t.input ?? "").length
372
+ break
373
+ case "tool_result":
374
+ chars += t.output.length
375
+ break
376
+ case "file_edit":
377
+ chars += t.patch?.length ?? 0
378
+ break
379
+ case "compaction":
380
+ chars += t.summary.length
381
+ break
382
+ }
383
+ }
384
+ return Math.ceil(chars / 4)
385
+ }
386
+
387
+ /** Escalation levels (the graduated idea: mask a little, then more, before giving up detail). Level 0 means nothing needed masking. */
388
+ export const BUDGET_LEVELS: ReadonlyArray<{ minChars: number; keepRecent: number }> = [
389
+ { minChars: 1500, keepRecent: 8 },
390
+ { minChars: 800, keepRecent: 8 },
391
+ { minChars: 800, keepRecent: 4 },
392
+ { minChars: 300, keepRecent: 2 },
393
+ ]
394
+ /** Share of the target the newest tool turns may take while they stay protected. */
395
+ export const KEEP_RECENT_SHARE = 0.2
396
+
397
+ export interface BudgetOptions {
398
+ targetTokens: number
399
+ tidy?: boolean
400
+ }
401
+ export interface BudgetOutcome extends ExternalizeOutcome {
402
+ /** 0: the tidied session already fits; 1..4: the first level at which it fits (4 when none does). */
403
+ level: number
404
+ /** Whether the session ended at or under the target. */
405
+ reached: boolean
406
+ /** Estimated tokens after (chars / 4, as `estimateTokens`). */
407
+ tokens: number
408
+ }
409
+
410
+ /**
411
+ * Masks only as much as the target needs: tries level 1 (today's defaults), then 2, 3, 4 until the whole level brings the session
412
+ * to the target, then applies that level for real, oldest first, stopping as soon as the session fits. Trial runs store nothing.
413
+ */
414
+ export function externalizeToBudget(session: Session, store: BlobStore, o: BudgetOptions): BudgetOutcome {
415
+ const target = Math.max(0, Math.floor(o.targetTokens))
416
+ const cap = Math.round(target * KEEP_RECENT_SHARE)
417
+ const common = { keepRecentTokens: cap, ...(o.tidy === false ? { tidy: false } : {}) }
418
+ let level = 0
419
+ const trialTokens = (idx: number) => approxTokens("x".repeat(externalize(session, countingBlobs(), { ...BUDGET_LEVELS[idx]!, ...common }).after))
420
+ const base = externalize(session, countingBlobs(), { ...BUDGET_LEVELS[0]!, ...common, stopAtTokens: target })
421
+ if (base.replaced === 0 && approxTokens("x".repeat(base.after)) <= target) {
422
+ level = 0
423
+ } else {
424
+ for (let i = 0; i < BUDGET_LEVELS.length; i++) {
425
+ level = i + 1
426
+ if (trialTokens(i) <= target) {
427
+ break
428
+ }
429
+ }
430
+ }
431
+ const opts: ExternalizeOpts = { ...BUDGET_LEVELS[Math.max(0, level - 1)]!, ...common, stopAtTokens: target }
432
+ const r = externalize(session, store, opts)
433
+ const tokens = estimateTokens(r.session)
434
+ return { ...r, level, reached: tokens <= target, tokens }
435
+ }
@@ -0,0 +1,70 @@
1
+ // The lookup-rate log: which kinds of tool output get replaced by stubs (at conversion) and which the model then asks back with
2
+ // brain_expand. Each lookup costs a whole extra request (docs/live-savings/), so this is the number that says whether a stub's
3
+ // preview is good enough for that kind of output. Local only: `<home>/.brain/logs/lookups.jsonl` (0600), ids and kinds, never text.
4
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs"
5
+ import { join } from "node:path"
6
+
7
+ export const lookupLog = (home: string) => join(home, ".brain", "logs", "lookups.jsonl")
8
+
9
+ export type LookupEvent =
10
+ | { event: "stubs"; at: string; session: string; byKind: Record<string, number> }
11
+ | { event: "expand"; at: string; id: string; kind: string; how: "grep" | "page" | "window" }
12
+
13
+ export function logLookup(home: string, e: LookupEvent): void {
14
+ try {
15
+ const f = lookupLog(home)
16
+ mkdirSync(join(home, ".brain", "logs"), { recursive: true, mode: 0o700 })
17
+ const fresh = !existsSync(f)
18
+ appendFileSync(f, `${JSON.stringify(e)}\n`, { mode: 0o600 })
19
+ if (fresh) chmodSync(f, 0o600)
20
+ } catch {
21
+ // A log never breaks a conversion or a tool call.
22
+ }
23
+ }
24
+
25
+ export interface LookupRow {
26
+ kind: string
27
+ stubbed: number
28
+ expanded: number
29
+ /** expanded / stubbed (an output expanded twice counts twice). */
30
+ rate: number
31
+ }
32
+
33
+ /** Stubs and expansions per tool kind, most expanded first. */
34
+ export function lookupRates(home: string): LookupRow[] {
35
+ let text = ""
36
+ try {
37
+ text = readFileSync(lookupLog(home), "utf8")
38
+ } catch {
39
+ return []
40
+ }
41
+ const stubbed = new Map<string, number>()
42
+ const expanded = new Map<string, number>()
43
+ for (const line of text.split("\n")) {
44
+ if (!line.trim()) continue
45
+ let e: LookupEvent
46
+ try {
47
+ e = JSON.parse(line)
48
+ } catch {
49
+ continue
50
+ }
51
+ if (e.event === "stubs") for (const [k, n] of Object.entries(e.byKind ?? {})) stubbed.set(k, (stubbed.get(k) ?? 0) + (Number(n) || 0))
52
+ else if (e.event === "expand") expanded.set(e.kind || "unknown", (expanded.get(e.kind || "unknown") ?? 0) + 1)
53
+ }
54
+ const kinds = new Set([...stubbed.keys(), ...expanded.keys()])
55
+ return [...kinds]
56
+ .map((kind) => {
57
+ const s = stubbed.get(kind) ?? 0
58
+ const x = expanded.get(kind) ?? 0
59
+ return { kind, stubbed: s, expanded: x, rate: s ? x / s : x ? Infinity : 0 }
60
+ })
61
+ .sort((a, b) => b.expanded - a.expanded || b.stubbed - a.stubbed)
62
+ }
63
+
64
+ export function formatLookupRates(rows: LookupRow[]): string[] {
65
+ if (!rows.length) return ["no lean conversions or expansions logged yet (`brain session continue --lean` writes them)"]
66
+ const out = ["kind stubbed expanded rate"]
67
+ for (const r of rows) out.push(`${r.kind.padEnd(10)} ${String(r.stubbed).padStart(7)} ${String(r.expanded).padStart(9)} ${Number.isFinite(r.rate) ? `${Math.round(100 * r.rate)}%` : "n/a"}`)
68
+ out.push("", "A kind with a high rate needs a better stub preview (src/memory/preview.ts): each expansion is one more request.")
69
+ return out
70
+ }