@100xprompt/brain 0.1.3 → 0.1.5

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/src/hooks.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import { CLI_PATH, selfArgs, stableBun } from "./self.ts"
2
- import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs"
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, statSync, unlinkSync } from "node:fs"
3
3
  import { dirname, join } from "node:path"
4
4
  import { toolLabel } from "./brief.ts"
5
- import { gitInfo, isDir } from "./fsutil.ts"
5
+ import { gitInfo, isDir, writeAtomic } from "./fsutil.ts"
6
6
  import { findHandoff, type Handoff, markConsumed } from "./handoff.ts"
7
7
  import { syncInBackground } from "./memory/sync.ts"
8
+ import { withBrainLock } from "./lock.ts"
8
9
 
9
10
  // Session-start hooks: how a pending handoff (handoff.ts) reaches the model in the target tool without the
10
11
  // person pasting anything. Each tool runs `<bun> brain/src/cli.ts session hook session-start --tool <tool>`
@@ -193,19 +194,15 @@ function save(path: string, config: Json, indent: string) {
193
194
  const real = existsSync(path) ? realpathSync(path) : path
194
195
  mkdirSync(dirname(real), { recursive: true })
195
196
  if (existsSync(real) && !existsSync(backupOf(path))) copyFileSync(real, backupOf(path))
196
- const tmp = `${real}.brain-tmp`
197
197
  let mode: number | undefined
198
198
  try {
199
199
  mode = statSync(real).mode & 0o777
200
200
  } catch {
201
201
  // A new file keeps the default mode.
202
202
  }
203
- // Create the copy with the file's own mode: Claude Code's settings are 0600 and can hold API keys, so they must
204
- // not sit readable by everyone, even for a moment. A copy left by an interrupted run would keep its old mode.
205
- rmSync(tmp, { force: true })
206
- writeFileSync(tmp, `${JSON.stringify(config, null, indent)}\n`, mode === undefined ? {} : { mode })
207
- if (mode !== undefined) chmodSync(tmp, mode)
208
- renameSync(tmp, real)
203
+ // The copy is created with the file's own mode: Claude Code's settings are 0600 and can hold API keys, so they must
204
+ // not sit readable by everyone, even for a moment.
205
+ writeAtomic(real, `${JSON.stringify(config, null, indent)}\n`, mode)
209
206
  }
210
207
 
211
208
  interface Pos {
@@ -256,6 +253,12 @@ export function hookInstalled(tool: string, home: string): boolean {
256
253
  return Array.isArray(list) && positions(list, spec, id).length > 0 && !spec.off?.(loaded.config)
257
254
  }
258
255
 
256
+ /** Whether `tool` has a session-start hook brain can install, and has run here (its folder exists). */
257
+ export function hookToolHere(tool: string, home: string): boolean {
258
+ const spec = SPECS[toolId(tool)]
259
+ return !!spec && isDir(join(home, spec.dir))
260
+ }
261
+
259
262
  export interface HookChange {
260
263
  tool: string
261
264
  /** The config file changed (or that would be). */
@@ -287,6 +290,10 @@ function prepare(tool: string, home: string): { id: string; spec: Spec; path: st
287
290
  * the hook command (`<bun> <cli.ts>`); the rest stays, since that is how uninstall finds the entry.
288
291
  */
289
292
  export async function installHooks(tools: string[], home: string, opts: { dryRun?: boolean; command?: string } = {}): Promise<HookChange[]> {
293
+ return opts.dryRun ? installLocked(tools, home, opts) : withBrainLock(home, () => installLocked(tools, home, opts), { what: "installing session hooks" })
294
+ }
295
+
296
+ async function installLocked(tools: string[], home: string, opts: { dryRun?: boolean; command?: string }): Promise<HookChange[]> {
290
297
  const out: HookChange[] = []
291
298
  for (const tool of [...new Set(tools)]) {
292
299
  const p = prepare(tool, home)
@@ -333,6 +340,10 @@ export async function installHooks(tools: string[], home: string, opts: { dryRun
333
340
 
334
341
  /** Removes exactly what installHooks added. */
335
342
  export async function uninstallHooks(tools: string[], home: string, opts: { dryRun?: boolean } = {}): Promise<HookChange[]> {
343
+ return opts.dryRun ? uninstallLocked(tools, home, opts) : withBrainLock(home, () => uninstallLocked(tools, home, opts), { what: "removing session hooks" })
344
+ }
345
+
346
+ async function uninstallLocked(tools: string[], home: string, opts: { dryRun?: boolean }): Promise<HookChange[]> {
336
347
  const out: HookChange[] = []
337
348
  for (const tool of [...new Set(tools)]) {
338
349
  const p = prepare(tool, home)
package/src/lock.ts ADDED
@@ -0,0 +1,272 @@
1
+ // The brain lock: one brain at a time changes the files brain manages (a coding tool's config, brain's own settings).
2
+ // Two brains run at once more often than it seems: a command in the terminal and, later, the background agent; two
3
+ // terminals; a tool starting `brain mcp serve` while the person runs `brain gateway connect`. Each of them reads a file,
4
+ // changes it and writes it back, so without a lock one could silently undo the other's change.
5
+ //
6
+ // - It waits: a brain that finds it taken waits its turn (up to 30 s, BRAIN_LOCK_TIMEOUT_MS), then says who holds it.
7
+ // - A lock never exists without its owner: the folder is made with owner.json inside, then renamed into place.
8
+ // - It is cleared only when its holder is gone: the holder's process id and start time are in owner.json, and a lock
9
+ // whose process has ended (or whose id now belongs to a process that started at another time) is removed, under a
10
+ // short second lock, after checking again that it is still that same lock. A slow holder is never pushed aside.
11
+ // - It is re-entrant within one process: a command that holds it can call code that takes it again. Two unrelated async
12
+ // flows in ONE process would share it: nothing in brain runs those yet (a background agent must keep one at a time).
13
+ // - Network checks happen before taking it; nothing slow runs while it is held.
14
+ // - Where <home>/.brain cannot be written (a read-only or root-owned folder), brain goes on without it, as it did before
15
+ // there was a lock: there is nothing to coordinate with there.
16
+ // Never taken by the session-start hook or by `brain mcp serve`: those only read.
17
+ import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
18
+ import { join } from "node:path"
19
+
20
+ export const lockPath = (home: string) => join(home, ".brain", "lock")
21
+ const ownerPath = (home: string) => join(lockPath(home), "owner.json")
22
+
23
+ export interface LockOwner {
24
+ pid: number
25
+ /** When the holding process started (epoch ms, as `ps` sees it), to tell it from a newer process that got the same id. */
26
+ startedAt: number
27
+ /** When it took the lock. */
28
+ at: string
29
+ /** What it was doing, for the message a waiting brain prints. */
30
+ what?: string
31
+ }
32
+
33
+ /** Thrown when the lock stays taken longer than the wait allows. */
34
+ export class BrainBusy extends Error {
35
+ constructor(
36
+ public owner: LockOwner | undefined,
37
+ waitedMs: number,
38
+ ) {
39
+ super(
40
+ `another brain${owner ? ` (process ${owner.pid}${owner.what ? `, ${owner.what}` : ""})` : ""} has been changing settings for more than ${waitedMs < 10_000 ? (waitedMs / 1000).toFixed(1) : Math.round(waitedMs / 1000)} s; try again in a moment`,
41
+ )
42
+ this.name = "BrainBusy"
43
+ }
44
+ }
45
+
46
+ /** A lock folder without an owner file this old is not a lock brain made (or one from before owner files came with it). */
47
+ const ORPHAN_MS = 10_000
48
+ /** Process start times are compared with this much slack (ps reports whole seconds). */
49
+ const START_SLACK_MS = 5_000
50
+ /** A holder found alive is not looked up again for this long (each look runs `ps`). */
51
+ const ALIVE_RECHECK_MS = 1_000
52
+
53
+ const depth = new Map<string, number>()
54
+ /** Homes this process goes on in without a lock (their .brain cannot be written). */
55
+ const unlocked = new Set<string>()
56
+ let seenAlive: { key: string; at: number } | undefined
57
+
58
+ const code = (e: unknown) => (e as NodeJS.ErrnoException)?.code
59
+ const unwritable = (e: unknown) => ["EACCES", "EPERM", "EROFS"].includes(code(e) ?? "")
60
+ const rand = () => Math.random().toString(36).slice(2, 10)
61
+
62
+ export function lockTimeout(env: Record<string, string | undefined> = process.env): number {
63
+ const n = Number(env.BRAIN_LOCK_TIMEOUT_MS)
64
+ return env.BRAIN_LOCK_TIMEOUT_MS && Number.isFinite(n) && n >= 0 ? n : 30_000
65
+ }
66
+
67
+ /** `ps` elapsed time ([[dd-]hh:]mm:ss) in seconds. */
68
+ export function elapsedSeconds(etime: string): number | undefined {
69
+ const m = /^\s*(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)\s*$/.exec(etime)
70
+ if (!m) return undefined
71
+ return Number(m[1] ?? 0) * 86_400 + Number(m[2] ?? 0) * 3_600 + Number(m[3]) * 60 + Number(m[4])
72
+ }
73
+
74
+ /**
75
+ * When a running process started (epoch ms), or undefined when that cannot be told here. From how long it has run
76
+ * (`ps -o etime=`, [[dd-]hh:]mm:ss), not from its start date, which ps prints in a time zone this process may not share.
77
+ */
78
+ export function processStart(pid: number, now = Date.now()): number | undefined {
79
+ if (process.platform === "win32") return undefined
80
+ try {
81
+ const p = Bun.spawnSync(["ps", "-o", "etime=", "-p", String(pid)], { stdout: "pipe", stderr: "ignore", timeout: 2_000, env: { ...process.env, LC_ALL: "C" } })
82
+ const s = elapsedSeconds(p.stdout.toString())
83
+ return s === undefined ? undefined : now - s * 1000
84
+ } catch {
85
+ return undefined
86
+ }
87
+ }
88
+
89
+ /**
90
+ * This process's start as a waiting brain will see it: measured the same way (`ps`), so a process that started well before
91
+ * Bun did (a wrapper that runs `exec brain` later) is not mistaken for a newer one. Measured once, when first needed.
92
+ */
93
+ let started: number | undefined
94
+ const myStart = () => (started ??= processStart(process.pid) ?? Math.round(performance.timeOrigin))
95
+
96
+ /** This process's start, as holderGone() judges this process (what a loop records to be judged alive by itself too). */
97
+ export const ownStart = () => myStart()
98
+
99
+ function readOwner(home: string): LockOwner | undefined {
100
+ try {
101
+ const o = JSON.parse(readFileSync(ownerPath(home), "utf8"))
102
+ return Number.isInteger(o?.pid) && Number.isFinite(o?.startedAt) ? (o as LockOwner) : undefined
103
+ } catch {
104
+ return undefined
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Whether the lock's holder has ended: its process is gone, or its id now belongs to a process that started at another
110
+ * time. A process of another user (EPERM) is not taken as alive by that alone: its start time is still compared.
111
+ */
112
+ export function holderGone(o: LockOwner): boolean {
113
+ if (o.pid === process.pid) return o.startedAt !== myStart()
114
+ try {
115
+ process.kill(o.pid, 0)
116
+ } catch (e) {
117
+ if (code(e) === "ESRCH") return true
118
+ if (code(e) !== "EPERM") return false
119
+ }
120
+ const s = processStart(o.pid)
121
+ return s !== undefined && Math.abs(s - o.startedAt) > START_SLACK_MS
122
+ }
123
+
124
+ /** Takes the lock if it is free: a folder holding owner.json is made aside, then renamed into place in one step. */
125
+ function tryTake(home: string, what: string | undefined): "taken" | "busy" | "unavailable" {
126
+ const tmp = `${lockPath(home)}.new-${process.pid}-${rand()}`
127
+ try {
128
+ mkdirSync(join(home, ".brain"), { recursive: true, mode: 0o700 })
129
+ mkdirSync(tmp, { mode: 0o700 })
130
+ const owner: LockOwner = { pid: process.pid, startedAt: myStart(), at: new Date().toISOString(), ...(what ? { what } : {}) }
131
+ writeFileSync(join(tmp, "owner.json"), JSON.stringify(owner), { mode: 0o600 })
132
+ } catch (e) {
133
+ rmSync(tmp, { recursive: true, force: true })
134
+ if (unwritable(e)) return "unavailable"
135
+ throw e
136
+ }
137
+ try {
138
+ renameSync(tmp, lockPath(home))
139
+ return "taken"
140
+ } catch (e) {
141
+ rmSync(tmp, { recursive: true, force: true })
142
+ // ENOTEMPTY / EEXIST: another brain's lock is there. ENOTDIR: something else is in its place.
143
+ if (["ENOTEMPTY", "EEXIST", "ENOTDIR", "EISDIR"].includes(code(e) ?? "")) return "busy"
144
+ if (unwritable(e)) return "unavailable"
145
+ throw e
146
+ }
147
+ }
148
+
149
+ /** Removes the lock folder in one step (moved aside first, so no other brain ever sees it half removed). */
150
+ function removeLock(home: string) {
151
+ const aside = `${lockPath(home)}.gone-${process.pid}-${rand()}`
152
+ try {
153
+ renameSync(lockPath(home), aside)
154
+ } catch (e) {
155
+ if (code(e) === "ENOENT") return
156
+ throw e
157
+ }
158
+ rmSync(aside, { recursive: true, force: true })
159
+ }
160
+
161
+ /**
162
+ * Clears a lock whose holder has ended, and says whether it did. Done under a short second lock (lock.break), after reading
163
+ * the owner again: only that same abandoned lock is removed, never one another brain took meanwhile.
164
+ */
165
+ function clearIfAbandoned(home: string): boolean {
166
+ const o = readOwner(home)
167
+ let mtime: number | undefined
168
+ if (o) {
169
+ const key = `${home}\0${o.pid}\0${o.startedAt}`
170
+ if (seenAlive?.key === key && Date.now() - seenAlive.at < ALIVE_RECHECK_MS) return false
171
+ if (!holderGone(o)) {
172
+ seenAlive = { key, at: Date.now() }
173
+ return false
174
+ }
175
+ } else {
176
+ try {
177
+ mtime = statSync(lockPath(home)).mtimeMs
178
+ } catch (e) {
179
+ if (code(e) === "ENOENT") return true
180
+ throw e
181
+ }
182
+ if (Date.now() - mtime < ORPHAN_MS) return false
183
+ }
184
+ const brk = `${lockPath(home)}.break`
185
+ try {
186
+ mkdirSync(brk)
187
+ } catch (e) {
188
+ if (code(e) !== "EEXIST") throw e
189
+ // Another brain is clearing it; one that died doing so leaves an old helper behind, which goes.
190
+ try {
191
+ if (Date.now() - statSync(brk).mtimeMs > ORPHAN_MS) rmSync(brk, { recursive: true, force: true })
192
+ } catch {}
193
+ return false
194
+ }
195
+ try {
196
+ const again = readOwner(home)
197
+ const same = o ? !!again && again.pid === o.pid && again.startedAt === o.startedAt : !again && statSync(lockPath(home), { throwIfNoEntry: false })?.mtimeMs === mtime
198
+ if (!same) return false
199
+ removeLock(home)
200
+ return true
201
+ } catch (e) {
202
+ if (code(e) === "ENOENT") return true
203
+ throw e
204
+ } finally {
205
+ rmSync(brk, { recursive: true, force: true })
206
+ }
207
+ }
208
+
209
+ function acquire(home: string, what: string | undefined, timeoutMs: number) {
210
+ const n = depth.get(home) ?? 0
211
+ if (n > 0) {
212
+ depth.set(home, n + 1)
213
+ return
214
+ }
215
+ const start = Date.now()
216
+ for (let i = 0; ; i++) {
217
+ if (i > 0 && Date.now() - start >= timeoutMs) throw new BrainBusy(readOwner(home), Date.now() - start)
218
+ const r = tryTake(home, what)
219
+ if (r === "unavailable") unlocked.add(home)
220
+ if (r !== "busy") break
221
+ if (clearIfAbandoned(home)) continue
222
+ Bun.sleepSync(Math.min(25 * 2 ** Math.min(i, 3), 200) + Math.floor(Math.random() * 20))
223
+ }
224
+ depth.set(home, 1)
225
+ }
226
+
227
+ function release(home: string) {
228
+ const n = depth.get(home) ?? 0
229
+ if (n > 1) {
230
+ depth.set(home, n - 1)
231
+ return
232
+ }
233
+ depth.delete(home)
234
+ if (unlocked.delete(home)) return
235
+ // Only a lock that is positively ours: one cleared and retaken by another brain is not ours to remove.
236
+ const o = readOwner(home)
237
+ if (!o || o.pid !== process.pid || o.startedAt !== myStart()) return
238
+ removeLock(home)
239
+ }
240
+
241
+ /** Whether this process holds the lock for `home` right now. */
242
+ export const holdsLock = (home: string) => (depth.get(home) ?? 0) > 0
243
+
244
+ /** Runs `fn` while holding the brain lock for `home`. */
245
+ export function withBrainLockSync<T>(home: string, fn: () => T, o: { what?: string; timeoutMs?: number } = {}): T {
246
+ acquire(home, o.what, o.timeoutMs ?? lockTimeout())
247
+ try {
248
+ return fn()
249
+ } finally {
250
+ release(home)
251
+ }
252
+ }
253
+
254
+ /** The same for async work. Hold it only around reading, changing and writing files: never across a network call. */
255
+ export async function withBrainLock<T>(home: string, fn: () => Promise<T> | T, o: { what?: string; timeoutMs?: number } = {}): Promise<T> {
256
+ acquire(home, o.what, o.timeoutMs ?? lockTimeout())
257
+ try {
258
+ return await fn()
259
+ } finally {
260
+ release(home)
261
+ }
262
+ }
263
+
264
+ // A brain that exits while holding the lock (process.exit in a command) leaves nothing behind.
265
+ process.on("exit", () => {
266
+ for (const home of [...depth.keys()]) {
267
+ depth.set(home, 1)
268
+ try {
269
+ release(home)
270
+ } catch {}
271
+ }
272
+ })
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { closeSync, lstatSync, openSync, readFileSync, readSync, readdirSync, realpathSync, statSync } from "node:fs"
8
8
  import { dirname, isAbsolute, join, relative, resolve } from "node:path"
9
- import { LEAN_AUTO_TOKENS } from "./prepare.ts"
9
+ import { leanSettings } from "../settings/values.ts"
10
10
  import { approxTokens } from "./types.ts"
11
11
 
12
12
  export const FILE_WARN_TOKENS = 2_000
@@ -69,7 +69,8 @@ export interface AuditReport {
69
69
  lockfilesUncovered: string[]
70
70
  }
71
71
  large: LargeFile[]
72
- brain: { installedIn: string[]; leanThresholdTokens: number }
72
+ /** leanThresholdTokens: from how many tokens a session is made lean by itself; null when that is turned off (brain settings). */
73
+ brain: { installedIn: string[]; leanThresholdTokens: number | null }
73
74
  findings: Finding[]
74
75
  /** Instruction-file tokens: what is certainly paid on every request. MCP tool definitions come on top and are not measured. */
75
76
  reloadedTokens: number
@@ -435,6 +436,7 @@ export function auditTokens(dirIn: string, home: string): AuditReport {
435
436
  const installedIn = mcp.filter((c) => c.servers.includes("brain")).map((c) => c.tool)
436
437
  if (!installedIn.length) findings.push({ severity: 1, area: "brain", message: "the brain MCP server is not installed in any tool, so digests of big tool output cannot be expanded", fix: "run `brain mcp install`" })
437
438
 
439
+ const lean = leanSettings(home)
438
440
  findings.sort((a, b) => b.severity - a.severity || (b.tokens ?? 0) - (a.tokens ?? 0) || (a.message < b.message ? -1 : a.message > b.message ? 1 : 0))
439
441
  return {
440
442
  dir,
@@ -445,7 +447,7 @@ export function auditTokens(dirIn: string, home: string): AuditReport {
445
447
  mcpServersMax,
446
448
  ignore,
447
449
  large,
448
- brain: { installedIn, leanThresholdTokens: LEAN_AUTO_TOKENS },
450
+ brain: { installedIn, leanThresholdTokens: lean.auto ? lean.threshold : null },
449
451
  findings,
450
452
  reloadedTokens: instructionTokens,
451
453
  }
@@ -474,7 +476,7 @@ export function formatAudit(r: AuditReport): string[] {
474
476
  out.push("", `ignore: .gitignore ${ig.gitignore ? "present" : "missing"}; agent ignore files: ${ig.agentIgnores.join(", ") || "none"}`)
475
477
  out.push(`large files (${r.gitTracked ? "git-tracked" : "not a git repo: folder walk"}, text over ${LARGE_FILE_BYTES / 1024} KB): ${r.large.length ? "" : "none"}`.trimEnd())
476
478
  for (const f of r.large) out.push(` ${f.path} ${n(f.tokens)} tokens`)
477
- out.push("", `brain: MCP server ${r.brain.installedIn.length ? `installed (${r.brain.installedIn.join(", ")})` : "not installed"}; lean is automatic from ${n(r.brain.leanThresholdTokens)} tokens`)
479
+ out.push("", `brain: MCP server ${r.brain.installedIn.length ? `installed (${r.brain.installedIn.join(", ")})` : "not installed"}; ${r.brain.leanThresholdTokens === null ? "lean is not automatic on this computer (brain settings: lean.auto off)" : `lean is automatic from ${n(r.brain.leanThresholdTokens)} tokens`}`)
478
480
  out.push("", `tokens reloaded on every request: about ${n(r.reloadedTokens)} (instruction files)${r.mcpServersMax ? `, plus the tool definitions of ${r.mcpServersMax} MCP server${r.mcpServersMax === 1 ? "" : "s"} (not measured)` : ""}`)
479
481
  return out
480
482
  }
package/src/memory/cli.ts CHANGED
@@ -8,6 +8,7 @@ import { projectKey } from "./project.ts"
8
8
  import { redact } from "./redact.ts"
9
9
  import { openMemory, storeBytes } from "./store.ts"
10
10
  import type { ProjectKey } from "./types.ts"
11
+ import { MEMORY_OFF, memoryOn } from "../settings/values.ts"
11
12
 
12
13
  function flag(args: string[], name: string): string | undefined {
13
14
  const i = args.indexOf(`--${name}`)
@@ -46,6 +47,11 @@ async function pickProject(args: string[], mem: ReturnType<typeof openMemory>):
46
47
 
47
48
  export async function memoryCommand(args: string[], home: string, resolve: (id: string) => Promise<Session>, out: (line: string) => void = console.log): Promise<number> {
48
49
  const [sub, ...rest] = args
50
+ // Turned off on this computer: nothing is stored or searched (forgetting still works: it only removes).
51
+ if (!memoryOn(home) && sub !== "forget") {
52
+ if (sub === "status" || !sub) return out(`memory: off`), out(`why: ${MEMORY_OFF}`), 0
53
+ if (sub === "ingest" || sub === "search") return out(MEMORY_OFF), 1
54
+ }
49
55
  const mem = openMemory(home)
50
56
  try {
51
57
  if (sub === "status" || !sub) {
@@ -397,6 +397,18 @@ export const KEEP_RECENT_SHARE = 0.2
397
397
  export interface BudgetOptions {
398
398
  targetTokens: number
399
399
  tidy?: boolean
400
+ /** Level 1's size from which an output is replaced (the `lean.minChars` setting); the later levels never take less than it would. Default 1,500. */
401
+ minChars?: number
402
+ }
403
+
404
+ /** The levels for a level-1 size of `minChars`: level 1 is the plain lean size, and each later level goes no higher than the one before. */
405
+ export function budgetLevels(minChars = BUDGET_LEVELS[0]!.minChars): Array<{ minChars: number; keepRecent: number }> {
406
+ let prev = minChars
407
+ return BUDGET_LEVELS.map((l, i) => {
408
+ const m = i === 0 ? minChars : Math.min(l.minChars, prev)
409
+ prev = m
410
+ return { minChars: m, keepRecent: l.keepRecent }
411
+ })
400
412
  }
401
413
  export interface BudgetOutcome extends ExternalizeOutcome {
402
414
  /** 0: the tidied session already fits; 1..4: the first level at which it fits (4 when none does). */
@@ -415,20 +427,21 @@ export function externalizeToBudget(session: Session, store: BlobStore, o: Budge
415
427
  const target = Math.max(0, Math.floor(o.targetTokens))
416
428
  const cap = Math.round(target * KEEP_RECENT_SHARE)
417
429
  const common = { keepRecentTokens: cap, ...(o.tidy === false ? { tidy: false } : {}) }
430
+ const levels = budgetLevels(o.minChars)
418
431
  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 })
432
+ const trialTokens = (idx: number) => approxTokens("x".repeat(externalize(session, countingBlobs(), { ...levels[idx]!, ...common }).after))
433
+ const base = externalize(session, countingBlobs(), { ...levels[0]!, ...common, stopAtTokens: target })
421
434
  if (base.replaced === 0 && approxTokens("x".repeat(base.after)) <= target) {
422
435
  level = 0
423
436
  } else {
424
- for (let i = 0; i < BUDGET_LEVELS.length; i++) {
437
+ for (let i = 0; i < levels.length; i++) {
425
438
  level = i + 1
426
439
  if (trialTokens(i) <= target) {
427
440
  break
428
441
  }
429
442
  }
430
443
  }
431
- const opts: ExternalizeOpts = { ...BUDGET_LEVELS[Math.max(0, level - 1)]!, ...common, stopAtTokens: target }
444
+ const opts: ExternalizeOpts = { ...levels[Math.max(0, level - 1)]!, ...common, stopAtTokens: target }
432
445
  const r = externalize(session, store, opts)
433
446
  const tokens = estimateTokens(r.session)
434
447
  return { ...r, level, reached: tokens <= target, tokens }
@@ -1,7 +1,9 @@
1
1
  import { PACKAGE, selfArgs, stableBun } from "../self.ts"
2
- import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs"
2
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, statSync, unlinkSync } from "node:fs"
3
3
  import { dirname, join } from "node:path"
4
4
  import { runMcpServer } from "./mcp-server.ts"
5
+ import { writeAtomic } from "../fsutil.ts"
6
+ import { withBrainLockSync } from "../lock.ts"
5
7
  import type { ProjectKey } from "./types.ts"
6
8
 
7
9
  // `brain mcp serve | install | uninstall | status`. The installer writes one entry named "brain" into each tool's
@@ -106,11 +108,7 @@ function writeFile(home: string, tool: string, path: string, text: string) {
106
108
  } catch {
107
109
  // New file: default mode.
108
110
  }
109
- const tmp = `${real}.brain-tmp`
110
- rmSync(tmp, { force: true })
111
- writeFileSync(tmp, text, mode === undefined ? {} : { mode })
112
- if (mode !== undefined) chmodSync(tmp, mode)
113
- renameSync(tmp, real)
111
+ writeAtomic(real, text, mode)
114
112
  }
115
113
 
116
114
  /** An entry is ours when it runs `... mcp serve` through a cli.ts, or through the brain package (`bun x @100xprompt/brain@...`). */
@@ -175,6 +173,12 @@ function hasRun(t: McpTool, home: string): boolean {
175
173
  return t.probe.some((p) => existsSync(join(home, p)))
176
174
  }
177
175
 
176
+ /** Whether a tool the installer knows has run on this machine (its config or folder exists). */
177
+ export function mcpHasRun(home: string, tool: string): boolean {
178
+ const t = MCP_TOOLS.find((x) => x.id === tool)
179
+ return !!t && hasRun(t, home)
180
+ }
181
+
178
182
  function prepare(tool: string, home: string): { t: McpTool; path: string } | McpChange {
179
183
  const t = MCP_TOOLS.find((x) => x.id === tool)
180
184
  if (!t) return { tool, path: "", action: "skipped", why: `no MCP installer for "${tool}"; available: ${MCP_TOOLS.map((x) => x.id).join(", ")}` }
@@ -191,6 +195,10 @@ function currentJson(t: McpTool, config: Json): { entry: unknown } | { error: st
191
195
  }
192
196
 
193
197
  export function installMcp(tools: string[], home: string, opts: Opts = {}): McpChange[] {
198
+ return opts.dryRun ? installLocked(tools, home, opts) : withBrainLockSync(home, () => installLocked(tools, home, opts), { what: "installing the brain tools" })
199
+ }
200
+
201
+ function installLocked(tools: string[], home: string, opts: Opts): McpChange[] {
194
202
  const out: McpChange[] = []
195
203
  const args = argsFor(opts.cli)
196
204
  for (const tool of [...new Set(tools)]) {
@@ -278,6 +286,10 @@ export function installMcp(tools: string[], home: string, opts: Opts = {}): McpC
278
286
  }
279
287
 
280
288
  export function uninstallMcp(tools: string[], home: string, opts: Opts = {}): McpChange[] {
289
+ return opts.dryRun ? uninstallLocked(tools, home, opts) : withBrainLockSync(home, () => uninstallLocked(tools, home, opts), { what: "removing the brain tools" })
290
+ }
291
+
292
+ function uninstallLocked(tools: string[], home: string, opts: Opts): McpChange[] {
281
293
  const out: McpChange[] = []
282
294
  for (const tool of [...new Set(tools)]) {
283
295
  const p = prepare(tool, home)
@@ -366,6 +378,38 @@ export function statusMcp(home: string): McpChange[] {
366
378
  })
367
379
  }
368
380
 
381
+ /**
382
+ * Whether brain's entry is in a tool's config, and whether it can still start: its bun and its cli.ts are where it says.
383
+ * Any brain's entry counts as installed (one from another checkout, or a pinned package): settings leave a working entry
384
+ * as it is, so two brains never keep rewriting each other's, and repair only one that cannot start.
385
+ */
386
+ export function mcpEntry(home: string, tool: string): { installed: boolean; broken?: string; off?: boolean } {
387
+ const t = MCP_TOOLS.find((x) => x.id === tool)
388
+ if (!t) return { installed: false }
389
+ const path = configPath(t, home)
390
+ let entry: unknown
391
+ try {
392
+ if (t.format === "toml") entry = existsSync(path) ? parseToml(readFileSync(path, "utf8"))?.mcp_servers?.[NAME] : undefined
393
+ else {
394
+ const loaded = loadJson(path)
395
+ const cur = "error" in loaded ? undefined : currentJson(t, loaded.config)
396
+ entry = cur && "entry" in cur ? cur.entry : undefined
397
+ }
398
+ } catch {
399
+ return { installed: false }
400
+ }
401
+ if (!isOurs(entry)) return { installed: false }
402
+ const e = entry as Json
403
+ const argv = (Array.isArray(e.command) ? e.command : [e.command, ...(Array.isArray(e.args) ? e.args : [])]).filter((x: unknown): x is string => typeof x === "string")
404
+ const bun = argv[0] ?? ""
405
+ const cli = argv[argv.length - 3] ?? ""
406
+ if (!(bun.includes("/") ? existsSync(bun) : Bun.which(bun))) return { installed: true, broken: `its bun (${bun}) is not there any more` }
407
+ if (/cli\.ts$/.test(cli) && !existsSync(cli)) return { installed: true, broken: `the brain it runs (${cli}) is not there any more` }
408
+ // Turned off in the tool's own settings (OpenCode's enabled: false, a disabled flag elsewhere).
409
+ if (e.enabled === false || e.disabled === true) return { installed: true, off: true }
410
+ return { installed: true }
411
+ }
412
+
369
413
  // ---- the command ----
370
414
 
371
415
  const flag = (args: string[], name: string) => {
@@ -387,7 +431,19 @@ export async function mcpCommand(args: string[], home: string): Promise<number>
387
431
  const tool = flag(rest, "--tool")
388
432
  const ids = tool ? [tool] : MCP_TOOLS.map((t) => t.id)
389
433
  const dryRun = rest.includes("--dry-run")
390
- const changes = action === "install" ? installMcp(ids, home, { dryRun }) : action === "uninstall" ? uninstallMcp(ids, home, { dryRun }) : tool ? statusMcp(home).filter((c) => c.tool === tool) : statusMcp(home)
434
+ // A real install or uninstall is recorded as this computer's setting (tools.<tool>.mcp) in the same hold of the brain
435
+ // lock as the change, so `brain settings` shows who set it. (The settings modules import this one: loaded here.)
436
+ const change = (action === "install" || action === "uninstall") && !dryRun
437
+ const settings = change ? { ...(await import("../settings/apply.ts")), ...(await import("../settings/catalog.ts")) } : undefined
438
+ const run = () => {
439
+ const cs = action === "install" ? installMcp(ids, home, { dryRun }) : action === "uninstall" ? uninstallMcp(ids, home, { dryRun }) : tool ? statusMcp(home).filter((c) => c.tool === tool) : statusMcp(home)
440
+ if (settings) {
441
+ const done = cs.filter((c) => (action === "install" ? ["installed", "already-installed"] : ["removed", "not-installed"]).includes(c.action))
442
+ settings.recordChanges(home, done.map((c) => ({ id: `tools.${settings.toolOfMcpInstaller(c.tool)}.mcp`, value: action === "install" })), `brain mcp ${action}`)
443
+ }
444
+ return cs
445
+ }
446
+ const changes = change ? withBrainLockSync(home, run, { what: `${action === "install" ? "installing" : "removing"} the brain tools` }) : run()
391
447
  if (!changes.length) {
392
448
  say(`no tool "${tool}"; available: ${MCP_TOOLS.map((t) => t.id).join(", ")}`)
393
449
  return 1
@@ -19,6 +19,7 @@ import { windowOf } from "./relevance.ts"
19
19
  import { logLookup } from "./lookups.ts"
20
20
  import { openMemory } from "./store.ts"
21
21
  import { approxTokens, type BlobStore, type Memory, type ProjectKey } from "./types.ts"
22
+ import { MEMORY_OFF, memoryOn } from "../settings/values.ts"
22
23
 
23
24
  export interface McpServerOptions {
24
25
  home: string
@@ -102,6 +103,8 @@ export async function runMcpServer(opts: McpServerOptions): Promise<void> {
102
103
  async function recall(args: Json) {
103
104
  const query = typeof args.query === "string" ? args.query.trim() : ""
104
105
  if (!query) return fail("query is required")
106
+ // Read on every call: the person can turn memory off while the tool keeps this server running.
107
+ if (!memoryOn(opts.home)) return ok(MEMORY_OFF)
105
108
  const st = await mem().status()
106
109
  if (!st.available) return ok(`memory is not available: ${st.why ?? "unknown reason"}`)
107
110
  const limit = Math.max(1, Math.min(50, Math.floor(Number(args.limit)) || 8))
@@ -184,6 +187,7 @@ export async function runMcpServer(opts: McpServerOptions): Promise<void> {
184
187
  }
185
188
 
186
189
  async function status() {
190
+ if (!memoryOn(opts.home)) return ok(redact([`project: ${project.label} (${project.id})`, `memory: off (${MEMORY_OFF})`, `blobs: ${blob().dir}`].join("\n")))
187
191
  const st = await mem().status()
188
192
  const lines = [`project: ${project.label} (${project.id})`, `memory: ${st.available ? "available" : `not available: ${st.why ?? "unknown reason"}`}`]
189
193
  if (st.store) lines.push(`store: ${st.store}`)
@@ -16,8 +16,12 @@ import { repoMap } from "./repomap.ts"
16
16
  import { logLookup } from "./lookups.ts"
17
17
  import { openMemory } from "./store.ts"
18
18
  import { approxTokens, type BlobRef, type BlobStore, type Memory, type ProjectKey } from "./types.ts"
19
+ import { leanSettings, MEMORY_OFF, memoryOn } from "../settings/values.ts"
19
20
 
20
- /** Sessions with at least this many tokens are made lean when nothing says otherwise: below it, full fidelity costs little. */
21
+ /**
22
+ * Sessions with at least this many tokens are made lean when nothing says otherwise: below it, full fidelity costs little.
23
+ * The default of the `lean.threshold` setting (`brain settings`), which can also turn it off (`lean.auto`).
24
+ */
21
25
  export const LEAN_AUTO_TOKENS = 50_000
22
26
  /** Above this many tokens a native continuation cannot fit the context of most models. */
23
27
  export const WINDOW_WARN_TOKENS = 400_000
@@ -105,14 +109,17 @@ export async function prepare(session: Session, o: PrepareOptions, deps: { memor
105
109
  const notes: string[] = []
106
110
  const tokens = sessionTokens(session)
107
111
  const budget = o.budget !== undefined && Number.isFinite(o.budget) && o.budget > 0 ? Math.floor(o.budget) : undefined
108
- const lean = o.lean ?? (tokens >= LEAN_AUTO_TOKENS || (budget !== undefined && tokens > budget))
112
+ // This computer's settings (ways 1 and 2): when a long session is made lean, and from what size an output is digested.
113
+ const ls = leanSettings(o.home)
114
+ const lean = o.lean ?? ((ls.auto && tokens >= ls.threshold) || (budget !== undefined && tokens > budget))
115
+ if (o.lean === undefined && !ls.auto && tokens >= ls.threshold && !lean) notes.push(`lean: not automatic on this computer (brain settings: lean.auto off); --lean makes this ${tokens.toLocaleString()}-token session lean`)
109
116
  const brief: Prepared["brief"] = { lean }
110
117
  let out = session
111
118
  const tidy = o.tidy !== false
112
119
 
113
120
  if (lean) {
114
121
  const blobs = deps.blobs ?? (o.dryRun ? scratchBlobs() : openBlobStore(o.home))
115
- const r = budget !== undefined ? externalizeToBudget(session, blobs, { targetTokens: budget, tidy }) : externalize(session, blobs, { tidy })
122
+ const r = budget !== undefined ? externalizeToBudget(session, blobs, { targetTokens: budget, tidy, minChars: ls.minChars }) : externalize(session, blobs, { tidy, minChars: ls.minChars })
116
123
  out = r.session
117
124
  if (!o.dryRun && r.replaced) logLookup(o.home, { event: "stubs", at: new Date().toISOString(), session: session.header.id, byKind: r.byKind as Record<string, number> })
118
125
  const digested = Math.round((r.before - r.after - r.tidied) / 4)
@@ -136,7 +143,8 @@ export async function prepare(session: Session, o: PrepareOptions, deps: { memor
136
143
  const left = sessionTokens(out)
137
144
  if (left > WINDOW_WARN_TOKENS) notes.push(`size: still about ${left.toLocaleString()} tokens, more than most models take at once; \`--way brief\` carries the essentials and what the project remembers in about ${(BRIEF_TOKENS_HINT).toLocaleString()} tokens`)
138
145
 
139
- if (!o.noMemory) {
146
+ if (!o.noMemory && !memoryOn(o.home)) notes.push(`memory: not used (${MEMORY_OFF})`)
147
+ else if (!o.noMemory) {
140
148
  const memory = deps.memory ?? openMemory(o.home)
141
149
  const status = await memory.status().catch((e: Error) => ({ available: false as const, why: e.message }))
142
150
  if (!status.available) notes.push(`memory: not used (${status.why ?? "unavailable"})`)