@100xprompt/brain 0.1.3 → 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.
@@ -0,0 +1,439 @@
1
+ // `brain settings`, `brain status` and `brain sync`: the command line's way into brain's settings (catalog.ts lists them,
2
+ // apply.ts changes them). The dashboard and, later, the page on this computer are the other ways in; all three see the
3
+ // same settings and the same record of who changed what.
4
+ import { type Env, MODEL_ALIASES, modelName } from "../gateway/common.ts"
5
+ import { type Family, FAMILIES, parseId, showValue, toolName } from "./catalog.ts"
6
+ import { listSettings, type Outcome, reconcile, type Row, rowOf, setSetting, unsetSetting } from "./apply.ts"
7
+ import { type Change, type Entry, readDoc, readSettingsFile, settingsFile } from "./store.ts"
8
+ import { readRecordedEnv } from "./env.ts"
9
+ import { type Exchange, exchange, exchangeState } from "./account.ts"
10
+
11
+ type Io = { out(l: string): void; err?(l: string): void }
12
+
13
+ const USAGE = [
14
+ "brain settings [list] [--tool <id>] [--json] every setting on this computer: its value, where it comes from, and whether it is applied",
15
+ "brain settings get <id> [--json] one setting, with its levels and recent changes",
16
+ "brain settings set <id> <value> [--for computer|me] change it here (default), or for all your computers",
17
+ "brain settings unset <id> [--for computer|me] clear it at that level: the value above applies, or brain leaves it as it is",
18
+ "brain settings history [<id>] [--limit 20] recent changes, by whom and what each replaced",
19
+ "brain settings ids the settings there are, and who may change each",
20
+ ]
21
+
22
+ const pad = (s: string, n: number) => (s.length >= n ? s : s + " ".repeat(n - s.length))
23
+
24
+ /** A time for people, in this computer's time zone: 2026-11-11 10:40. */
25
+ export function when(iso: string | undefined): string {
26
+ const t = iso ? new Date(iso) : undefined
27
+ if (!t || Number.isNaN(t.getTime())) return ""
28
+ const z = (n: number) => String(n).padStart(2, "0")
29
+ return `${t.getFullYear()}-${z(t.getMonth() + 1)}-${z(t.getDate())} ${z(t.getHours())}:${z(t.getMinutes())}`
30
+ }
31
+
32
+ /** Who made a change or an entry: a command, `brain settings`, the dashboard, the team admin. */
33
+ export function by(e: Pick<Entry, "source" | "command">): string {
34
+ switch (e.source) {
35
+ case "command":
36
+ return e.command ? `\`${e.command}\`` : "a brain command"
37
+ case "settings":
38
+ return "`brain settings`"
39
+ case "dashboard":
40
+ return "the dashboard"
41
+ case "admin":
42
+ return "your team admin"
43
+ case "local-page":
44
+ return "the page on this computer"
45
+ }
46
+ }
47
+
48
+ function fromText(r: Row): string {
49
+ switch (r.from) {
50
+ case "lock":
51
+ return "locked by your team admin"
52
+ case "computer":
53
+ return `this computer, by ${by(r.entry!)} ${when(r.entry!.at)}`
54
+ case "person":
55
+ return `all your computers, by ${by(r.entry!)} ${when(r.entry!.at)}`
56
+ case "team":
57
+ return "your team's default"
58
+ case "default":
59
+ return "brain's default"
60
+ case "none":
61
+ return "as found (not set)"
62
+ }
63
+ }
64
+
65
+ function stateText(r: Row): string {
66
+ switch (r.state) {
67
+ case "applied":
68
+ case "default":
69
+ case "as found":
70
+ return r.state === "applied" && r.from !== "default" ? "applied" : ""
71
+ case "waiting":
72
+ return "waiting to be applied (brain sync)"
73
+ case "failed":
74
+ return `failed: ${r.error ?? "unknown reason"}`
75
+ case "changed outside brain":
76
+ return `changed outside brain: it is ${showValue(r.family.type, r.actual.value)} now`
77
+ case "can't read":
78
+ return `can't read it${r.notes[0] ? `: ${r.notes[0]}` : ""}`
79
+ case "locked":
80
+ return "locked"
81
+ default:
82
+ return r.state
83
+ }
84
+ }
85
+
86
+ /** The value a row shows: what applies, or (not set) what this computer has. */
87
+ const shown = (r: Row) => showValue(r.family.type, r.value !== undefined ? r.value : r.actual.value)
88
+
89
+ function printRows(rows: Row[], io: Io) {
90
+ const idw = Math.max(...rows.map((r) => r.id.length), 10) + 2
91
+ const vw = Math.max(...rows.map((r) => shown(r).length), 5) + 2
92
+ const groups = new Map<string, Row[]>()
93
+ for (const r of rows) {
94
+ const g = r.tool ? `${toolName(r.tool)} (${r.tool})` : "brain"
95
+ groups.set(g, [...(groups.get(g) ?? []), r])
96
+ }
97
+ const order = [...groups.keys()].sort((a, b) => (a === "brain" ? 1 : b === "brain" ? -1 : a.localeCompare(b)))
98
+ for (const [i, g] of order.entries()) {
99
+ if (i) io.out("")
100
+ io.out(g)
101
+ for (const r of groups.get(g)!) {
102
+ const st = stateText(r)
103
+ io.out(` ${pad(r.id, idw)}${pad(shown(r), vw)}${fromText(r)}${st ? ` [${st}]` : ""}`)
104
+ if (["failed", "changed outside brain"].includes(r.state)) for (const n of r.notes) io.out(` ${" ".repeat(idw)}note: ${n}`)
105
+ }
106
+ }
107
+ }
108
+
109
+ const rowJson = (r: Row) => ({
110
+ id: r.id,
111
+ ...(r.tool ? { tool: r.tool } : {}),
112
+ value: r.value ?? null,
113
+ from: r.from,
114
+ ...(r.entry ? { entry: r.entry } : {}),
115
+ // A model brain has no short name for is "other": people never see the gateway's model ids.
116
+ actual: r.family.type.kind === "choice" && typeof r.actual.value === "string" && r.actual.value !== "partly" && !r.family.type.choices.includes(r.actual.value) ? "other" : r.actual.value,
117
+ state: r.state,
118
+ ...(r.error ? { error: r.error } : {}),
119
+ ...(r.notes.length ? { notes: r.notes } : {}),
120
+ })
121
+
122
+ function changeLine(c: Change, type?: Family["type"]): string {
123
+ const v = c.value === null ? "cleared" : type ? showValue(type, c.value) : String(c.value)
124
+ const where = c.level === "computer" ? "this computer" : c.level === "person" ? "all your computers" : "team default"
125
+ const rep = c.replaced ? `; replaced ${type ? showValue(type, c.replaced.value) : String(c.replaced.value)} set by ${by(c.replaced)} ${when(c.replaced.at)}` : ""
126
+ return `${when(c.at)} ${c.id} = ${v} (${where}, by ${by(c)}${rep})`
127
+ }
128
+
129
+ function printOutcome(o: Outcome, io: Io, verb: string): number {
130
+ if (o.refused) {
131
+ io.out(`brain settings: ${o.refused}`)
132
+ return 1
133
+ }
134
+ if (o.row) {
135
+ const r = o.row
136
+ const st = stateText(r)
137
+ io.out(`${r.id} = ${shown(r)} (${fromText(r)})${st ? ` ${st}` : ""}`)
138
+ if (r.state === "failed") io.out(` ${verb}, but not applied; \`brain sync\` tries again`)
139
+ if (o.change?.replaced) io.out(` replaced ${showValue(r.family.type, o.change.replaced.value)}, set by ${by(o.change.replaced)} ${when(o.change.replaced.at)}`)
140
+ for (const n of r.notes) io.out(` note: ${n}`)
141
+ }
142
+ for (const n of o.notes) io.out(` ${n}`)
143
+ return o.ok ? 0 : 1
144
+ }
145
+
146
+ function levelFlag(args: string[]): "computer" | "person" | { error: string } {
147
+ const i = args.indexOf("--for")
148
+ if (i < 0) return "computer"
149
+ const v = args[i + 1]
150
+ if (v === "computer" || v === "this") return "computer"
151
+ if (v === "me" || v === "all" || v === "person") return "person"
152
+ return { error: "--for takes computer (this computer, the default) or me (all your computers)" }
153
+ }
154
+
155
+ /** One line on an exchange with the account, when it did something or failed (nothing on a computer that is not paired). */
156
+ export function exchangeLines(x: Exchange, waiting: number): string[] {
157
+ if (!x.paired) return []
158
+ const n = (k: number, one: string) => `${k} ${one}${k === 1 ? "" : "s"}`
159
+ if (!x.ok) return [`account: not reached (${x.why})${waiting ? `; ${n(waiting, "change")} made here wait to be sent` : ""}`]
160
+ const parts: string[] = []
161
+ if (x.sent) parts.push(`sent ${n(x.sent, "change")} made here`)
162
+ if (x.received.length) parts.push(`${n(x.received.length, "change")} from your account taken${x.applied.length ? `, ${x.applied.length} applied here` : ""}`)
163
+ if (x.refused.length) parts.push(`refused by your account: ${x.refused.map((r) => `${r.id} (${r.message || r.code})`).join(", ")}`)
164
+ for (const h of x.held) parts.push(`${h.id} not taken here: ${h.why}`)
165
+ return parts.length ? [`account: ${parts.join("; ")}`] : []
166
+ }
167
+
168
+ /** Exchanges with the account when this computer is paired (within `deadline`: 3 s for the whole command), and says what that did. */
169
+ async function withAccount(home: string, env: Env, say: (l: string) => void, fetchImpl: typeof fetch | undefined, deadline: number): Promise<Exchange | undefined> {
170
+ if (Date.now() >= deadline) return undefined
171
+ const x = await exchange(home, { env, timeoutMs: deadline - Date.now(), ...(fetchImpl ? { fetchImpl } : {}) })
172
+ for (const l of exchangeLines(x, exchangeState(home).waiting)) say(l)
173
+ return x
174
+ }
175
+
176
+ /**
177
+ * `brain settings ...`. `hooks.changed` is called when a set or unset saved something (the dashboard report is sent then,
178
+ * not after a refusal). A paired computer exchanges with the account first (and, after a change, again).
179
+ */
180
+ export async function settingsCommand(
181
+ args: string[],
182
+ home: string,
183
+ io: Io = { out: (l) => console.log(l) },
184
+ env: Env = process.env,
185
+ hooks: { changed?: () => void; fetchImpl?: typeof fetch } = {},
186
+ ): Promise<number> {
187
+ // Options alone (`brain settings --json`, `--tool codex`) mean list.
188
+ const [sub = "list", ...rest] = !args[0] || args[0].startsWith("--") ? ["list", ...args] : args
189
+ const words = rest.filter((a, i) => !a.startsWith("--") && !["--for", "--limit", "--tool"].includes(rest[i - 1] ?? ""))
190
+ const json = rest.includes("--json")
191
+ // With --json the output must stay JSON: notes go to stderr.
192
+ const note = (l: string) => (json ? (io.err ?? ((x: string) => process.stderr.write(`${x}\n`))) : io.out)(l)
193
+ const file = readSettingsFile(home)
194
+ if (file.problem === "newer") note(`note: ${settingsFile(home)} was written by a newer brain; values are shown as this brain reads them, and cannot be changed or applied here`)
195
+ if (file.problem === "damaged") note(`note: ${settingsFile(home)} could not be read: brain's defaults apply (memory counts as off), and it is kept aside on the next change`)
196
+ // What the account changed is taken (and applied) before anything is shown or changed here; the whole command keeps to 3 s.
197
+ const deadline = Date.now() + 3_000
198
+ if (["list", "get", "history", "set", "unset"].includes(sub) && !file.problem) await withAccount(home, env, note, hooks.fetchImpl, deadline)
199
+ switch (sub) {
200
+ case "list": {
201
+ const ti = rest.indexOf("--tool")
202
+ const tool = ti >= 0 ? rest[ti + 1] : undefined
203
+ let rows = await listSettings(home, env)
204
+ if (tool) {
205
+ const { canonicalTool } = await import("./catalog.ts")
206
+ const t = canonicalTool(tool)
207
+ rows = rows.filter((r) => r.tool === t)
208
+ if (!rows.length) {
209
+ io.out(`no settings for "${tool}" on this computer`)
210
+ return 1
211
+ }
212
+ }
213
+ if (json) io.out(JSON.stringify(rows.map(rowJson), null, 1))
214
+ else {
215
+ printRows(rows, io)
216
+ io.out("")
217
+ io.out("change one: brain settings set <id> <value> (brain settings get <id> says more)")
218
+ }
219
+ return 0
220
+ }
221
+ case "get": {
222
+ const ref = parseId(words[0] ?? "")
223
+ if (!words[0] || "error" in ref) {
224
+ io.out(words[0] ? `brain settings: ${(ref as { error: string }).error}` : `usage: ${USAGE[1]}`)
225
+ return words[0] ? 1 : 2
226
+ }
227
+ const doc = readDoc(home)
228
+ const r = await rowOf({ home, env, doc }, ref)
229
+ if (json) {
230
+ io.out(JSON.stringify({ ...rowJson(r), levels: { computer: doc.levels.computer[r.id] ?? null, person: doc.levels.person[r.id] ?? null, team: doc.levels.team[r.id] ?? null }, lock: doc.locks[r.id] ?? null, history: doc.history.filter((c) => c.id === r.id).slice(-10) }, null, 1))
231
+ return 0
232
+ }
233
+ const f = r.family
234
+ const st = stateText(r)
235
+ io.out(`${r.id} = ${shown(r)}`)
236
+ io.out(` what: ${f.help}`)
237
+ io.out(` from: ${fromText(r)}`)
238
+ io.out(` on this computer now: ${showValue(f.type, r.actual.value)}${st ? ` (${st})` : ""}`)
239
+ io.out(` levels: ${(["computer", "person", "team"] as const).map((l) => `${l === "computer" ? "this computer" : l === "person" ? "all your computers" : "team default"} ${showValue(f.type, doc.levels[l][r.id]?.value)}`).join("; ")}${doc.locks[r.id] ? `; locked to ${showValue(f.type, doc.locks[r.id]!.value)}` : ""}`)
240
+ io.out(` type: ${f.type.kind === "bool" ? "on or off" : f.type.kind === "choice" ? f.type.choices.join(", ") : `${f.type.min.toLocaleString("en-US")} to ${f.type.max.toLocaleString("en-US")} ${f.type.unit}`}${f.default !== undefined ? `; default ${showValue(f.type, f.default)}` : ""}`)
241
+ io.out(` changed with: \`brain settings\` on this computer${f.remote === "yes" ? "" : " (it stays a this-computer-only setting)"}`)
242
+ for (const n of r.notes) io.out(` note: ${n}`)
243
+ const hist = doc.history.filter((c) => c.id === r.id).slice(-5)
244
+ if (hist.length) {
245
+ io.out(" recent changes:")
246
+ for (const c of hist) io.out(` ${changeLine(c, f.type)}`)
247
+ }
248
+ return 0
249
+ }
250
+ case "set":
251
+ case "unset": {
252
+ const level = levelFlag(rest)
253
+ if (typeof level === "object") {
254
+ io.out(level.error)
255
+ return 2
256
+ }
257
+ if (sub === "set" ? words.length !== 2 : words.length !== 1) {
258
+ io.out(`usage: ${USAGE[sub === "set" ? 2 : 3]}`)
259
+ return 2
260
+ }
261
+ const o = sub === "set" ? await setSetting(home, words[0]!, words[1]!, { level, env }) : await unsetSetting(home, words[0]!, { level, env })
262
+ if (o.change) hooks.changed?.()
263
+ const code = printOutcome(o, io, sub === "set" ? "saved" : "cleared")
264
+ // The change goes to the account at once.
265
+ if (o.change) await withAccount(home, env, io.out, hooks.fetchImpl, deadline)
266
+ return code
267
+ }
268
+ case "history": {
269
+ const li = rest.indexOf("--limit")
270
+ const limit = li >= 0 ? Math.max(1, Number(rest[li + 1]) || 20) : 20
271
+ const ref = words[0] ? parseId(words[0]) : undefined
272
+ if (ref && "error" in ref) {
273
+ io.out(`brain settings: ${ref.error}`)
274
+ return 1
275
+ }
276
+ const doc = readDoc(home)
277
+ const hist = doc.history.filter((c) => !ref || c.id === ref.id).slice(-limit)
278
+ if (!hist.length) io.out("no changes recorded yet")
279
+ for (const c of hist) {
280
+ const r = parseId(c.id)
281
+ io.out(changeLine(c, "error" in r ? undefined : r.family.type))
282
+ }
283
+ return 0
284
+ }
285
+ case "ids": {
286
+ for (const f of FAMILIES) {
287
+ const type = f.type.kind === "bool" ? "on/off" : f.type.kind === "choice" ? f.type.choices.join("|") : `${f.type.min}-${f.type.max} ${f.type.unit}`
288
+ io.out(`${pad(f.key.replace("*", "<tool>"), 26)}${pad(type, 30)}${f.label}${f.remote === "no" ? " (this computer only)" : ""}`)
289
+ }
290
+ io.out("")
291
+ io.out(`<tool>: a tool's id, as \`brain settings list\` shows it (claude-code, codex, cursor-ide, gemini-cli, ...)`)
292
+ return 0
293
+ }
294
+ case "help":
295
+ case "--help":
296
+ case "-h":
297
+ USAGE.forEach((l) => io.out(l))
298
+ return 0
299
+ default:
300
+ io.out(`unknown settings command "${sub}"`)
301
+ USAGE.forEach((l) => io.out(`usage: ${l}`))
302
+ return 2
303
+ }
304
+ }
305
+
306
+ // ---- brain status -------------------------------------------------------------------------------------------------------
307
+
308
+ const ago = (iso: string | undefined) => {
309
+ const t = iso ? Date.parse(iso) : NaN
310
+ if (!Number.isFinite(t)) return ""
311
+ const m = Math.max(0, Math.round((Date.now() - t) / 60_000))
312
+ return m < 1 ? "just now" : m < 60 ? `${m}m ago` : m < 48 * 60 ? `${Math.round(m / 60)}h ago` : `${Math.round(m / 1440)}d ago`
313
+ }
314
+
315
+ /** A model by its 100xprompt name (100xprompt Pro), never the gateway's id. */
316
+ const productName = (alias: unknown) => (typeof alias === "string" && MODEL_ALIASES[alias] ? modelName(MODEL_ALIASES[alias]!) : "another model")
317
+
318
+ /**
319
+ * `brain status`: this computer at a glance, with whether its settings are applied. On a paired computer it first exchanges
320
+ * with the account (so a change made in the dashboard is taken and applied); otherwise it only reads.
321
+ */
322
+ export async function statusCommand(home: string, io: Io = { out: (l) => console.log(l) }, env: Env = process.env, deps: { fetchImpl?: typeof fetch } = {}): Promise<number> {
323
+ const exchanged = readSettingsFile(home).problem ? undefined : await exchange(home, { env, ...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}) })
324
+ const { VERSION } = await import("../self.ts")
325
+ const { machineName, gatewayState } = await import("../dashboard/report.ts")
326
+ const { machineId, readCache } = await import("../dashboard/state.ts")
327
+ const { canReport, gatewayUrl, sendingOff, signedIn } = await import("../dashboard/send.ts")
328
+ const { readOrgConfig } = await import("../org/config.ts")
329
+ const { leanSettings, memoryOn } = await import("./values.ts")
330
+ const { chittaCandidates, memoryDbPath } = await import("../memory/store.ts")
331
+ const { existsSync } = await import("node:fs")
332
+ const file = readSettingsFile(home)
333
+ const doc = file.doc
334
+ const rows = await listSettings(home, env)
335
+ const email = gatewayState(home).email
336
+
337
+ io.out(`brain ${VERSION} on ${machineName()} (machine ${machineId(home).slice(0, 8)})`)
338
+ if (file.problem === "damaged") io.out(`note: ${settingsFile(home)} could not be read: brain's defaults apply and memory counts as off until it is fixed (\`brain settings set\` writes a new one)`)
339
+ if (file.problem === "newer") io.out(`note: ${settingsFile(home)} was written by a newer brain: update brain to change or apply settings here`)
340
+ io.out(`gateway: ${signedIn(home) ? `signed in${typeof email === "string" && email ? ` as ${email}` : ""} (${gatewayUrl(home)})` : "not signed in (brain gateway login)"}`)
341
+ const off = sendingOff(home, env)
342
+ const push = readCache(home).push ?? {}
343
+ io.out(`reports: ${off ? `off (${off})` : canReport(home) ? `on${push.lastSentAt ? `, last sent ${ago(push.lastSentAt)}` : ", none sent yet"}${push.lastError ? `; the last attempt failed: ${push.lastError}` : ""}` : "on, once you sign in"}`)
344
+ const org = env.BRAIN_ORG !== "off" && readOrgConfig(home)
345
+ // On, and usable: the team's server, or this computer's own store (which needs Chitta, as `brain memory status` says).
346
+ const local = existsSync(memoryDbPath(home)) || chittaCandidates().some((f) => existsSync(f))
347
+ io.out(`memory: ${!memoryOn(home) ? `off${file.problem === "damaged" ? " (the settings file cannot be read)" : " (brain settings: memory.enabled)"}` : org ? `on, kept on your team's server (${org.server})` : local ? "on, kept on this computer" : "on, but not available here (Chitta is not installed: brain memory status)"}`)
348
+ const lean = leanSettings(home, doc)
349
+ io.out(`lean: ${lean.auto ? `automatic from ${lean.threshold.toLocaleString("en-US")} tokens` : "only with --lean (lean.auto off)"}; outputs over ${lean.minChars.toLocaleString("en-US")} characters become digests`)
350
+
351
+ // Each tool with something from brain: on the gateway, brain's tools, or the session hook.
352
+ const byTool = new Map<string, Row[]>()
353
+ for (const r of rows) if (r.tool) byTool.set(r.tool, [...(byTool.get(r.tool) ?? []), r])
354
+ const part = (rs: Row[], key: string) => rs.find((r) => r.family.key === `tools.*.${key}`)
355
+ const on = (r?: Row) => r?.actual.value === true || r?.actual.value === "partly"
356
+ const active = [...byTool.entries()].filter(([, rs]) => ["gateway", "mcp", "hooks"].some((k) => on(part(rs, k)))).sort((a, b) => toolName(a[0]).localeCompare(toolName(b[0])))
357
+ if (active.length) {
358
+ io.out("tools:")
359
+ const w = Math.max(...active.map(([t]) => toolName(t).length)) + 2
360
+ for (const [t, rs] of active) {
361
+ const g = part(rs, "gateway")
362
+ const m = part(rs, "model")
363
+ const ce = part(rs, "contextEdit")
364
+ const bits = [
365
+ g ? `gateway ${g.actual.value === true ? `on (${productName(m?.actual.value)}${ce?.actual.value === false ? ", context editing off" : ""})` : g.actual.value === "partly" ? "partly on" : "off"}` : "",
366
+ part(rs, "mcp") ? `brain tools ${on(part(rs, "mcp")) ? "on" : "off"}` : "",
367
+ part(rs, "hooks") ? `hook ${on(part(rs, "hooks")) ? "on" : "off"}` : "",
368
+ ].filter(Boolean)
369
+ io.out(` ${pad(toolName(t), w)}${bits.join(", ")}`)
370
+ }
371
+ }
372
+ const quiet = byTool.size - active.length
373
+ if (quiet > 0) io.out(`${active.length ? " and" : "tools:"} ${quiet} more tool${quiet === 1 ? "" : "s"} here with nothing from brain (brain settings list shows every setting)`)
374
+
375
+ const set = rows.filter((r) => r.from === "computer" || r.from === "person" || r.from === "team" || r.from === "lock")
376
+ // A value for all the person's computers, for a tool not on this one, is for the others: not a problem here.
377
+ const elsewhere = rows.filter((r) => r.state === "not on this computer" && r.from !== "computer")
378
+ const failed = rows.filter((r) => r.state === "failed" || (r.state === "not on this computer" && r.from === "computer"))
379
+ const drifted = rows.filter((r) => r.state === "changed outside brain")
380
+ const unreadable = rows.filter((r) => r.state === "can't read" && r.from !== "none")
381
+ const waiting = rows.filter((r) => r.state === "waiting")
382
+ const problems = failed.length + drifted.length + unreadable.length + waiting.length
383
+ io.out(`settings: ${set.length} set${problems ? "" : set.length ? ", all applied" : " (brain's defaults and the tools as they are)"}${failed.length ? `; ${failed.length} not applied` : ""}${drifted.length ? `; ${drifted.length} changed outside brain` : ""}${unreadable.length ? `; ${unreadable.length} can't be read` : ""}${waiting.length ? `; ${waiting.length} waiting (brain sync)` : ""}${elsewhere.length ? `; ${elsewhere.length} for tools on your other computers` : ""}`)
384
+ for (const r of failed) io.out(` ${r.id}: ${r.state === "failed" ? `failed: ${r.error}` : "not on this computer"}`)
385
+ for (const r of unreadable) io.out(` ${r.id}: can't read it${r.notes[0] ? ` (${r.notes[0]})` : ""}`)
386
+ for (const r of drifted) io.out(` ${r.id}: brain set ${showValue(r.family.type, r.value)}, it is ${showValue(r.family.type, r.actual.value)} now${r.notes[0] ? ` (${r.notes[0]})` : ""}`)
387
+ const acct = exchangeState(home)
388
+ const waitingNote = acct.waiting ? `; ${acct.waiting} change${acct.waiting === 1 ? "" : "s"} made here wait to be sent` : ""
389
+ if (!acct.paired) io.out(`account: not paired (\`brain pair\` shows this computer's settings in your dashboard)${waitingNote}`)
390
+ else io.out(`account: paired, last exchange ${acct.lastSyncAt ? ago(acct.lastSyncAt) : "never"}${acct.lastError ? `; the last attempt failed: ${acct.lastError}` : ""}${waitingNote}`)
391
+ if (exchanged) for (const l of exchangeLines(exchanged, acct.waiting).filter((l) => !/not reached/.test(l))) io.out(` ${l.replace(/^account: /, "")}`)
392
+ for (const h of acct.held) io.out(` ${h.id}: not taken from your account: ${h.why}`)
393
+ const refused = acct.refused.slice(-3)
394
+ if (refused.length) io.out(` refused by your account (this computer went back to its value): ${refused.map((r) => `${r.id} = ${String(r.value)} (${r.message || r.code})`).join("; ")}`)
395
+ const recorded = readRecordedEnv(home)
396
+ // As the code that obeys them reads them: BRAIN_NO_REPORT anything but empty, 0 or false; BRAIN_NO_MEMORY exactly 1.
397
+ const envOff = [...(off && /BRAIN_NO_REPORT/.test(off) ? ["BRAIN_NO_REPORT"] : []), ...(env.BRAIN_NO_MEMORY === "1" ? ["BRAIN_NO_MEMORY"] : [])]
398
+ if (envOff.length || env.BRAIN_ORG === "off") io.out(`environment: ${[...envOff, ...(env.BRAIN_ORG === "off" ? ["BRAIN_ORG=off"] : [])].join(", ")} set in this shell`)
399
+ if (recorded) io.out(`tool folders: as your shell had them ${when(recorded.recordedAt)}`)
400
+ return 0
401
+ }
402
+
403
+ // ---- brain sync ---------------------------------------------------------------------------------------------------------
404
+
405
+ /**
406
+ * `brain sync`: applies the settings whose value changed since brain last applied them, tries again those that failed,
407
+ * and sends this computer's report now. (With the dashboard's settings it will also send changes made here and fetch
408
+ * those made there.)
409
+ */
410
+ export async function syncCommand(home: string, io: Io = { out: (l) => console.log(l) }, env: Env = process.env, deps: { fetchImpl?: typeof fetch } = {}): Promise<number> {
411
+ // With the account first (what changed there is taken and applied), then everything that changed or failed here.
412
+ const x = await exchange(home, { env, ...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}) })
413
+ if (!x.paired) io.out("account: not paired (`brain pair` shows this computer's settings in your dashboard)")
414
+ else {
415
+ const lines = exchangeLines(x, exchangeState(home).waiting)
416
+ io.out(lines.length ? lines.join("\n") : "account: in step with your account")
417
+ }
418
+ const r = await reconcile(home, { env, retryFailed: true })
419
+ const bad = r.applied.filter((x) => x.state === "failed" || x.state === "not on this computer" || x.state === "can't read")
420
+ const good = r.applied.filter((x) => !bad.includes(x))
421
+ if (!r.applied.length) io.out("settings: nothing to apply; everything set here is applied")
422
+ else io.out(`settings: applied ${good.length}${bad.length ? `, ${bad.length} not` : ""}`)
423
+ for (const x of good) io.out(` ${x.id} = ${showValue(x.family.type, x.value)}`)
424
+ for (const x of bad) io.out(` ${x.id}: ${x.state === "failed" ? `failed: ${x.error}` : "not on this computer"}`)
425
+ for (const id of r.unknown) io.out(` ${id}: not a setting this brain knows; left alone`)
426
+ if (r.elsewhere.length) io.out(` ${r.elsewhere.length} for tools not on this computer (kept for your other computers): ${r.elsewhere.join(", ")}`)
427
+
428
+ const { canReport, gatewayUrl, noticeOnce, sendingOff, sendReport } = await import("../dashboard/send.ts")
429
+ const off = sendingOff(home, env)
430
+ if (!canReport(home)) io.out("report: not sent (not signed in: brain gateway login)")
431
+ else if (off) io.out(`report: not sent (${off})`)
432
+ else {
433
+ noticeOnce(home, io.out)
434
+ const { buildReport } = await import("../dashboard/report.ts")
435
+ const sent = await sendReport(home, await buildReport(home, { env }), { timeoutMs: 5_000, ...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}) })
436
+ io.out(sent.ok ? `report: sent to ${gatewayUrl(home)}` : `report: not sent (${sent.why})`)
437
+ }
438
+ return bad.length || (x.paired && !x.ok) ? 1 : 0
439
+ }
@@ -0,0 +1,101 @@
1
+ // <home>/.brain/env.json: the environment variables that decide where the person's coding tools keep their files, and
2
+ // brain's own opt-outs, as the person's shell has them. A background brain (started by launchd or systemd, later) does not
3
+ // get the person's shell: without this it would edit ~/.claude/settings.json while the person's Claude Code reads
4
+ // $CLAUDE_CONFIG_DIR, and it would send reports the person turned off with BRAIN_NO_REPORT.
5
+ //
6
+ // Recorded by the commands a person runs, never by the ones tools or brain start on their own (the session hook,
7
+ // `brain mcp serve`, background pushes and memory syncs): those run with the tool's environment, not the person's.
8
+ // Only the names below are kept, never anything else (no keys, no tokens); the file is 0600 and stays on this computer.
9
+ import { join } from "node:path"
10
+ import { mkdirSync, readFileSync } from "node:fs"
11
+ import { writeAtomic } from "../fsutil.ts"
12
+
13
+ export const envFile = (home: string) => join(home, ".brain", "env.json")
14
+
15
+ /** Where tools keep their files (what brain's connectors, installers and readers look at), and brain's opt-outs. */
16
+ export const ENV_VARS = [
17
+ "PATH",
18
+ "XDG_CONFIG_HOME",
19
+ "XDG_DATA_HOME",
20
+ "XDG_STATE_HOME",
21
+ "XDG_CACHE_HOME",
22
+ "APPDATA",
23
+ "CLAUDE_CONFIG_DIR",
24
+ "CODEX_HOME",
25
+ "GEMINI_CLI_HOME",
26
+ "HUNDREDXPROMPT_HOME",
27
+ "COPILOT_HOME",
28
+ "COPILOT_PROVIDERS_CONFIG",
29
+ "CONTINUE_GLOBAL_DIR",
30
+ "CRUSH_GLOBAL_DATA",
31
+ "FACTORY_HOME",
32
+ "GOOSE_PATH_ROOT",
33
+ "GROK_HOME",
34
+ "HERMES_HOME",
35
+ "IFLOW_HOME",
36
+ "KIMI_CODE_HOME",
37
+ "LETTA_CONFIG_DIR",
38
+ "LETTA_EXTENSIONS_DIR",
39
+ "LETTA_MODS_DIR",
40
+ "MASTRA_APP_DATA_DIR",
41
+ "OMP_PROFILE",
42
+ "PI_CONFIG_DIR",
43
+ "PI_PROFILE",
44
+ "QWEN_HOME",
45
+ "VIBE_HOME",
46
+ "BUN_INSTALL",
47
+ "BRAIN_CHITTA",
48
+ "BRAIN_CHITTA_DIRS",
49
+ "BRAIN_NO_REPORT",
50
+ "BRAIN_NO_MEMORY",
51
+ "BRAIN_ORG",
52
+ ] as const
53
+
54
+ export interface RecordedEnv {
55
+ /** When these values were first seen (the file is rewritten only when one changes). */
56
+ recordedAt: string
57
+ vars: Record<string, string>
58
+ }
59
+
60
+ export function readRecordedEnv(home: string): RecordedEnv | undefined {
61
+ try {
62
+ const j = JSON.parse(readFileSync(envFile(home), "utf8"))
63
+ if (!j || typeof j !== "object" || typeof j.vars !== "object" || !j.vars) return undefined
64
+ const vars: Record<string, string> = {}
65
+ for (const k of ENV_VARS) if (typeof j.vars[k] === "string") vars[k] = j.vars[k]
66
+ return { recordedAt: typeof j.recordedAt === "string" ? j.recordedAt : "", vars }
67
+ } catch {
68
+ return undefined
69
+ }
70
+ }
71
+
72
+ /** Keeps the person's current values of ENV_VARS (a variable no longer set is dropped). Best effort, never throws. */
73
+ export function recordEnv(home: string, env: Record<string, string | undefined> = process.env): boolean {
74
+ try {
75
+ const vars: Record<string, string> = {}
76
+ for (const k of ENV_VARS) {
77
+ const v = env[k]
78
+ // PATH often lists the same folders many times (each shell startup adds them again): kept once each, in order.
79
+ if (typeof v === "string" && v !== "") vars[k] = k === "PATH" ? [...new Set(v.split(":"))].join(":") : v
80
+ }
81
+ const had = readRecordedEnv(home)
82
+ if (had && JSON.stringify(had.vars) === JSON.stringify(vars)) return false
83
+ mkdirSync(join(home, ".brain"), { recursive: true, mode: 0o700 })
84
+ writeAtomic(envFile(home), `${JSON.stringify({ recordedAt: new Date().toISOString(), vars }, null, 1)}\n`, 0o600)
85
+ return true
86
+ } catch {
87
+ return false
88
+ }
89
+ }
90
+
91
+ /** Whether a command line is one a person runs (so its environment is theirs), not one a tool or brain started. */
92
+ export function isPersonsCommand(argv: string[]): boolean {
93
+ const [cmd, ...rest] = argv
94
+ if (!cmd || ["--help", "-h", "help", "--version", "-v", "version"].includes(cmd)) return false
95
+ // The session hook (run by a tool), the MCP server (started by a tool), and the background children brain starts.
96
+ if (cmd === "hook" || (cmd === "session" && rest[0] === "hook")) return false
97
+ if (cmd === "mcp" && rest[0] === "serve") return false
98
+ if (cmd === "dashboard" && rest[0] === "push" && (rest.includes("--quiet") || rest.includes("--at"))) return false
99
+ if (cmd === "memory" && rest[0] === "sync" && rest.includes("--quiet")) return false
100
+ return true
101
+ }
@@ -0,0 +1,83 @@
1
+ // `brain pair [--no-browser] | status | forget`: gives this computer its own token for the person's 100xprompt account, so
2
+ // its settings show in the dashboard (and, from step 3, can be changed there). The person approves the computer in the
3
+ // browser, signed in to their account (docs/settings-contract.md, section 1); the token comes back to a one-use server on
4
+ // 127.0.0.1, as `brain gateway login` does it, and is kept in ~/.brain/machine.key (0600). Never printed.
5
+ import { machineName } from "../dashboard/report.ts"
6
+ import { hardwareHash, MACHINE_TOKEN, machineId, readMachineToken } from "../dashboard/state.ts"
7
+ import type { Env } from "../gateway/common.ts"
8
+ import { askHidden, type BrowserFlow, browserSignIn, type SignInDeps } from "../gateway/signin.ts"
9
+ import { exchange, exchangeState, forgetMachineToken, sendAllAgain, storeMachineToken } from "./account.ts"
10
+ import { exchangeLines } from "./cli.ts"
11
+
12
+ type Io = { out(l: string): void }
13
+
14
+ /** The approve page's round trip: /auth/brain, told which computer it is approving; a computer token comes back. */
15
+ export function pairFlow(machine: string, name: string, hw: string | null): BrowserFlow {
16
+ return {
17
+ command: "brain pair",
18
+ path: "/auth/brain",
19
+ params: { machine, name, ...(hw ? { hw } : {}) },
20
+ accept: (t) => (MACHINE_TOKEN.test(t.trim()) ? { ok: true, key: t.trim() } : { ok: false, why: "that is not this computer's code from the approve page (it starts with 100x_brain_)" }),
21
+ opening: "approve this computer in your 100xprompt account",
22
+ done: () => ({ title: "Computer approved", body: "brain on this computer is now paired with your 100xprompt account. You can close this tab and go back to your terminal." }),
23
+ paste: "Paste the code from the page's Copy button, or the address the browser could not open",
24
+ }
25
+ }
26
+
27
+ const USAGE = [
28
+ "brain pair [--no-browser] approve this computer in your 100xprompt account, so its settings show in the dashboard",
29
+ "brain pair status whether this computer is paired, and its last exchange with the account",
30
+ "brain pair forget forget this computer's token here (remove the computer in the dashboard to revoke it there)",
31
+ ]
32
+
33
+ export async function pairCommand(args: string[], home: string, io: Io = { out: (l) => console.log(l) }, deps: { env?: Env; fetchImpl?: typeof fetch; signIn?: Partial<SignInDeps> } = {}): Promise<number> {
34
+ const sub = args.find((a) => !a.startsWith("-"))
35
+ if (args.includes("--help") || args.includes("-h") || sub === "help") {
36
+ USAGE.forEach((l) => io.out(l))
37
+ return 0
38
+ }
39
+ if (sub === "status") {
40
+ const st = exchangeState(home)
41
+ io.out(`paired: ${st.paired ? "yes" : "no (brain pair)"}`)
42
+ io.out(`computer: ${machineName()} (${machineId(home)})`)
43
+ if (st.paired) {
44
+ io.out(`last exchange: ${st.lastSyncAt ?? "never"}${st.lastError ? `; the last attempt failed: ${st.lastError}` : ""}`)
45
+ io.out(`changes waiting to be sent: ${st.waiting}`)
46
+ for (const r of st.refused.slice(-5)) io.out(`refused by your account: ${r.id} = ${String(r.value)} (${r.message || r.code})`)
47
+ }
48
+ return 0
49
+ }
50
+ if (sub === "forget") {
51
+ io.out(forgetMachineToken(home) ? "forgot this computer's token here. To revoke it on your account too, remove this computer in the dashboard's Brain view." : "this computer is not paired; nothing to forget")
52
+ return 0
53
+ }
54
+ if (sub) {
55
+ io.out(`unknown pair command "${sub}"`)
56
+ USAGE.forEach((l) => io.out(`usage: ${l}`))
57
+ return 2
58
+ }
59
+ const machine = machineId(home)
60
+ const r = await browserSignIn(
61
+ { say: io.out, ask: process.stdin.isTTY ? askHidden : undefined, browser: !args.includes("--no-browser"), ...deps.signIn },
62
+ pairFlow(machine, machineName(), hardwareHash()),
63
+ )
64
+ if (!r.ok) {
65
+ io.out(`brain pair: ${r.why}`)
66
+ return 1
67
+ }
68
+ const had = !!readMachineToken(home)
69
+ storeMachineToken(home, r.key)
70
+ // The account knows this computer afresh (a first pairing, or again after it was removed): everything set here goes up.
71
+ sendAllAgain(home)
72
+ // Checked at once, by sending what was changed here and fetching what the account has.
73
+ const x = await exchange(home, { timeoutMs: 8_000, ...(deps.env ? { env: deps.env } : {}), ...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}) })
74
+ if (!x.ok && /no longer takes this computer's token|refused this exchange/.test(x.why ?? "")) {
75
+ forgetMachineToken(home)
76
+ io.out(`brain pair: the account did not accept the token (${x.why}); nothing was kept`)
77
+ return 1
78
+ }
79
+ io.out(`paired${had ? " again" : ""}: ${machineName()} is linked to your 100xprompt account; its token is in ~/.brain/machine.key (readable only by you).`)
80
+ io.out(x.ok ? `settings: ${x.sent} change${x.sent === 1 ? "" : "s"} made here sent to your account; the dashboard's Brain view shows this computer's settings.` : `settings: not exchanged yet (${x.why}); \`brain sync\` tries again.`)
81
+ if (x.ok) for (const l of exchangeLines({ ...x, sent: 0 }, 0)) io.out(l)
82
+ return 0
83
+ }