@100xprompt/brain 0.1.2 → 0.1.4
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/README.md +59 -2
- package/package.json +1 -1
- package/src/cli.ts +74 -9
- package/src/dashboard/cli.ts +25 -9
- package/src/dashboard/report.ts +84 -0
- package/src/dashboard/send.ts +20 -4
- package/src/dashboard/state.ts +67 -5
- package/src/fsutil.ts +19 -1
- package/src/gateway/common.ts +56 -4
- package/src/gateway/connect.ts +256 -100
- package/src/gateway/connectors/aider.ts +3 -5
- package/src/gateway/connectors/cline-sdk.ts +2 -5
- package/src/gateway/connectors/continue.ts +5 -6
- package/src/gateway/connectors/crush.ts +2 -2
- package/src/gateway/connectors/factory-droid.ts +6 -1
- package/src/gateway/connectors/goose.ts +4 -4
- package/src/gateway/connectors/hermes.ts +7 -3
- package/src/gateway/connectors/kimi-code.ts +2 -2
- package/src/gateway/connectors/letta-code.ts +4 -7
- package/src/gateway/connectors/openhands.ts +9 -4
- package/src/gateway/connectors/qwen-code.ts +16 -2
- package/src/gateway/connectors/vscode-copilot.ts +4 -6
- package/src/gateway/signin.ts +44 -12
- package/src/hooks.ts +20 -9
- package/src/lock.ts +269 -0
- package/src/memory/audit.ts +6 -4
- package/src/memory/cli.ts +6 -0
- package/src/memory/externalize.ts +17 -4
- package/src/memory/mcp-install.ts +63 -7
- package/src/memory/mcp-server.ts +4 -0
- package/src/memory/prepare.ts +12 -4
- package/src/memory/sync.ts +3 -0
- package/src/settings/account.ts +336 -0
- package/src/settings/apply.ts +306 -0
- package/src/settings/catalog.ts +476 -0
- package/src/settings/cli.ts +439 -0
- package/src/settings/env.ts +101 -0
- package/src/settings/pair.ts +83 -0
- package/src/settings/store.ts +216 -0
- package/src/settings/values.ts +80 -0
package/src/lock.ts
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
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
|
+
function readOwner(home: string): LockOwner | undefined {
|
|
97
|
+
try {
|
|
98
|
+
const o = JSON.parse(readFileSync(ownerPath(home), "utf8"))
|
|
99
|
+
return Number.isInteger(o?.pid) && Number.isFinite(o?.startedAt) ? (o as LockOwner) : undefined
|
|
100
|
+
} catch {
|
|
101
|
+
return undefined
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Whether the lock's holder has ended: its process is gone, or its id now belongs to a process that started at another
|
|
107
|
+
* time. A process of another user (EPERM) is not taken as alive by that alone: its start time is still compared.
|
|
108
|
+
*/
|
|
109
|
+
export function holderGone(o: LockOwner): boolean {
|
|
110
|
+
if (o.pid === process.pid) return o.startedAt !== myStart()
|
|
111
|
+
try {
|
|
112
|
+
process.kill(o.pid, 0)
|
|
113
|
+
} catch (e) {
|
|
114
|
+
if (code(e) === "ESRCH") return true
|
|
115
|
+
if (code(e) !== "EPERM") return false
|
|
116
|
+
}
|
|
117
|
+
const s = processStart(o.pid)
|
|
118
|
+
return s !== undefined && Math.abs(s - o.startedAt) > START_SLACK_MS
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Takes the lock if it is free: a folder holding owner.json is made aside, then renamed into place in one step. */
|
|
122
|
+
function tryTake(home: string, what: string | undefined): "taken" | "busy" | "unavailable" {
|
|
123
|
+
const tmp = `${lockPath(home)}.new-${process.pid}-${rand()}`
|
|
124
|
+
try {
|
|
125
|
+
mkdirSync(join(home, ".brain"), { recursive: true, mode: 0o700 })
|
|
126
|
+
mkdirSync(tmp, { mode: 0o700 })
|
|
127
|
+
const owner: LockOwner = { pid: process.pid, startedAt: myStart(), at: new Date().toISOString(), ...(what ? { what } : {}) }
|
|
128
|
+
writeFileSync(join(tmp, "owner.json"), JSON.stringify(owner), { mode: 0o600 })
|
|
129
|
+
} catch (e) {
|
|
130
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
131
|
+
if (unwritable(e)) return "unavailable"
|
|
132
|
+
throw e
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
renameSync(tmp, lockPath(home))
|
|
136
|
+
return "taken"
|
|
137
|
+
} catch (e) {
|
|
138
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
139
|
+
// ENOTEMPTY / EEXIST: another brain's lock is there. ENOTDIR: something else is in its place.
|
|
140
|
+
if (["ENOTEMPTY", "EEXIST", "ENOTDIR", "EISDIR"].includes(code(e) ?? "")) return "busy"
|
|
141
|
+
if (unwritable(e)) return "unavailable"
|
|
142
|
+
throw e
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Removes the lock folder in one step (moved aside first, so no other brain ever sees it half removed). */
|
|
147
|
+
function removeLock(home: string) {
|
|
148
|
+
const aside = `${lockPath(home)}.gone-${process.pid}-${rand()}`
|
|
149
|
+
try {
|
|
150
|
+
renameSync(lockPath(home), aside)
|
|
151
|
+
} catch (e) {
|
|
152
|
+
if (code(e) === "ENOENT") return
|
|
153
|
+
throw e
|
|
154
|
+
}
|
|
155
|
+
rmSync(aside, { recursive: true, force: true })
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Clears a lock whose holder has ended, and says whether it did. Done under a short second lock (lock.break), after reading
|
|
160
|
+
* the owner again: only that same abandoned lock is removed, never one another brain took meanwhile.
|
|
161
|
+
*/
|
|
162
|
+
function clearIfAbandoned(home: string): boolean {
|
|
163
|
+
const o = readOwner(home)
|
|
164
|
+
let mtime: number | undefined
|
|
165
|
+
if (o) {
|
|
166
|
+
const key = `${home}\0${o.pid}\0${o.startedAt}`
|
|
167
|
+
if (seenAlive?.key === key && Date.now() - seenAlive.at < ALIVE_RECHECK_MS) return false
|
|
168
|
+
if (!holderGone(o)) {
|
|
169
|
+
seenAlive = { key, at: Date.now() }
|
|
170
|
+
return false
|
|
171
|
+
}
|
|
172
|
+
} else {
|
|
173
|
+
try {
|
|
174
|
+
mtime = statSync(lockPath(home)).mtimeMs
|
|
175
|
+
} catch (e) {
|
|
176
|
+
if (code(e) === "ENOENT") return true
|
|
177
|
+
throw e
|
|
178
|
+
}
|
|
179
|
+
if (Date.now() - mtime < ORPHAN_MS) return false
|
|
180
|
+
}
|
|
181
|
+
const brk = `${lockPath(home)}.break`
|
|
182
|
+
try {
|
|
183
|
+
mkdirSync(brk)
|
|
184
|
+
} catch (e) {
|
|
185
|
+
if (code(e) !== "EEXIST") throw e
|
|
186
|
+
// Another brain is clearing it; one that died doing so leaves an old helper behind, which goes.
|
|
187
|
+
try {
|
|
188
|
+
if (Date.now() - statSync(brk).mtimeMs > ORPHAN_MS) rmSync(brk, { recursive: true, force: true })
|
|
189
|
+
} catch {}
|
|
190
|
+
return false
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
const again = readOwner(home)
|
|
194
|
+
const same = o ? !!again && again.pid === o.pid && again.startedAt === o.startedAt : !again && statSync(lockPath(home), { throwIfNoEntry: false })?.mtimeMs === mtime
|
|
195
|
+
if (!same) return false
|
|
196
|
+
removeLock(home)
|
|
197
|
+
return true
|
|
198
|
+
} catch (e) {
|
|
199
|
+
if (code(e) === "ENOENT") return true
|
|
200
|
+
throw e
|
|
201
|
+
} finally {
|
|
202
|
+
rmSync(brk, { recursive: true, force: true })
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function acquire(home: string, what: string | undefined, timeoutMs: number) {
|
|
207
|
+
const n = depth.get(home) ?? 0
|
|
208
|
+
if (n > 0) {
|
|
209
|
+
depth.set(home, n + 1)
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
const start = Date.now()
|
|
213
|
+
for (let i = 0; ; i++) {
|
|
214
|
+
if (i > 0 && Date.now() - start >= timeoutMs) throw new BrainBusy(readOwner(home), Date.now() - start)
|
|
215
|
+
const r = tryTake(home, what)
|
|
216
|
+
if (r === "unavailable") unlocked.add(home)
|
|
217
|
+
if (r !== "busy") break
|
|
218
|
+
if (clearIfAbandoned(home)) continue
|
|
219
|
+
Bun.sleepSync(Math.min(25 * 2 ** Math.min(i, 3), 200) + Math.floor(Math.random() * 20))
|
|
220
|
+
}
|
|
221
|
+
depth.set(home, 1)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function release(home: string) {
|
|
225
|
+
const n = depth.get(home) ?? 0
|
|
226
|
+
if (n > 1) {
|
|
227
|
+
depth.set(home, n - 1)
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
depth.delete(home)
|
|
231
|
+
if (unlocked.delete(home)) return
|
|
232
|
+
// Only a lock that is positively ours: one cleared and retaken by another brain is not ours to remove.
|
|
233
|
+
const o = readOwner(home)
|
|
234
|
+
if (!o || o.pid !== process.pid || o.startedAt !== myStart()) return
|
|
235
|
+
removeLock(home)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Whether this process holds the lock for `home` right now. */
|
|
239
|
+
export const holdsLock = (home: string) => (depth.get(home) ?? 0) > 0
|
|
240
|
+
|
|
241
|
+
/** Runs `fn` while holding the brain lock for `home`. */
|
|
242
|
+
export function withBrainLockSync<T>(home: string, fn: () => T, o: { what?: string; timeoutMs?: number } = {}): T {
|
|
243
|
+
acquire(home, o.what, o.timeoutMs ?? lockTimeout())
|
|
244
|
+
try {
|
|
245
|
+
return fn()
|
|
246
|
+
} finally {
|
|
247
|
+
release(home)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The same for async work. Hold it only around reading, changing and writing files: never across a network call. */
|
|
252
|
+
export async function withBrainLock<T>(home: string, fn: () => Promise<T> | T, o: { what?: string; timeoutMs?: number } = {}): Promise<T> {
|
|
253
|
+
acquire(home, o.what, o.timeoutMs ?? lockTimeout())
|
|
254
|
+
try {
|
|
255
|
+
return await fn()
|
|
256
|
+
} finally {
|
|
257
|
+
release(home)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// A brain that exits while holding the lock (process.exit in a command) leaves nothing behind.
|
|
262
|
+
process.on("exit", () => {
|
|
263
|
+
for (const home of [...depth.keys()]) {
|
|
264
|
+
depth.set(home, 1)
|
|
265
|
+
try {
|
|
266
|
+
release(home)
|
|
267
|
+
} catch {}
|
|
268
|
+
}
|
|
269
|
+
})
|
package/src/memory/audit.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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:
|
|
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(), { ...
|
|
420
|
-
const base = externalize(session, countingBlobs(), { ...
|
|
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 <
|
|
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 = { ...
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/memory/mcp-server.ts
CHANGED
|
@@ -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}`)
|
package/src/memory/prepare.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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"})`)
|
package/src/memory/sync.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { distill } from "./distill.ts"
|
|
|
10
10
|
import { projectKey } from "./project.ts"
|
|
11
11
|
import { openMemory } from "./store.ts"
|
|
12
12
|
import type { Memory } from "./types.ts"
|
|
13
|
+
import { MEMORY_OFF, memoryOn } from "../settings/values.ts"
|
|
13
14
|
|
|
14
15
|
export interface SessionSource {
|
|
15
16
|
/** Portable id (`<tool>:<id>`). */
|
|
@@ -112,6 +113,7 @@ export async function syncMemory(o: SyncOptions, deps: { memory?: Memory; sessio
|
|
|
112
113
|
const result: SyncResult = { ingested: 0, items: 0, upToDate: 0, deferred: 0, errors: [], ms: 0 }
|
|
113
114
|
const done = (): SyncResult => ({ ...result, ms: now() - t0 })
|
|
114
115
|
|
|
116
|
+
if (!memoryOn(o.home)) return { ...done(), unavailable: MEMORY_OFF }
|
|
115
117
|
const memory = deps.memory ?? openMemory(o.home)
|
|
116
118
|
const status = await memory.status().catch((e: Error) => ({ available: false as const, why: e.message }))
|
|
117
119
|
if (!status.available) return { ...done(), unavailable: status.why ?? "unavailable" }
|
|
@@ -156,6 +158,7 @@ export async function syncMemory(o: SyncOptions, deps: { memory?: Memory; sessio
|
|
|
156
158
|
export function syncInBackground(home: string, cwd: string, cli: string) {
|
|
157
159
|
if (process.env.BRAIN_NO_MEMORY === "1") return
|
|
158
160
|
try {
|
|
161
|
+
if (!memoryOn(home)) return
|
|
159
162
|
// 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
163
|
if (!existsSync(join(home, ".brain", "memory"))) return
|
|
161
164
|
const child = Bun.spawn([process.execPath, cli, "memory", "sync", "--here", "--quiet"], { cwd, stdin: "ignore", stdout: "ignore", stderr: "ignore", env: { ...process.env, BRAIN_HOME: home } })
|