@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.
- package/LICENSE +48 -0
- package/README.md +172 -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 +326 -0
- package/src/fsutil.ts +133 -0
- package/src/gateway/connect.ts +845 -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,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
|
+
}
|