@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.
@@ -0,0 +1,391 @@
1
+ // Exchanging settings with the person's 100xprompt account (docs/settings-contract.md, section 5). A paired computer has
2
+ // its own token (~/.brain/machine.key, from `brain pair`); model API keys never read or change settings.
3
+ //
4
+ // 1. send the outbox: what was changed here, one PATCH per level (this computer, all your computers). Accepted and
5
+ // refused changes leave it; refused ones are kept to be shown, and the account's value then applies again.
6
+ // 2. fetch what the account has (GET, with the revision last seen: "unchanged" costs nothing).
7
+ // 3. take it: the account's entries become this computer's and the person's levels, except settings only this computer
8
+ // can have and changes made here that are not sent yet. Changes from elsewhere go into the history.
9
+ // 4. apply what that changed (apply.ts reconcile), and tell the account what applying gave.
10
+ // Network calls never run under the brain lock; the whole exchange keeps to a time budget (3 s for commands). Never run
11
+ // from the session hook, `brain mcp serve` or the background children. Anything the account answers is checked before it
12
+ // is taken: an answer that cannot be read changes nothing here.
13
+ import { existsSync, mkdirSync, rmSync } from "node:fs"
14
+ import type { Env } from "../gateway/common.ts"
15
+ import { writeAtomic } from "../fsutil.ts"
16
+ import { withBrainLockSync } from "../lock.ts"
17
+ import { gatewayUrl, safeUrl } from "../dashboard/send.ts"
18
+ import { brainDir, MACHINE_TOKEN, machineId, machineKeyPath, readMachineToken } from "../dashboard/state.ts"
19
+ import { readOrgConfig } from "../org/config.ts"
20
+ import { type Row, reconcile } from "./apply.ts"
21
+ import { checkValue, parseId, resolve } from "./catalog.ts"
22
+ import { type Entry, type Level, readSettingsFile, same, type SettingsDoc, type Source, updateDoc, type Value } from "./store.ts"
23
+ import { memoryOn } from "./values.ts"
24
+
25
+ export { MACHINE_TOKEN, readMachineToken }
26
+
27
+ export function storeMachineToken(home: string, token: string): void {
28
+ withBrainLockSync(home, () => {
29
+ mkdirSync(brainDir(home), { recursive: true, mode: 0o700 })
30
+ writeAtomic(machineKeyPath(home), `${token}\n`, 0o600)
31
+ })
32
+ }
33
+
34
+ export function forgetMachineToken(home: string): boolean {
35
+ const had = existsSync(machineKeyPath(home))
36
+ rmSync(machineKeyPath(home), { force: true })
37
+ return had
38
+ }
39
+
40
+ /**
41
+ * A computer the account knows afresh (paired again after "Remove this machine", or a copied ~/.brain paired as a computer
42
+ * of its own) has an empty document there: every value set at this computer's level here is sent again, and the next fetch
43
+ * starts from nothing, so the account's empty document never erases them. Not the dashboard's or an admin's: those are the
44
+ * account's to keep (a computer never sends them as its own; the gateway refuses that), and an account that forgot this
45
+ * computer forgot them too.
46
+ */
47
+ export function sendAllAgain(home: string): void {
48
+ updateDoc(home, (d) => {
49
+ const at = new Date().toISOString()
50
+ for (const [id, e] of Object.entries(d.levels.computer)) {
51
+ const ref = parseId(id)
52
+ if ("error" in ref || ref.family.remote !== "yes" || !COMPUTER_SOURCES.includes(e.source)) continue
53
+ d.outbox = d.outbox.filter((c) => !(c.id === id && c.level === "computer"))
54
+ d.outbox.push({ id, level: "computer", value: e.value, source: e.source, at: e.at || at, ...(e.command ? { command: e.command } : {}) })
55
+ }
56
+ delete d.sync.rev
57
+ d.sync.machine = machineId(home)
58
+ })
59
+ }
60
+
61
+ export interface Exchange {
62
+ paired: boolean
63
+ /** The account was reached (sending and fetching worked). */
64
+ ok: boolean
65
+ why?: string
66
+ /** Changes made here the account accepted. */
67
+ sent: number
68
+ refused: Array<{ id: string; code: string; message: string }>
69
+ /** Values that came from the account (changed since the last exchange). */
70
+ received: string[]
71
+ applied: Row[]
72
+ /** Values from the account this computer did not take, and why. */
73
+ held: Array<{ id: string; why: string }>
74
+ }
75
+
76
+ const LEVEL_OUT: Record<"computer" | "person", "machine" | "account"> = { computer: "machine", person: "account" }
77
+ const SOURCES: Source[] = ["settings", "command", "dashboard", "admin", "local-page"]
78
+ /** The sources a computer sends its changes with; the dashboard's and an admin's come only from the account. */
79
+ const COMPUTER_SOURCES: Source[] = ["settings", "command", "local-page"]
80
+ const isValue = (v: unknown): v is Value => typeof v === "boolean" || (typeof v === "number" && Number.isFinite(v)) || typeof v === "string"
81
+ const obj = (v: unknown): Record<string, any> => (v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, any>) : {})
82
+ const isObj = (v: unknown) => !!v && typeof v === "object" && !Array.isArray(v)
83
+ const MEMORY_HELD = "this computer keeps memory on your team's server: only this computer turns it on (brain settings set memory.enabled on)"
84
+
85
+ /** An entry as the account sends it, if it is one this brain can keep: a known setting the account may change, at a level it can be set at, with a valid value. */
86
+ function entryFrom(id: string, level: Level, e: unknown): Entry | undefined {
87
+ const x = obj(e)
88
+ if (!isValue(x.value) || typeof x.at !== "string" || x.at.length > 40 || !Number.isFinite(Date.parse(x.at))) return undefined
89
+ const ref = parseId(id)
90
+ if ("error" in ref || ref.id !== id || ref.family.remote !== "yes" || !ref.family.levels.includes(level) || !checkValue(ref.family.type, x.value)) return undefined
91
+ return { value: x.value, source: SOURCES.includes(x.source) ? x.source : "dashboard", at: x.at, ...(Number.isSafeInteger(x.rev) ? { rev: x.rev } : {}) }
92
+ }
93
+
94
+ const sameEntry = (x: Entry | undefined, y: Entry | undefined) =>
95
+ x === y || (!!x && !!y && same(x.value, y.value) && x.source === y.source && x.at === y.at && x.rev === y.rev)
96
+
97
+ /** "<machine>.<account>" as two numbers; undefined when it is not one. */
98
+ const revParts = (r: unknown): [number, number] | undefined => {
99
+ const m = typeof r === "string" ? /^(\d{1,15})\.(\d{1,15})$/.exec(r) : null
100
+ return m ? [Number(m[1]), Number(m[2])] : undefined
101
+ }
102
+
103
+ /** Plain words for what the account answered. */
104
+ function trouble(status: number, said: string): string {
105
+ if (status === 401) return "your account no longer takes this computer's token (was the computer removed in the dashboard?): pair it again with `brain pair`"
106
+ if (status === 403) return `your account refused this exchange${said ? ` (${said})` : ""}: pair this computer again with \`brain pair\``
107
+ if (status === 404) return "the gateway does not keep brain settings yet"
108
+ if (status === 429) return "too many exchanges for now; the next one carries everything"
109
+ return `the gateway answered HTTP ${status}${said ? `: ${said}` : ""}`
110
+ }
111
+
112
+ class Unreachable extends Error {}
113
+ class Unreadable extends Error {}
114
+
115
+ /**
116
+ * One exchange with the account. Does nothing (paired: false) on a computer that is not paired. Never throws; what went
117
+ * wrong is in `why` and kept in settings.json for `brain status`.
118
+ */
119
+ export async function exchange(home: string, o: { env?: Env; fetchImpl?: typeof fetch; timeoutMs?: number; apply?: boolean } = {}): Promise<Exchange> {
120
+ const out: Exchange = { paired: false, ok: false, sent: 0, refused: [], received: [], applied: [], held: [] }
121
+ // The computer's id first: a ~/.brain copied from another computer gets its own id here, and drops that one's token.
122
+ const machine = machineId(home)
123
+ const token = readMachineToken(home)
124
+ if (!token) return { ...out, why: "this computer is not paired with your account (brain pair)" }
125
+ out.paired = true
126
+ const file = readSettingsFile(home)
127
+ if (file.problem) return { ...out, why: file.problem === "newer" ? "settings.json was written by a newer brain: update brain to exchange settings here" : "settings.json could not be read: `brain settings set` writes a new one" }
128
+ const base = gatewayUrl(home)
129
+ if (!safeUrl(`${base}/api/brain/settings`)) return { ...out, why: `the gateway address ${base} is not https; the token is never sent over it` }
130
+ const deadline = Date.now() + (o.timeoutMs ?? 3_000)
131
+ const call = async (method: string, path: string, body?: unknown): Promise<{ status: number; json: unknown }> => {
132
+ const left = deadline - Date.now()
133
+ if (left <= 0) throw Object.assign(new Error("out of time"), { name: "TimeoutError" })
134
+ let res: Response
135
+ try {
136
+ res = await (o.fetchImpl ?? fetch)(`${base}${path}`, {
137
+ method,
138
+ headers: { Authorization: `Bearer ${token}`, ...(body ? { "Content-Type": "application/json" } : {}) },
139
+ ...(body ? { body: JSON.stringify(body) } : {}),
140
+ // A redirect is not followed: it could carry the token to another host.
141
+ redirect: "manual",
142
+ signal: AbortSignal.timeout(Math.max(200, left)),
143
+ })
144
+ } catch (e) {
145
+ const n = (e as Error)?.name
146
+ if (n === "TimeoutError" || n === "AbortError") throw e
147
+ throw new Unreachable(String((e as Error)?.message ?? e))
148
+ }
149
+ // Reading the body may run out of time too: that is a failed exchange, never an empty answer.
150
+ const text = await res.text()
151
+ let json: unknown
152
+ try {
153
+ json = text ? JSON.parse(text) : undefined
154
+ } catch {
155
+ json = undefined
156
+ }
157
+ return { status: res.status, json }
158
+ }
159
+ const said = (j: any) => String(j?.error?.message ?? j?.error ?? j?.message ?? "").slice(0, 200)
160
+ const fail = (why: string): Exchange => {
161
+ try {
162
+ updateDoc(home, (d) => {
163
+ d.sync.lastError = why
164
+ })
165
+ } catch {}
166
+ return { ...out, why }
167
+ }
168
+
169
+ try {
170
+ // A computer the account knows afresh: everything set here goes again (see sendAllAgain).
171
+ if (file.doc.sync.machine !== machine) sendAllAgain(home)
172
+ // A change this computer cannot send as its own (an older brain queued the dashboard's) would wait for good and keep
173
+ // the account's value from being taken: it leaves the outbox.
174
+ if (readSettingsFile(home).doc.outbox.some((c) => !COMPUTER_SOURCES.includes(c.source)))
175
+ updateDoc(home, (d) => {
176
+ d.outbox = d.outbox.filter((c) => COMPUTER_SOURCES.includes(c.source))
177
+ })
178
+ // 1. Send what changed here.
179
+ const snapshot = readSettingsFile(home).doc.outbox
180
+ for (const level of ["computer", "person"] as const) {
181
+ const changes = snapshot.filter((c) => {
182
+ if (c.level !== level) return false
183
+ const r = parseId(c.id)
184
+ return !("error" in r) && r.family.remote === "yes"
185
+ })
186
+ if (!changes.length) continue
187
+ const r = await call("PATCH", "/api/brain/settings", { level: LEVEL_OUT[level], machine, changes: changes.map((c) => ({ id: c.id, value: c.value, source: c.source, at: c.at })) })
188
+ if (r.status !== 200) return fail(trouble(r.status, said(r.json)))
189
+ const j = obj(r.json)
190
+ if (!Array.isArray(j.accepted) || !Array.isArray(j.refused)) throw new Unreadable("PATCH")
191
+ const accepted = new Map<string, number>()
192
+ for (const a of j.accepted) if (typeof a?.id === "string") accepted.set(a.id, Number(a.rev))
193
+ const refused = j.refused.filter((x: any) => typeof x?.id === "string").map((x: any) => ({ id: String(x.id).slice(0, 80), code: String(x.code ?? "refused").slice(0, 40), message: String(x.message ?? "").slice(0, 200) }))
194
+ out.sent += accepted.size
195
+ out.refused.push(...refused)
196
+ const settledIds = new Set([...accepted.keys(), ...refused.map((x: { id: string }) => x.id)])
197
+ updateDoc(home, (d) => {
198
+ // Only what was sent leaves the outbox: a change made here meanwhile waits for the next exchange.
199
+ d.outbox = d.outbox.filter((c) => !(c.level === level && settledIds.has(c.id) && changes.some((s) => s.id === c.id && s.at === c.at && same(s.value, c.value))))
200
+ for (const [id, rev] of accepted) {
201
+ const e = d.levels[level][id]
202
+ if (e && Number.isSafeInteger(rev)) e.rev = rev
203
+ }
204
+ const at = new Date().toISOString()
205
+ for (const x of refused) {
206
+ const sent = changes.find((c) => c.id === x.id)
207
+ d.sync.refused.push({ id: x.id, level, value: sent?.value ?? null, code: x.code, message: x.message, at })
208
+ }
209
+ d.sync.refused = d.sync.refused.slice(-20)
210
+ })
211
+ }
212
+
213
+ // 2. Fetch what the account has. After a refusal the account's revision has not moved, but this computer must go back
214
+ // to the account's value: the whole state is fetched then.
215
+ const since = out.refused.length ? "" : (readSettingsFile(home).doc.sync.rev ?? "")
216
+ const r = await call("GET", `/api/brain/settings?machine=${encodeURIComponent(machine)}&since=${encodeURIComponent(since)}`)
217
+ if (r.status !== 200) return fail(trouble(r.status, said(r.json)))
218
+ const j = obj(r.json)
219
+ const now = new Date().toISOString()
220
+ if (j.unchanged === true && revParts(j.rev)) {
221
+ updateDoc(home, (d) => {
222
+ d.sync.lastSyncAt = now
223
+ delete d.sync.lastError
224
+ })
225
+ } else {
226
+ // Only an answer of the contract's shape is taken: anything else (an HTML page, `{}`) would erase every setting here.
227
+ const fetched = revParts(j.rev)
228
+ if (!fetched || !isObj(j.machine) || !isObj(j.machine.entries) || !isObj(j.account) || !isObj(j.account.entries)) throw new Unreadable("GET")
229
+ // 3. Take it.
230
+ const org = !!readOrgConfig(home)
231
+ updateDoc(home, (d) => {
232
+ // Another brain (a second terminal) may have taken a newer answer meanwhile: an older one is not taken over it.
233
+ const stored = revParts(d.sync.rev)
234
+ if (stored && (fetched[0] < stored[0] || fetched[1] < stored[1])) return
235
+ const pending = new Set(d.outbox.map((c) => `${c.level}\0${c.id}`))
236
+ // The levels as they were, to tell what taking the answer changes in the value in force here.
237
+ const before: SettingsDoc = { ...d, levels: { ...d.levels } }
238
+ // The same value written again on the account (a newer rev at another time: the dashboard's "try again").
239
+ const again: string[] = []
240
+ const next: Record<"computer" | "person", Record<string, Entry>> = { computer: {}, person: {} }
241
+ for (const [level, srv] of [
242
+ ["computer", j.machine.entries],
243
+ ["person", j.account.entries],
244
+ ] as Array<["computer" | "person", Record<string, unknown>]>) {
245
+ // Kept from here: settings only this computer can have, and changes not sent yet.
246
+ for (const [id, e] of Object.entries(d.levels[level])) {
247
+ const ref = parseId(id)
248
+ if (pending.has(`${level}\0${id}`) || "error" in ref || ref.family.remote !== "yes") next[level][id] = e
249
+ }
250
+ for (const [id, raw] of Object.entries(srv)) {
251
+ if (!Object.hasOwn(srv, id) || pending.has(`${level}\0${id}`) || Object.hasOwn(next[level], id)) continue
252
+ const e = entryFrom(id, level, raw)
253
+ if (!e) continue
254
+ const was = d.levels[level][id]
255
+ if (was && same(was.value, e.value) && was.source === e.source) {
256
+ if (e.rev !== undefined && was.rev !== undefined && e.rev > was.rev && e.at !== was.at) {
257
+ next[level][id] = { ...e, ...(was.command ? { command: was.command } : {}) }
258
+ again.push(id)
259
+ }
260
+ // The same value from the same source: kept as it is here (with the command that set it).
261
+ else next[level][id] = { ...was, ...(e.rev !== undefined ? { rev: e.rev } : {}) }
262
+ } else next[level][id] = e
263
+ }
264
+ }
265
+ // Memory turned on from afar on a computer that sends it to a team server: only this computer turns that on. Judged
266
+ // by the effect (whatever level or source did it): memory off before, on after.
267
+ const held: Array<{ id: string; why: string }> = []
268
+ if (org && !memoryOn(home, d) && memoryOn(home, { ...d, levels: { ...d.levels, computer: next.computer, person: next.person } })) {
269
+ for (const level of ["computer", "person"] as const) {
270
+ const was = d.levels[level]["memory.enabled"]
271
+ if (was) next[level]["memory.enabled"] = was
272
+ else delete next[level]["memory.enabled"]
273
+ }
274
+ held.push({ id: "memory.enabled", why: MEMORY_HELD })
275
+ d.applied["memory.enabled"] = { want: true, ok: false, at: now, error: MEMORY_HELD }
276
+ }
277
+ for (const level of ["computer", "person"] as const) {
278
+ for (const id of new Set([...Object.keys(d.levels[level]), ...Object.keys(next[level])])) {
279
+ const was = d.levels[level][id]
280
+ const now2 = next[level][id]
281
+ if (was && now2 && same(was.value, now2.value) && was.source === now2.source) continue
282
+ if (!was && !now2) continue
283
+ // A change from elsewhere goes into the history, with what it replaced here. A removal at this computer's
284
+ // level comes from the dashboard; one for all the person's computers may come from another computer.
285
+ d.history.push({
286
+ id,
287
+ level,
288
+ value: now2 ? now2.value : null,
289
+ source: now2?.source ?? (level === "computer" ? "dashboard" : "settings"),
290
+ at: now2?.at ?? now,
291
+ ...(was ? { replaced: { value: was.value, source: was.source, at: was.at, ...(was.command ? { command: was.command } : {}) } } : {}),
292
+ })
293
+ out.received.push(id)
294
+ }
295
+ d.levels[level] = next[level]
296
+ }
297
+ // Team defaults and locks come with step 4: until then the account sends none.
298
+ const locks: SettingsDoc["locks"] = {}
299
+ for (const [id, l] of Object.entries(obj(j.locks))) if (Object.hasOwn(obj(j.locks), id) && isValue(obj(l).value) && !("error" in parseId(id))) locks[id] = { value: obj(l).value }
300
+ d.locks = locks
301
+ // The value in force here set from the account (set, set again, the same value from another source, or cleared down
302
+ // to another level's equal value) is applied afresh, even where it failed or was changed by hand since: that is the
303
+ // person setting it (again). A change at a level that does not decide here applies nothing.
304
+ // A setting with nothing to apply set again is still taken, so what it gave is said (below, after applying).
305
+ for (const id of new Set([...out.received, ...again])) {
306
+ const ref = parseId(id)
307
+ if ("error" in ref) continue
308
+ const a = resolve(d, ref)
309
+ if (a.value === undefined || (a.from !== "computer" && a.from !== "person" && a.from !== "team")) continue
310
+ const b = resolve(before, ref)
311
+ if (b.from === a.from && sameEntry(b.entry, a.entry)) continue
312
+ if (ref.family.apply) delete d.applied[id]
313
+ if (!out.received.includes(id)) out.received.push(id)
314
+ }
315
+ d.history = d.history.slice(-200)
316
+ d.sync.rev = `${fetched[0]}.${fetched[1]}`
317
+ d.sync.lastSyncAt = now
318
+ d.sync.machine = machine
319
+ delete d.sync.lastError
320
+ d.sync.held = held
321
+ out.held = held
322
+ })
323
+ }
324
+ out.ok = true
325
+ } catch (e) {
326
+ const name = (e as Error)?.name
327
+ if (name === "TimeoutError" || name === "AbortError") return fail("the account did not answer in time; changes made here wait for the next exchange")
328
+ if (e instanceof Unreachable) return fail(`could not reach ${base}`)
329
+ if (e instanceof Unreadable) return fail("the account's answer could not be read; nothing here was changed")
330
+ return fail(`the exchange stopped: ${String((e as Error)?.message ?? e).slice(0, 200)}`)
331
+ }
332
+
333
+ // 4. Apply what changed, and say what it gave (a value held back here counts as failed).
334
+ if (o.apply !== false) {
335
+ let elsewhere: string[] = []
336
+ try {
337
+ const r = await reconcile(home, { env: o.env ?? process.env })
338
+ out.applied = r.applied
339
+ elsewhere = r.elsewhere
340
+ } catch {}
341
+ const applied: Record<string, unknown> = {}
342
+ const at = new Date().toISOString()
343
+ for (const row of out.applied)
344
+ applied[row.id] = { want: row.value ?? null, ok: row.state !== "failed" && row.state !== "not on this computer" && row.state !== "can't read", ...(row.error ? { error: row.error } : {}), ...(row.notes.length ? { notes: row.notes.slice(0, 5) } : {}), at }
345
+ // A value taken that has nothing to apply (brain reads it where it is used: lean.*, memory.enabled) is in force once
346
+ // taken; one for a tool not on this computer waits for that tool. Both are said, so the dashboard never waits on them.
347
+ // (A value in force set from the account is applied afresh above, so it is among the applied rows.)
348
+ if (out.received.length) {
349
+ const doc = readSettingsFile(home).doc
350
+ for (const id of out.received) {
351
+ const ref = parseId(id)
352
+ if ("error" in ref || Object.hasOwn(applied, id) || out.held.some((h) => h.id === id)) continue
353
+ const want = resolve(doc, ref).value
354
+ if (want === undefined) continue
355
+ if (!ref.family.apply) applied[id] = { want, ok: true, at }
356
+ else if (elsewhere.includes(id)) applied[id] = { want, ok: false, error: `${ref.tool ?? "this tool"} is not on this computer: brain applies it once it is`, at }
357
+ }
358
+ }
359
+ for (const h of out.held) applied[h.id] = { want: true, ok: false, error: h.why, at }
360
+ if (Object.keys(applied).length && Date.now() < deadline) {
361
+ try {
362
+ await call("POST", "/api/brain/settings/applied", { machine, applied })
363
+ } catch {}
364
+ }
365
+ }
366
+ return out
367
+ }
368
+
369
+ /** What `brain status` says about the account exchange. */
370
+ export function exchangeState(home: string): {
371
+ paired: boolean
372
+ lastSyncAt?: string
373
+ lastError?: string
374
+ waiting: number
375
+ refused: SettingsDoc["sync"]["refused"]
376
+ held: Array<{ id: string; why: string }>
377
+ } {
378
+ const d = readSettingsFile(home).doc
379
+ const waiting = d.outbox.filter((c) => {
380
+ const r = parseId(c.id)
381
+ return !("error" in r) && r.family.remote === "yes"
382
+ }).length
383
+ return {
384
+ paired: !!readMachineToken(home),
385
+ ...(d.sync.lastSyncAt ? { lastSyncAt: d.sync.lastSyncAt } : {}),
386
+ ...(d.sync.lastError ? { lastError: d.sync.lastError } : {}),
387
+ waiting,
388
+ refused: d.sync.refused,
389
+ held: d.sync.held,
390
+ }
391
+ }