@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,270 @@
|
|
|
1
|
+
// The brain's own memory store: Chitta (permission-aware graph + vector memory) at `<home>/.brain/memory/context.db`.
|
|
2
|
+
//
|
|
3
|
+
// Chitta is optional and not a dependency: the SDK is loaded lazily from the first place it is found (`$BRAIN_CHITTA`,
|
|
4
|
+
// next to brain's own install, `~/node_modules/@100xprompt/chitta`, a global bun/npm install, then $BRAIN_CHITTA_DIRS) and
|
|
5
|
+
// `status()` says why when it is not.
|
|
6
|
+
// The person's personal Chitta store (CONTEXT_DB, ~/.local/share/100xprompt) is never opened: the path is always explicit.
|
|
7
|
+
//
|
|
8
|
+
// Project scoping, chosen: ONE CHITTA PRINCIPAL PER PROJECT (`chitta.agent("brain-<hash of project id>")`). Every record is
|
|
9
|
+
// private to its principal, so the engine's own ACL keeps projects apart in search, graph expansion and facts; a memory
|
|
10
|
+
// from project A cannot come back for project B even by a bug in this file. (The alternative, filtering after recall, would
|
|
11
|
+
// leak across projects the day a filter was forgotten, and would let one project's records crowd out another's top-k.)
|
|
12
|
+
// A small side table `brain_items` in the same SQLite file keeps the full text and the kind/source/project of every item:
|
|
13
|
+
// Chitta chunks long records into passages, and recall must hand back whole items, so Chitta only ranks and the table answers.
|
|
14
|
+
import { existsSync, mkdirSync, statSync } from "node:fs"
|
|
15
|
+
import { homedir } from "node:os"
|
|
16
|
+
import { dirname, join } from "node:path"
|
|
17
|
+
import { pathToFileURL } from "node:url"
|
|
18
|
+
import { createHash } from "node:crypto"
|
|
19
|
+
import { openRemoteMemory } from "../org/client.ts"
|
|
20
|
+
import { readOrgConfig } from "../org/config.ts"
|
|
21
|
+
import { redact } from "./redact.ts"
|
|
22
|
+
import { approxTokens, type Memory, type MemoryItem, type MemoryKind, type MemoryStatus, type ProjectKey, type Recalled } from "./types.ts"
|
|
23
|
+
|
|
24
|
+
export interface MemoryCounts {
|
|
25
|
+
total: number
|
|
26
|
+
byKind: Partial<Record<MemoryKind, number>>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** `Memory` plus what the CLI and the MCP server's status need. */
|
|
30
|
+
export interface BrainMemory extends Memory {
|
|
31
|
+
/** Item counts for one project, or the whole store. */
|
|
32
|
+
counts(project?: ProjectKey): Promise<MemoryCounts>
|
|
33
|
+
/** Projects with memories: id, label, items. */
|
|
34
|
+
projects(): Promise<Array<{ id: string; label: string; items: number }>>
|
|
35
|
+
/** Closes the database (long-running servers may keep it open; tests and one-shot commands close it). */
|
|
36
|
+
close(): void
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const memoryDbPath = (home: string): string => join(home, ".brain", "memory", "context.db")
|
|
40
|
+
|
|
41
|
+
const sha = (s: string, n = 12): string => createHash("sha1").update(s).digest("hex").slice(0, n)
|
|
42
|
+
const principalOf = (project: ProjectKey): string => `brain-${sha(project.id)}`
|
|
43
|
+
const recordId = (project: ProjectKey, item: MemoryItem): string => `${sha(project.id)}~${sha(item.source, 8)}~${item.id}`
|
|
44
|
+
|
|
45
|
+
// ---- locating the SDK ---------------------------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/** Where Chitta's SDK entry might be, in order; an explicit `$BRAIN_CHITTA` is the only candidate when it is set. */
|
|
48
|
+
export function chittaCandidates(env = process.env, home = homedir()): string[] {
|
|
49
|
+
const entry = (dir: string): string[] => [join(dir, "dist", "sdk.js"), join(dir, "src", "sdk.ts")]
|
|
50
|
+
const explicit = env.BRAIN_CHITTA
|
|
51
|
+
if (explicit) return /\.(?:js|ts|mjs)$/.test(explicit) ? [explicit] : entry(explicit)
|
|
52
|
+
const dirs = [
|
|
53
|
+
// Beside this brain: installed in the same node_modules (a global bun or npm install, or a project's).
|
|
54
|
+
join(import.meta.dir, "..", "..", "..", "chitta"),
|
|
55
|
+
join(home, "node_modules", "@100xprompt", "chitta"),
|
|
56
|
+
...(env.BUN_INSTALL ? [join(env.BUN_INSTALL, "install", "global", "node_modules", "@100xprompt", "chitta")] : []),
|
|
57
|
+
join(home, ".bun", "install", "global", "node_modules", "@100xprompt", "chitta"),
|
|
58
|
+
"/opt/homebrew/lib/node_modules/@100xprompt/chitta",
|
|
59
|
+
"/usr/local/lib/node_modules/@100xprompt/chitta",
|
|
60
|
+
join(home, ".npm-global", "lib", "node_modules", "@100xprompt", "chitta"),
|
|
61
|
+
// More places, searched last (a Chitta checkout, for example): BRAIN_CHITTA_DIRS, separated like PATH.
|
|
62
|
+
...(env.BRAIN_CHITTA_DIRS ?? "").split(process.platform === "win32" ? ";" : ":").filter(Boolean),
|
|
63
|
+
]
|
|
64
|
+
return dirs.flatMap(entry)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface ChittaLike {
|
|
68
|
+
ctx: { store: { db: SqlDb; close(): void }; deleteRecord(actingUserId: string, recordId: string): void }
|
|
69
|
+
agent(id: string): { userId: string; remember(text: string, opts: Record<string, unknown>): Promise<{ id: string }>; recall(query: string, opts?: { limit?: number }): Promise<Array<{ text: string; score: number; recordId?: string }>> }
|
|
70
|
+
close(): void
|
|
71
|
+
}
|
|
72
|
+
interface SqlDb {
|
|
73
|
+
exec(sql: string): void
|
|
74
|
+
query(sql: string): { all(...p: unknown[]): any[]; get(...p: unknown[]): any; run(...p: unknown[]): unknown }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function loadChittaClass(): Promise<{ Chitta: new (o: Record<string, unknown>) => ChittaLike; from: string } | { why: string }> {
|
|
78
|
+
const tried: string[] = []
|
|
79
|
+
for (const file of chittaCandidates()) {
|
|
80
|
+
if (!existsSync(file)) continue
|
|
81
|
+
try {
|
|
82
|
+
const mod = await import(pathToFileURL(file).href)
|
|
83
|
+
if (typeof mod.Chitta === "function") return { Chitta: mod.Chitta, from: file }
|
|
84
|
+
tried.push(`${file} (no Chitta export)`)
|
|
85
|
+
} catch (e) {
|
|
86
|
+
tried.push(`${file} (${(e as Error).message.split("\n")[0]})`)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { why: tried.length ? `Chitta could not be loaded: ${tried.join("; ")}` : "Chitta is not installed (set BRAIN_CHITTA to its folder, or install @100xprompt/chitta)" }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---- busy handling ------------------------------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
const BUSY = /SQLITE_BUSY|database is locked|database table is locked/i
|
|
95
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
|
96
|
+
|
|
97
|
+
/** Runs `fn`, retrying while another process holds the database (several tools each run their own `brain mcp`). */
|
|
98
|
+
async function whenIdle<T>(fn: () => T | Promise<T>, tries = 12): Promise<T> {
|
|
99
|
+
for (let i = 0; ; i++) {
|
|
100
|
+
try {
|
|
101
|
+
return await fn()
|
|
102
|
+
} catch (e) {
|
|
103
|
+
if (i >= tries || !BUSY.test(String((e as Error)?.message ?? e))) throw e
|
|
104
|
+
await sleep(Math.min(40 * 2 ** Math.min(i, 5), 600) + Math.random() * 40)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---- the store ----------------------------------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
/** The memory the brain uses: the organisation's server when this machine is logged in to one (<home>/.brain/org.json, unless
|
|
112
|
+
* BRAIN_ORG=off), else the local store below, exactly as before. */
|
|
113
|
+
export function openMemory(home: string): BrainMemory {
|
|
114
|
+
if (process.env.BRAIN_ORG !== "off") {
|
|
115
|
+
const cfg = readOrgConfig(home)
|
|
116
|
+
if (cfg) return openRemoteMemory(cfg)
|
|
117
|
+
}
|
|
118
|
+
return openLocalMemory(home)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The local store only: `<home>/.brain/memory/context.db`. */
|
|
122
|
+
export function openLocalMemory(home: string): BrainMemory {
|
|
123
|
+
const path = memoryDbPath(home)
|
|
124
|
+
const hash = process.env.BRAIN_MEMORY_EMBEDDINGS === "hash" || process.env.NODE_ENV === "test"
|
|
125
|
+
let handle: Promise<{ chitta: ChittaLike; db: SqlDb } | { why: string }> | undefined
|
|
126
|
+
|
|
127
|
+
const open = (): Promise<{ chitta: ChittaLike; db: SqlDb } | { why: string }> =>
|
|
128
|
+
(handle ??= (async () => {
|
|
129
|
+
const loaded = await loadChittaClass()
|
|
130
|
+
if ("why" in loaded) return loaded
|
|
131
|
+
try {
|
|
132
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
|
|
133
|
+
const chitta = await whenIdle(() => new loaded.Chitta({ path, embeddings: hash ? "hash" : "auto", rerank: false }))
|
|
134
|
+
const db = chitta.ctx.store.db
|
|
135
|
+
try {
|
|
136
|
+
db.exec("PRAGMA busy_timeout = 5000")
|
|
137
|
+
} catch {}
|
|
138
|
+
await whenIdle(() =>
|
|
139
|
+
db.exec(
|
|
140
|
+
`CREATE TABLE IF NOT EXISTS brain_items (rid TEXT PRIMARY KEY, project TEXT NOT NULL, label TEXT NOT NULL, source TEXT NOT NULL, kind TEXT NOT NULL, name TEXT NOT NULL, text TEXT NOT NULL, at TEXT);
|
|
141
|
+
CREATE INDEX IF NOT EXISTS brain_items_project ON brain_items(project, source);`,
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
return { chitta, db }
|
|
145
|
+
} catch (e) {
|
|
146
|
+
return { why: `memory store could not be opened at ${path}: ${(e as Error).message.split("\n")[0]}` }
|
|
147
|
+
}
|
|
148
|
+
})())
|
|
149
|
+
|
|
150
|
+
const ready = async () => {
|
|
151
|
+
const h = await open()
|
|
152
|
+
return "why" in h ? undefined : h
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
async status(): Promise<MemoryStatus> {
|
|
157
|
+
const h = await open()
|
|
158
|
+
if ("why" in h) return { available: false, why: h.why }
|
|
159
|
+
const items = (h.db.query("SELECT count(*) c FROM brain_items").get() as { c: number }).c
|
|
160
|
+
return { available: true, store: path, items }
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
async ingest(project, items) {
|
|
164
|
+
const h = await ready()
|
|
165
|
+
if (!h) throw new Error("memory is not available")
|
|
166
|
+
const agent = h.chitta.agent(principalOf(project))
|
|
167
|
+
let stored = 0
|
|
168
|
+
for (const item of items) {
|
|
169
|
+
// Everything is redacted on the way in, even when the caller already did: this is the last gate before disk.
|
|
170
|
+
const text = redact(item.text)
|
|
171
|
+
const name = redact(item.name)
|
|
172
|
+
const rid = recordId(project, item)
|
|
173
|
+
await whenIdle(() =>
|
|
174
|
+
agent.remember(text, {
|
|
175
|
+
id: rid,
|
|
176
|
+
name: `${item.kind}: ${name}`,
|
|
177
|
+
entities: item.entities?.map((e) => ({ name: redact(e.name), ...(e.type ? { type: e.type } : {}) })),
|
|
178
|
+
relations: item.relations?.map((r) => ({ from: redact(r.from), to: redact(r.to), type: r.type })),
|
|
179
|
+
procedures: item.procedures?.map((p) => ({ ...(p.trigger ? { trigger: redact(p.trigger) } : {}), action: redact(p.action) })),
|
|
180
|
+
occurredAt: item.occurredAt,
|
|
181
|
+
}),
|
|
182
|
+
)
|
|
183
|
+
await whenIdle(() =>
|
|
184
|
+
h.db
|
|
185
|
+
.query("INSERT OR REPLACE INTO brain_items (rid, project, label, source, kind, name, text, at) VALUES (?,?,?,?,?,?,?,?)")
|
|
186
|
+
.run(rid, project.id, project.label, item.source, item.kind, name, text, item.occurredAt ?? null),
|
|
187
|
+
)
|
|
188
|
+
stored++
|
|
189
|
+
}
|
|
190
|
+
return { stored }
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
async recall(project, query, opts = {}) {
|
|
194
|
+
const h = await ready()
|
|
195
|
+
if (!h || !query.trim()) return []
|
|
196
|
+
const limit = opts.limit ?? 8
|
|
197
|
+
const maxTokens = opts.maxTokens ?? 1500
|
|
198
|
+
const agent = h.chitta.agent(principalOf(project))
|
|
199
|
+
const hits = await whenIdle(() => agent.recall(query, { limit: Math.max(limit * 4, 20) }))
|
|
200
|
+
const seen = new Set<string>()
|
|
201
|
+
const out: Recalled[] = []
|
|
202
|
+
let tokens = 0
|
|
203
|
+
for (const hit of hits) {
|
|
204
|
+
if (!hit.recordId || seen.has(hit.recordId) || !(hit.score > 0)) continue
|
|
205
|
+
seen.add(hit.recordId)
|
|
206
|
+
const row = h.db.query("SELECT kind, name, source, text FROM brain_items WHERE rid = ? AND project = ?").get(hit.recordId, project.id) as
|
|
207
|
+
| { kind: MemoryKind; name: string; source: string; text: string }
|
|
208
|
+
| undefined
|
|
209
|
+
if (!row) continue // not ours, or deleted: never returned
|
|
210
|
+
const cost = approxTokens(row.text)
|
|
211
|
+
if (out.length && tokens + cost > maxTokens) continue
|
|
212
|
+
out.push({ text: redact(row.text), kind: row.kind, name: row.name, score: hit.score, source: row.source })
|
|
213
|
+
tokens += cost
|
|
214
|
+
if (out.length >= limit) break
|
|
215
|
+
}
|
|
216
|
+
return out
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
async forget(project, opts = {}) {
|
|
220
|
+
const h = await ready()
|
|
221
|
+
if (!h) return 0
|
|
222
|
+
const agent = h.chitta.agent(principalOf(project))
|
|
223
|
+
const rows = (opts.source
|
|
224
|
+
? h.db.query("SELECT rid FROM brain_items WHERE project = ? AND source = ?").all(project.id, opts.source)
|
|
225
|
+
: h.db.query("SELECT rid FROM brain_items WHERE project = ?").all(project.id)) as Array<{ rid: string }>
|
|
226
|
+
for (const { rid } of rows) {
|
|
227
|
+
await whenIdle(() => h.chitta.ctx.deleteRecord(agent.userId, rid))
|
|
228
|
+
await whenIdle(() => h.db.query("DELETE FROM brain_items WHERE rid = ?").run(rid))
|
|
229
|
+
}
|
|
230
|
+
return rows.length
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
async counts(project) {
|
|
234
|
+
const h = await ready()
|
|
235
|
+
if (!h) return { total: 0, byKind: {} }
|
|
236
|
+
const rows = (project
|
|
237
|
+
? h.db.query("SELECT kind, count(*) c FROM brain_items WHERE project = ? GROUP BY kind").all(project.id)
|
|
238
|
+
: h.db.query("SELECT kind, count(*) c FROM brain_items GROUP BY kind").all()) as Array<{ kind: MemoryKind; c: number }>
|
|
239
|
+
const byKind: MemoryCounts["byKind"] = {}
|
|
240
|
+
let total = 0
|
|
241
|
+
for (const r of rows) {
|
|
242
|
+
byKind[r.kind] = r.c
|
|
243
|
+
total += r.c
|
|
244
|
+
}
|
|
245
|
+
return { total, byKind }
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
async projects() {
|
|
249
|
+
const h = await ready()
|
|
250
|
+
if (!h) return []
|
|
251
|
+
return (h.db.query("SELECT project id, max(label) label, count(*) items FROM brain_items GROUP BY project ORDER BY items DESC").all() as Array<{ id: string; label: string; items: number }>)
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
close() {
|
|
255
|
+
if (!handle) return
|
|
256
|
+
void handle.then((h) => {
|
|
257
|
+
if (!("why" in h)) try { h.chitta.close() } catch {}
|
|
258
|
+
})
|
|
259
|
+
},
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The size of the store on disk in bytes (0 when it does not exist), for status output. */
|
|
264
|
+
export function storeBytes(home: string): number {
|
|
265
|
+
try {
|
|
266
|
+
return statSync(memoryDbPath(home)).size
|
|
267
|
+
} catch {
|
|
268
|
+
return 0
|
|
269
|
+
}
|
|
270
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Keeps a project's memory up to date by itself: the sessions of this project that memory has not seen (or that grew since)
|
|
2
|
+
// are distilled and ingested. It runs in the background when a session starts in any tool (the session-start hook starts it),
|
|
3
|
+
// and by hand as `brain memory sync`. Idempotent: item ids are stable and a ledger records what was done, so running it
|
|
4
|
+
// twice does nothing the second time. Bounded: a few sessions and a few seconds per run; the rest waits for the next run.
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
|
|
6
|
+
import { join } from "node:path"
|
|
7
|
+
import { gitInfo } from "../fsutil.ts"
|
|
8
|
+
import type { Session } from "../session/types.ts"
|
|
9
|
+
import { distill } from "./distill.ts"
|
|
10
|
+
import { projectKey } from "./project.ts"
|
|
11
|
+
import { openMemory } from "./store.ts"
|
|
12
|
+
import type { Memory } from "./types.ts"
|
|
13
|
+
|
|
14
|
+
export interface SessionSource {
|
|
15
|
+
/** Portable id (`<tool>:<id>`). */
|
|
16
|
+
id: string
|
|
17
|
+
cwd?: string
|
|
18
|
+
updatedAt?: string
|
|
19
|
+
read(): Promise<Session>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SyncOptions {
|
|
23
|
+
home: string
|
|
24
|
+
/** Only sessions of the project this folder belongs to (default: every project). */
|
|
25
|
+
cwd?: string
|
|
26
|
+
/** At most this many sessions per run. Default 8. */
|
|
27
|
+
limit?: number
|
|
28
|
+
/** Stop starting new sessions after this long. Default 8,000 ms. */
|
|
29
|
+
maxMs?: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SyncResult {
|
|
33
|
+
ingested: number
|
|
34
|
+
items: number
|
|
35
|
+
/** Sessions already in the ledger at their current time. */
|
|
36
|
+
upToDate: number
|
|
37
|
+
/** Sessions left for a later run (over the limit or the time budget). */
|
|
38
|
+
deferred: number
|
|
39
|
+
unavailable?: string
|
|
40
|
+
/** Another sync holds the lock: nothing was done. */
|
|
41
|
+
busy?: boolean
|
|
42
|
+
errors: string[]
|
|
43
|
+
ms: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type Ledger = Record<string, { u: string; n: number }>
|
|
47
|
+
|
|
48
|
+
const dirOf = (home: string) => join(home, ".brain", "memory")
|
|
49
|
+
const ledgerFile = (home: string) => join(dirOf(home), "ledger.json")
|
|
50
|
+
const lockDir = (home: string) => join(dirOf(home), "sync.lock")
|
|
51
|
+
/** A lock older than this is left over from a run that died. */
|
|
52
|
+
const STALE_LOCK_MS = 2 * 60_000
|
|
53
|
+
|
|
54
|
+
function readLedger(home: string): Ledger {
|
|
55
|
+
try {
|
|
56
|
+
const j = JSON.parse(readFileSync(ledgerFile(home), "utf8"))
|
|
57
|
+
return j && typeof j === "object" && !Array.isArray(j) ? j : {}
|
|
58
|
+
} catch {
|
|
59
|
+
return {}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function writeLedger(home: string, l: Ledger) {
|
|
64
|
+
mkdirSync(dirOf(home), { recursive: true })
|
|
65
|
+
const tmp = `${ledgerFile(home)}.tmp-${process.pid}`
|
|
66
|
+
writeFileSync(tmp, JSON.stringify(l))
|
|
67
|
+
renameSync(tmp, ledgerFile(home))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Takes the lock (an atomic mkdir), clearing one left by a dead run. False when another run holds it. */
|
|
71
|
+
function lock(home: string): boolean {
|
|
72
|
+
mkdirSync(dirOf(home), { recursive: true })
|
|
73
|
+
const dir = lockDir(home)
|
|
74
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
75
|
+
try {
|
|
76
|
+
mkdirSync(dir)
|
|
77
|
+
return true
|
|
78
|
+
} catch {
|
|
79
|
+
try {
|
|
80
|
+
if (Date.now() - statSync(dir).mtimeMs < STALE_LOCK_MS) return false
|
|
81
|
+
rmSync(dir, { recursive: true, force: true })
|
|
82
|
+
} catch {
|
|
83
|
+
return false
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const unlock = (home: string) => rmSync(lockDir(home), { recursive: true, force: true })
|
|
91
|
+
|
|
92
|
+
/** Whether a session that ran in `cwd` belongs to the project of `here` (the same folder, one inside it, or one above it in one repository). */
|
|
93
|
+
function inProject(cwd: string | undefined, here: string, home: string): boolean {
|
|
94
|
+
if (!cwd) return false
|
|
95
|
+
if (cwd === here || cwd.startsWith(`${here}/`)) return true
|
|
96
|
+
if (!here.startsWith(`${cwd}/`) || cwd === home || home.startsWith(`${cwd}/`)) return false
|
|
97
|
+
const a = gitInfo(cwd).gitRemote
|
|
98
|
+
return !!a && a === gitInfo(here).gitRemote
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function defaultSessions(home: string): Promise<SessionSource[]> {
|
|
102
|
+
const { READERS } = await import("../adapters/index.ts")
|
|
103
|
+
const lists = await Promise.all(READERS.map((r) => r.list(home).then((refs) => refs.map((ref) => ({ r, ref }))).catch(() => [])))
|
|
104
|
+
return lists
|
|
105
|
+
.flat()
|
|
106
|
+
.map(({ r, ref }) => ({ id: `${ref.tool}:${ref.sourceId}`, ...(ref.cwd ? { cwd: ref.cwd } : {}), ...(ref.updatedAt ? { updatedAt: ref.updatedAt } : {}), read: () => r.read(ref, home) }))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function syncMemory(o: SyncOptions, deps: { memory?: Memory; sessions?: () => Promise<SessionSource[]>; now?: () => number } = {}): Promise<SyncResult> {
|
|
110
|
+
const now = deps.now ?? Date.now
|
|
111
|
+
const t0 = now()
|
|
112
|
+
const result: SyncResult = { ingested: 0, items: 0, upToDate: 0, deferred: 0, errors: [], ms: 0 }
|
|
113
|
+
const done = (): SyncResult => ({ ...result, ms: now() - t0 })
|
|
114
|
+
|
|
115
|
+
const memory = deps.memory ?? openMemory(o.home)
|
|
116
|
+
const status = await memory.status().catch((e: Error) => ({ available: false as const, why: e.message }))
|
|
117
|
+
if (!status.available) return { ...done(), unavailable: status.why ?? "unavailable" }
|
|
118
|
+
if (!lock(o.home)) return { ...done(), busy: true }
|
|
119
|
+
try {
|
|
120
|
+
const here = o.cwd
|
|
121
|
+
const all = (await (deps.sessions ?? (() => defaultSessions(o.home)))()).filter((s) => !here || inProject(s.cwd, here, o.home))
|
|
122
|
+
all.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""))
|
|
123
|
+
const ledger = readLedger(o.home)
|
|
124
|
+
const todo = all.filter((s) => (ledger[s.id]?.u ?? "") !== (s.updatedAt ?? "none"))
|
|
125
|
+
result.upToDate = all.length - todo.length
|
|
126
|
+
const limit = o.limit ?? 8
|
|
127
|
+
const budget = o.maxMs ?? 8_000
|
|
128
|
+
for (const [i, s] of todo.entries()) {
|
|
129
|
+
if (result.ingested >= limit || now() - t0 > budget) {
|
|
130
|
+
result.deferred = todo.length - i
|
|
131
|
+
break
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
const session = await s.read()
|
|
135
|
+
const items = distill(session)
|
|
136
|
+
if (items.length) {
|
|
137
|
+
const key = projectKey({ ...session.header.project, ...(session.header.project.cwd ? gitInfo(session.header.project.cwd) : {}) })
|
|
138
|
+
await memory.ingest(key, items)
|
|
139
|
+
}
|
|
140
|
+
ledger[s.id] = { u: s.updatedAt ?? "none", n: items.length }
|
|
141
|
+
result.ingested++
|
|
142
|
+
result.items += items.length
|
|
143
|
+
} catch (e) {
|
|
144
|
+
// One unreadable session must not stop the rest; it is retried next time (it is not in the ledger).
|
|
145
|
+
result.errors.push(`${s.id}: ${(e as Error).message}`)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (result.ingested) writeLedger(o.home, ledger)
|
|
149
|
+
} finally {
|
|
150
|
+
unlock(o.home)
|
|
151
|
+
}
|
|
152
|
+
return done()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Starts a sync in a separate process and returns at once: a hook must never wait for it. */
|
|
156
|
+
export function syncInBackground(home: string, cwd: string, cli: string) {
|
|
157
|
+
if (process.env.BRAIN_NO_MEMORY === "1") return
|
|
158
|
+
try {
|
|
159
|
+
// Only where memory has been set up on this machine: a person who never used it does not get a store created by a hook.
|
|
160
|
+
if (!existsSync(join(home, ".brain", "memory"))) return
|
|
161
|
+
const child = Bun.spawn([process.execPath, cli, "memory", "sync", "--here", "--quiet"], { cwd, stdin: "ignore", stdout: "ignore", stderr: "ignore", env: { ...process.env, BRAIN_HOME: home } })
|
|
162
|
+
child.unref()
|
|
163
|
+
} catch {
|
|
164
|
+
// Best effort.
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// tidy: lossless-in-meaning cleaning of a tool result that STAYS in the conversation (the original text of anything replaced
|
|
2
|
+
// by a digest is kept byte for byte in the blob store; this step never sees it). Pure, deterministic, no model, never grows a text.
|
|
3
|
+
//
|
|
4
|
+
// What it does (and what it deliberately does not):
|
|
5
|
+
// - strips ANSI escape sequences (CSI and OSC);
|
|
6
|
+
// - resolves carriage-return progress lines the way a terminal shows them (the last state of a `\r`-overwritten line);
|
|
7
|
+
// - turns CRLF into LF, trims trailing spaces/tabs per line, collapses 3+ blank lines to one blank line;
|
|
8
|
+
// - collapses 3+ identical consecutive lines into the line plus `[previous line repeated N more times]`, only when that is shorter;
|
|
9
|
+
// - minifies a text that is ONE JSON document, or JSON lines that each parse, by removing only the whitespace outside strings:
|
|
10
|
+
// every lexeme (1.0, 1e2, big integers, escapes, key order, duplicate keys) stays byte for byte, and the result is checked to
|
|
11
|
+
// parse to the same value, else the text is left alone.
|
|
12
|
+
// NOT done: stripping a `cat -n` style line-number prefix. The numbers carry meaning (a model cites them, an edit tool is told them)
|
|
13
|
+
// and the strip cannot be made reversible, so it is skipped by design.
|
|
14
|
+
import { STUB_PREFIX } from "./types.ts"
|
|
15
|
+
import type { Session, Turn } from "../session/types.ts"
|
|
16
|
+
|
|
17
|
+
export interface Tidied {
|
|
18
|
+
text: string
|
|
19
|
+
/** Characters saved (never negative). */
|
|
20
|
+
saved: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Longer texts are left alone: bounded time matters more than the last few percent. */
|
|
24
|
+
const MAX_CHARS = 10_000_000
|
|
25
|
+
const MAX_JSON_VERIFY = 5_000_000
|
|
26
|
+
|
|
27
|
+
// CSI: ESC [ params intermediates final. OSC: ESC ] ... BEL or ESC \. Neither class can contain ESC, so scans are linear.
|
|
28
|
+
const ANSI = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g
|
|
29
|
+
|
|
30
|
+
function trimEnd(line: string): string {
|
|
31
|
+
let e = line.length
|
|
32
|
+
while (e > 0) {
|
|
33
|
+
const c = line.charCodeAt(e - 1)
|
|
34
|
+
if (c === 32 || c === 9) e--
|
|
35
|
+
else break
|
|
36
|
+
}
|
|
37
|
+
return e === line.length ? line : line.slice(0, e)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The state of a line a terminal would show after all its `\r`-separated writes (later writes overwrite from column 0). */
|
|
41
|
+
function overlay(line: string): string {
|
|
42
|
+
let cur: string[] = []
|
|
43
|
+
for (const seg of line.split("\r")) {
|
|
44
|
+
if (!seg) continue
|
|
45
|
+
const s = Array.from(seg)
|
|
46
|
+
cur = s.length >= cur.length ? s : [...s, ...cur.slice(s.length)]
|
|
47
|
+
}
|
|
48
|
+
return cur.join("")
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const isWs = (c: number) => c === 32 || c === 9 || c === 10 || c === 13
|
|
52
|
+
|
|
53
|
+
/** Removes the whitespace outside strings from text that is already known to be valid JSON. Linear, no recursion. */
|
|
54
|
+
function stripJsonWhitespace(s: string): string {
|
|
55
|
+
const parts: string[] = []
|
|
56
|
+
let start = 0
|
|
57
|
+
let inStr = false
|
|
58
|
+
for (let i = 0; i < s.length; i++) {
|
|
59
|
+
const c = s.charCodeAt(i)
|
|
60
|
+
if (inStr) {
|
|
61
|
+
if (c === 92) i++
|
|
62
|
+
else if (c === 34) inStr = false
|
|
63
|
+
} else if (c === 34) inStr = true
|
|
64
|
+
else if (isWs(c)) {
|
|
65
|
+
if (i > start) parts.push(s.slice(start, i))
|
|
66
|
+
start = i + 1
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (start < s.length) parts.push(s.slice(start))
|
|
70
|
+
return parts.join("")
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* For text that is one JSON container document: its minified form (or the trimmed text itself when nothing can be removed or the
|
|
75
|
+
* minified form cannot be proven to parse to the same value). undefined when the text is not one JSON container document.
|
|
76
|
+
*/
|
|
77
|
+
function minifyDoc(s: string): string | undefined {
|
|
78
|
+
const t = s.trim()
|
|
79
|
+
if (t.length < 2) return undefined
|
|
80
|
+
const a = t[0]
|
|
81
|
+
const z = t[t.length - 1]
|
|
82
|
+
if (!((a === "{" && z === "}") || (a === "[" && z === "]"))) return undefined
|
|
83
|
+
let v: unknown
|
|
84
|
+
try {
|
|
85
|
+
v = JSON.parse(t)
|
|
86
|
+
} catch {
|
|
87
|
+
return undefined
|
|
88
|
+
}
|
|
89
|
+
const min = stripJsonWhitespace(t)
|
|
90
|
+
if (min.length >= t.length) return t
|
|
91
|
+
try {
|
|
92
|
+
if (t.length <= MAX_JSON_VERIFY && JSON.stringify(JSON.parse(min)) !== JSON.stringify(v)) return t
|
|
93
|
+
} catch {
|
|
94
|
+
return t
|
|
95
|
+
}
|
|
96
|
+
return min
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Minified JSON text (possibly unchanged) when the text is one JSON document or JSON lines that all parse; else undefined. */
|
|
100
|
+
function minifyJson(s: string): string | undefined {
|
|
101
|
+
const whole = minifyDoc(s)
|
|
102
|
+
if (whole !== undefined) return whole
|
|
103
|
+
const out: string[] = []
|
|
104
|
+
let docs = 0
|
|
105
|
+
for (const line of s.split("\n")) {
|
|
106
|
+
if (!line.trim()) {
|
|
107
|
+
out.push("")
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
const m = minifyDoc(line)
|
|
111
|
+
if (m === undefined) return undefined
|
|
112
|
+
out.push(m)
|
|
113
|
+
docs++
|
|
114
|
+
}
|
|
115
|
+
return docs >= 2 ? out.join("\n") : undefined
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function markerFor(n: number): string {
|
|
119
|
+
return `[previous line repeated ${n} more times]`
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function collapseLines(lines: string[]): string[] {
|
|
123
|
+
// 1. blank runs of 3+ become one blank line
|
|
124
|
+
const a: string[] = []
|
|
125
|
+
for (let i = 0; i < lines.length; ) {
|
|
126
|
+
if (lines[i] === "") {
|
|
127
|
+
let j = i
|
|
128
|
+
while (j < lines.length && lines[j] === "") j++
|
|
129
|
+
const n = j - i
|
|
130
|
+
if (n >= 3) a.push("")
|
|
131
|
+
else for (let k = 0; k < n; k++) a.push("")
|
|
132
|
+
i = j
|
|
133
|
+
} else a.push(lines[i]!), i++
|
|
134
|
+
}
|
|
135
|
+
// 2. runs of 3+ identical lines become the line plus a marker, only when shorter
|
|
136
|
+
const out: string[] = []
|
|
137
|
+
for (let i = 0; i < a.length; ) {
|
|
138
|
+
let j = i + 1
|
|
139
|
+
while (j < a.length && a[j] === a[i]) j++
|
|
140
|
+
const n = j - i
|
|
141
|
+
if (n >= 3 && a[i] !== "") {
|
|
142
|
+
const marker = markerFor(n - 1)
|
|
143
|
+
if (marker.length < (n - 1) * (a[i]!.length + 1)) {
|
|
144
|
+
out.push(a[i]!, marker)
|
|
145
|
+
i = j
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
for (let k = i; k < j; k++) out.push(a[k]!)
|
|
150
|
+
i = j
|
|
151
|
+
}
|
|
152
|
+
return out
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function pass(text: string): string {
|
|
156
|
+
let s = text
|
|
157
|
+
if (s.includes("\x1b")) s = s.replace(ANSI, "")
|
|
158
|
+
if (s.includes("\r")) {
|
|
159
|
+
s = s.replace(/\r\n/g, "\n")
|
|
160
|
+
if (s.includes("\r")) s = s.split("\n").map((l) => (l.includes("\r") ? overlay(l) : l)).join("\n")
|
|
161
|
+
}
|
|
162
|
+
const lines = s.split("\n").map(trimEnd)
|
|
163
|
+
s = lines.join("\n")
|
|
164
|
+
const json = minifyJson(s)
|
|
165
|
+
if (json !== undefined) return json
|
|
166
|
+
return collapseLines(lines).join("\n")
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Tidy one tool result's text. Never longer than the input; unchanged when the text is a brain stub, holds one, or looks binary. */
|
|
170
|
+
export function tidyOutput(text: string): Tidied {
|
|
171
|
+
if (typeof text !== "string" || text.length < 4 || text.length > MAX_CHARS) return { text, saved: 0 }
|
|
172
|
+
if (text.includes(STUB_PREFIX) || text.includes("\0")) return { text, saved: 0 }
|
|
173
|
+
let cur = text
|
|
174
|
+
for (let i = 0; i < 6; i++) {
|
|
175
|
+
const next = pass(cur)
|
|
176
|
+
if (next === cur || next.length > cur.length) break
|
|
177
|
+
cur = next
|
|
178
|
+
}
|
|
179
|
+
return cur.length < text.length ? { text: cur, saved: text.length - cur.length } : { text, saved: 0 }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Tidies every tool result of a session (the input is not modified; untouched turns are shared). `tidied` is characters saved. */
|
|
183
|
+
export function tidySession(session: Session): { session: Session; tidied: number } {
|
|
184
|
+
let tidied = 0
|
|
185
|
+
const turns = session.turns.map((t): Turn => {
|
|
186
|
+
if (t.kind !== "tool_result") return t
|
|
187
|
+
const r = tidyOutput(t.output)
|
|
188
|
+
if (!r.saved) return t
|
|
189
|
+
tidied += r.saved
|
|
190
|
+
return { ...t, output: r.text }
|
|
191
|
+
})
|
|
192
|
+
return tidied ? { session: { ...session, header: { ...session.header }, turns }, tidied } : { session, tidied: 0 }
|
|
193
|
+
}
|