@100xprompt/brain 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -167,10 +167,73 @@ brain dashboard off # send nothing from this machine (brain dashboard o
167
167
  ```
168
168
 
169
169
  The report goes out in the background, at most every 30 seconds, after a command that changes it (`gateway login`,
170
- `connect`, `disconnect`, `session continue`, `handoff`, `mcp install`/`uninstall`, `session hooks install`/`uninstall`),
171
- and never makes that command wait or fail. The first time, brain prints what it sends and how to stop it.
170
+ `connect`, `disconnect`, `session continue`, `handoff`, `mcp install`/`uninstall`, `session hooks install`/`uninstall`,
171
+ `settings set`/`unset`, `memory ingest`/`forget`/`sync`, `org login`/`logout`, `dashboard on`), and never makes that
172
+ command wait or fail. The first time, brain prints what it sends and how to stop it.
172
173
  `BRAIN_NO_REPORT=1` also turns it off. The conversion log is `~/.brain/logs/conversions.jsonl`, readable only by you.
173
174
 
175
+ ## Settings
176
+
177
+ Everything brain can switch on this computer is one list of settings:
178
+
179
+ ```bash
180
+ brain status # this computer at a glance, and whether its settings are applied
181
+ brain settings # every setting here: its value, who set it and when, applied or not
182
+ brain settings set tools.codex.model flash # Codex on 100xprompt Flash (it reconnects Codex)
183
+ brain settings set lean.threshold 80k # make sessions lean by themselves from 80,000 tokens
184
+ brain settings set memory.enabled off # no project memory on this computer
185
+ brain settings unset lean.threshold # back to brain's default
186
+ brain settings history # recent changes, and what each replaced
187
+ brain sync # apply what changed or failed, and send the report now
188
+ ```
189
+
190
+ | Setting | What it does |
191
+ |---|---|
192
+ | `tools.<tool>.gateway` | the tool uses 100xprompt's gateway (as `gateway connect` / `disconnect`) |
193
+ | `tools.<tool>.model` | `pro`, `flash` or `auto`, per tool |
194
+ | `tools.<tool>.contextEdit` | the gateway clears old tool results from long requests |
195
+ | `tools.<tool>.mcp` | the tool has `brain_recall`, `brain_expand` and `brain_map` |
196
+ | `tools.<tool>.hooks` | the session-start hook (this computer only) |
197
+ | `lean.auto`, `lean.threshold`, `lean.minChars` | when a long session is made lean, and from what size an output becomes a digest |
198
+ | `memory.enabled` | project memory |
199
+ | `reports.enabled` | reports to your account (this computer only; the same as `dashboard on`/`off`) |
200
+
201
+ The other commands keep working as before and are recorded too: after `brain gateway connect --tool codex --model flash`,
202
+ `brain settings` shows `tools.codex.model = flash`, set by that command. A value that cannot be applied yet (not signed in,
203
+ say) stays saved and is marked failed with the reason; `brain sync` tries again. If you change a tool's file by hand
204
+ afterwards, brain shows the setting as changed outside brain and does not put it back until you set it again.
205
+
206
+ To see this computer's settings in your dashboard's Brain view, pair it once:
207
+
208
+ ```bash
209
+ brain pair # approve this computer in your 100xprompt account (in the browser); --no-browser on SSH
210
+ brain pair status # paired or not, and the last exchange with the account
211
+ ```
212
+
213
+ To have changes you make in the dashboard apply here by themselves, run brain in the background:
214
+
215
+ ```bash
216
+ brain service install # macOS (launchd) or Linux (systemd --user); every minute it takes and applies dashboard changes
217
+ brain service status # installed, running, and its last round
218
+ brain service uninstall
219
+ ```
220
+
221
+ It uses the folders and opt-outs your shell had when you last ran a brain command (`~/.brain/env.json`), restarts itself
222
+ when you update brain, and waits longer between tries while you are offline. Its log is `~/.brain/logs/service.log`. Run
223
+ `brain service uninstall` before removing brain (if you forget, it stops by itself). Without it, `brain status` or
224
+ `brain sync` take dashboard changes by hand.
225
+
226
+ Pairing gives this computer its own token (`~/.brain/machine.key`, readable only by you), separate from the gateway key
227
+ your tools use: model keys never read or change settings. A paired computer sends the changes made here to your account
228
+ and takes the ones made there when you run `brain status`, `brain settings` or `brain sync` (a few seconds at most; the
229
+ changes wait when you are offline). Hooks and reports stay this-computer-only. Removing the computer in the dashboard
230
+ revokes its token; a `~/.brain` copied to another computer is not paired there.
231
+
232
+ Settings are kept in `~/.brain/settings.json`, readable only by you. Commands you run also note where your tools keep their
233
+ files (`CLAUDE_CONFIG_DIR`, `CODEX_HOME` and the like) and brain's own opt-outs in `~/.brain/env.json`, kept for a
234
+ background brain (coming) to use the same folders; never keys. One brain at a time changes a tool's files: another waits its
235
+ turn (`~/.brain/lock`).
236
+
174
237
  ## Teams
175
238
 
176
239
  Memory can live on one shared server instead of each person's machine, with who may see what enforced there.
@@ -212,6 +275,11 @@ brain mcp serve | install | uninstall | status
212
275
  brain tokens audit | lookups
213
276
  brain gateway login | logout | connect | disconnect | status | tools
214
277
  brain dashboard [push | on | off | status]
278
+ brain status
279
+ brain settings [list | get | set | unset | history | ids]
280
+ brain sync
281
+ brain pair [status | forget]
282
+ brain service install | uninstall | status
215
283
  brain org login | logout | whoami | status | promote | demote | forget | admin ...
216
284
  brain expand <id>
217
285
  brain --version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@100xprompt/brain",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "One working session, any AI coding tool: move sessions between Claude Code, Codex, Cursor, Gemini CLI, OpenCode and more, keep what projects learned, spend fewer tokens. Runs on Bun. By 100xprompt.",
5
5
  "license": "MIT",
6
6
  "author": "Nipurn Agarwal",
package/src/cli.ts CHANGED
@@ -41,6 +41,11 @@ brain org admin init|serve|user|token|team|project|policy|audit|retention shar
41
41
  brain mcp serve | install | uninstall | status [--tool <id>] the brain_recall / brain_expand tools for your AI tools
42
42
  brain gateway login [--no-browser] | logout | connect --tool <id> | disconnect --tool <id> | status | tools sign in to 100xprompt and route a coding tool through its gateway (and back)
43
43
  brain dashboard [push|on|off|status] [--quiet] [--no-open] send this machine's report (tools, gateway, conversions; never chat text) to your 100xprompt account and open its Brain view
44
+ brain status this computer at a glance: sign-in, reports, memory, lean, tools, and whether its settings are applied
45
+ brain settings [list|get|set|unset|history|ids] brain's settings on this computer: who set each, and whether it is applied (\`brain settings help\`)
46
+ brain sync exchange settings with your account, apply what changed or failed, and send this computer's report now
47
+ brain pair [--no-browser] | status | forget approve this computer in your 100xprompt account, so its settings show in the dashboard
48
+ brain service install | uninstall | status run brain in the background, so changes made in your dashboard apply here by themselves
44
49
  brain --version the version, and where this brain runs from
45
50
  brain expand <blob id> the full text of a tool output that was replaced by a digest`
46
51
 
@@ -212,6 +217,41 @@ async function main() {
212
217
  out(`running from ${CLI_PATH}${isEphemeral() ? " (a temporary bunx copy: hooks and MCP entries it installs run the pinned package)" : ""}`)
213
218
  return
214
219
  }
220
+ // A command the person runs records where their tools keep their files (env.json), for a brain that runs without their shell.
221
+ {
222
+ const { isPersonsCommand, recordEnv } = await import("./settings/env.ts")
223
+ if (isPersonsCommand(process.argv.slice(2))) recordEnv(home)
224
+ }
225
+ if (cmd === "settings") {
226
+ const { settingsCommand } = await import("./settings/cli.ts")
227
+ let changed = false
228
+ const code = await settingsCommand(rest, home, undefined, undefined, { changed: () => (changed = true) })
229
+ // The report carries the settings: sent when one was saved, never after a refusal.
230
+ if (changed) await reportLater()
231
+ process.exit(code)
232
+ }
233
+ if (cmd === "status") {
234
+ const { statusCommand } = await import("./settings/cli.ts")
235
+ let changed = false
236
+ const code = await statusCommand(home, undefined, undefined, { changed: () => (changed = true) })
237
+ if (changed) await reportLater()
238
+ process.exit(code)
239
+ }
240
+ if (cmd === "service") {
241
+ const { serviceCommand } = await import("./service.ts")
242
+ process.exit(await serviceCommand(rest, home))
243
+ }
244
+ if (cmd === "pair") {
245
+ const { pairCommand } = await import("./settings/pair.ts")
246
+ const code = await pairCommand(rest, home)
247
+ // A newly paired computer reports at once, so the dashboard shows it as paired.
248
+ if (code === 0 && !rest.some((a) => !a.startsWith("-"))) await reportLater()
249
+ process.exit(code)
250
+ }
251
+ if (cmd === "sync") {
252
+ const { syncCommand } = await import("./settings/cli.ts")
253
+ process.exit(await syncCommand(home))
254
+ }
215
255
  // Commands with their own flags: `mcp serve` must keep stdout clean for the protocol, so they run before option parsing.
216
256
  if (cmd === "gateway") {
217
257
  const { gatewayCommand } = await import("./gateway/connect.ts")
@@ -227,7 +267,10 @@ async function main() {
227
267
  }
228
268
  if (cmd === "dashboard") {
229
269
  const { dashboardCommand } = await import("./dashboard/cli.ts")
230
- process.exit(await dashboardCommand(rest, home))
270
+ const code = await dashboardCommand(rest, home)
271
+ // Turned back on: the dashboard gets this computer as it is now.
272
+ if (code === 0 && rest[0] === "on") await reportLater()
273
+ process.exit(code)
231
274
  }
232
275
  if (cmd === "memory" && rest[0] === "sync") {
233
276
  const { syncMemory } = await import("./memory/sync.ts")
@@ -235,6 +278,7 @@ async function main() {
235
278
  const limitAt = rest.indexOf("--limit")
236
279
  const r = await syncMemory({ home, ...(here ? { cwd: process.cwd() } : {}), ...(limitAt >= 0 ? { limit: Number(rest[limitAt + 1]) || 8 } : {}) })
237
280
  if (!rest.includes("--quiet")) {
281
+ if (r.ingested) await reportLater()
238
282
  if (r.unavailable) out(`memory sync: memory is not available (${r.unavailable})`)
239
283
  else if (r.busy) out("memory sync: another sync is running")
240
284
  else out(`memory sync: ${r.ingested} session${r.ingested === 1 ? "" : "s"} (${r.items} items) added, ${r.upToDate} already up to date${r.deferred ? `, ${r.deferred} left for the next run` : ""}${r.errors.length ? `; could not read ${r.errors.length}` : ""} (${(r.ms / 1000).toFixed(1)}s)`)
@@ -243,7 +287,10 @@ async function main() {
243
287
  }
244
288
  if (cmd === "memory") {
245
289
  const { memoryCommand } = await import("./memory/cli.ts")
246
- process.exit(await memoryCommand(rest, home, async (id) => readRef(await resolveRef(id)), out))
290
+ const code = await memoryCommand(rest, home, async (id) => readRef(await resolveRef(id)), out)
291
+ // The report carries memory's counts.
292
+ if (code === 0 && (rest[0] === "ingest" || rest[0] === "forget")) await reportLater()
293
+ process.exit(code)
247
294
  }
248
295
  if (cmd === "tokens") {
249
296
  const { tokensCommand } = await import("./memory/audit.ts")
@@ -251,7 +298,13 @@ async function main() {
251
298
  }
252
299
  if (cmd === "org") {
253
300
  const { orgCommand } = await import("./org/cli.ts")
254
- process.exit(await orgCommand(rest, home, { out }))
301
+ const { orgConfigPath } = await import("./org/config.ts")
302
+ const { existsSync } = await import("node:fs")
303
+ const was = existsSync(orgConfigPath(home))
304
+ const code = await orgCommand(rest, home, { out })
305
+ // Joining or leaving team memory changes the report's memory part (a logout that left nothing changes nothing).
306
+ if (code === 0 && (rest[0] === "login" || (rest[0] === "logout" && was))) await reportLater()
307
+ process.exit(code)
255
308
  }
256
309
  if (cmd === "expand") {
257
310
  const id = rest[0] ?? fail("expand needs a blob id (from a digest: \"id=...\")")
@@ -329,13 +382,26 @@ async function main() {
329
382
  if (sub === "hooks") {
330
383
  const action = positionals[0] ?? "status"
331
384
  const tools = values.tool ? [values.tool] : HOOK_TOOLS
332
- const changes = action === "install" ? await installHooks(tools, home, { dryRun: !!values["dry-run"] })
333
- : action === "uninstall" ? await uninstallHooks(tools, home, { dryRun: !!values["dry-run"] })
334
- : action === "status" ? await installHooks(tools, home, { dryRun: true })
335
- : fail(`hooks ${action}? use install, uninstall or status`)
385
+ if (!["install", "uninstall", "status"].includes(action)) fail(`hooks ${action}? use install, uninstall or status`)
386
+ const change = action !== "status" && !values["dry-run"]
387
+ // A real install or uninstall is recorded as this computer's setting (tools.<tool>.hooks) in the same hold of the brain
388
+ // lock as the change, so `brain settings` shows who set it.
389
+ const { recordChanges } = change ? await import("./settings/apply.ts") : { recordChanges: undefined }
390
+ const { withBrainLock } = await import("./lock.ts")
391
+ const run = async () => {
392
+ const cs = action === "install" ? await installHooks(tools, home, { dryRun: !!values["dry-run"] })
393
+ : action === "uninstall" ? await uninstallHooks(tools, home, { dryRun: !!values["dry-run"] })
394
+ : await installHooks(tools, home, { dryRun: true })
395
+ if (recordChanges) {
396
+ const done = cs.filter((c) => (action === "install" ? ["installed", "already-installed"] : ["removed", "not-installed"]).includes(c.action))
397
+ recordChanges(home, done.map((c) => ({ id: `tools.${c.tool}.hooks`, value: action === "install" })), `brain session hooks ${action}`)
398
+ }
399
+ return cs
400
+ }
401
+ const changes = change ? await withBrainLock(home, run, { what: `${action === "install" ? "installing" : "removing"} session hooks` }) : await run()
336
402
  for (const c of changes) out(`${c.tool.padEnd(14)} ${c.action}${c.why ? ` (${c.why})` : ""} ${short(c.path)}`)
337
403
  if (!changes.length) out("no tools with session-start hooks found")
338
- if ((action === "install" || action === "uninstall") && !values["dry-run"]) await reportLater()
404
+ if (change) await reportLater()
339
405
  return
340
406
  }
341
407
  if (sub === "hook") {
@@ -348,4 +414,11 @@ async function main() {
348
414
  fail(`unknown command "${sub}"\n\n${USAGE}`)
349
415
  }
350
416
 
351
- await main()
417
+ try {
418
+ await main()
419
+ } catch (e) {
420
+ // Another brain holding the lock too long, or a settings file from a newer brain: a sentence, not a stack trace.
421
+ const name = (e as Error)?.name
422
+ if (name === "BrainBusy" || (e as Error)?.constructor?.name === "SettingsUnwritable") fail((e as Error).message)
423
+ throw e
424
+ }
@@ -5,7 +5,7 @@ import type { Reader } from "../adapters/types.ts"
5
5
  import type { Env } from "../gateway/common.ts"
6
6
  import { openInBrowser } from "../gateway/signin.ts"
7
7
  import { readConversions } from "./conversions.ts"
8
- import { DASHBOARD_URL, gatewayUrl, noticeOnce, reportUrl, sendingOff, sendReport, signedIn, startScheduled } from "./send.ts"
8
+ import { canReport, DASHBOARD_URL, gatewayUrl, noticeOnce, reportUrl, sendingOff, sendReport, signedIn, startScheduled } from "./send.ts"
9
9
  import type { BrainReport } from "./report.ts"
10
10
  import { readCache, readSettings, updateSettings } from "./state.ts"
11
11
 
@@ -35,6 +35,26 @@ export function summary(r: BrainReport): string {
35
35
  return `${plural(r.harnesses.length, "tool")} (${connected} connected to the gateway)${counted.length ? `, ${plural(sessions, "session")}` : ""}, ${plural(r.totals.conversions, "conversion")}`
36
36
  }
37
37
 
38
+ /**
39
+ * `brain dashboard on|off`: the choice in dashboard.json, recorded as this computer's reports.enabled setting in the same
40
+ * hold of the brain lock, so `brain settings` shows who set it.
41
+ */
42
+ async function setReports(home: string, on: boolean) {
43
+ const { recordChanges } = await import("../settings/apply.ts")
44
+ const { withBrainLockSync } = await import("../lock.ts")
45
+ withBrainLockSync(
46
+ home,
47
+ () => {
48
+ updateSettings(home, (s) => {
49
+ if (on) delete s.off
50
+ else s.off = true
51
+ })
52
+ recordChanges(home, [{ id: "reports.enabled", value: on }], `brain dashboard ${on ? "on" : "off"}`)
53
+ },
54
+ { what: `turning reports ${on ? "on" : "off"}` },
55
+ )
56
+ }
57
+
38
58
  export async function dashboardCommand(
39
59
  args: string[],
40
60
  home: string,
@@ -74,7 +94,7 @@ export async function dashboardCommand(
74
94
 
75
95
  switch (sub) {
76
96
  case undefined: {
77
- if (!signedIn(home)) {
97
+ if (!canReport(home)) {
78
98
  io.out(notSignedIn)
79
99
  return 1
80
100
  }
@@ -92,7 +112,7 @@ export async function dashboardCommand(
92
112
  // `--at <ms>`: a background push started by another command waits for its turn (send.ts), then reads the state.
93
113
  const at = atAt >= 0 ? Number(args[atAt + 1]) : NaN
94
114
  if (Number.isFinite(at)) await startScheduled(home, at, env)
95
- if (!signedIn(home)) {
115
+ if (!canReport(home)) {
96
116
  say(notSignedIn)
97
117
  return quiet ? 0 : 1
98
118
  }
@@ -105,15 +125,11 @@ export async function dashboardCommand(
105
125
  return 0
106
126
  }
107
127
  case "off":
108
- updateSettings(home, (s) => {
109
- s.off = true
110
- })
128
+ await setReports(home, false)
111
129
  say("reports are off: brain sends nothing from this machine to your 100xprompt account. The console keeps the last report until you remove this machine there; `brain dashboard on` starts them again.")
112
130
  return 0
113
131
  case "on": {
114
- updateSettings(home, (s) => {
115
- delete s.off
116
- })
132
+ await setReports(home, true)
117
133
  const notes = [sendingOff(home, env) ? "BRAIN_NO_REPORT is set here, so nothing is sent while it is" : "", signedIn(home) ? "" : "sign in first: brain gateway login"].filter(Boolean)
118
134
  say(`reports are on: brain sends this machine's report after commands that change it, and with \`brain dashboard\`${notes.length ? ` (${notes.join("; ")})` : ""}.`)
119
135
  return 0
@@ -13,6 +13,7 @@ import { HOOK_TOOLS, hookInstalled } from "../hooks.ts"
13
13
  import { MCP_TOOLS, statusMcp } from "../memory/mcp-install.ts"
14
14
  import { chittaCandidates, memoryDbPath, openMemory } from "../memory/store.ts"
15
15
  import { readOrgConfig } from "../org/config.ts"
16
+ import { memoryOn } from "../settings/values.ts"
16
17
  import { VERSION } from "../self.ts"
17
18
  import { clip, type Conversion, readConversions, toolId } from "./conversions.ts"
18
19
  import { machineId, readCache, type ToolCount, updateCache } from "./state.ts"
@@ -52,6 +53,94 @@ export interface BrainReport {
52
53
  conversions: Conversion[]
53
54
  /** byMonth: conversions per month, UTC "YYYY-MM", the latest twelve months that have any. */
54
55
  totals: { conversions: number; byMonth: Record<string, number> }
56
+ /** Every setting on this computer and its state (docs/settings-contract.md, section 3). Absent when it could not be read. */
57
+ settings?: SettingsSection
58
+ }
59
+
60
+ export interface SettingsRow {
61
+ id: string
62
+ family: string
63
+ tool: string | null
64
+ type: "bool" | "choice" | "number"
65
+ choices?: string[]
66
+ min?: number
67
+ max?: number
68
+ unit?: string
69
+ value: boolean | number | string | null
70
+ from: string
71
+ by?: string
72
+ command?: string
73
+ at?: string
74
+ actual: boolean | number | string | null
75
+ state: string
76
+ error?: string
77
+ notes?: string[]
78
+ remote: boolean
79
+ /** A change made here to this setting (any level) not sent to the account yet: being the later write, it will win. */
80
+ pending?: true
81
+ }
82
+
83
+ export interface SettingsSection {
84
+ paired: boolean
85
+ lastSyncAt: string | null
86
+ waiting: number
87
+ /** brain's background service on this computer (docs/settings-contract.md, section 6). */
88
+ service: { installed: boolean; running: boolean; lastRunAt: string | null }
89
+ rows: SettingsRow[]
90
+ }
91
+
92
+ /**
93
+ * The report's settings: each setting's value, where it comes from and its state, as `brain settings` shows them. A
94
+ * model is pro, flash, auto, other or partly, never a gateway id; notes and errors were cleaned when they were kept.
95
+ */
96
+ export async function settingsSection(home: string, env: Env): Promise<SettingsSection | undefined> {
97
+ try {
98
+ const { listSettings } = await import("../settings/apply.ts")
99
+ const { cleanText } = await import("../settings/catalog.ts")
100
+ const { exchangeState } = await import("../settings/account.ts")
101
+ const rows = await withTimeout(listSettings(home, env), 5_000)
102
+ if (!rows) return undefined
103
+ const st = exchangeState(home)
104
+ const { serviceState } = await import("../service.ts")
105
+ const { readSettingsFile } = await import("../settings/store.ts")
106
+ const unsent = new Set(readSettingsFile(home).doc.outbox.map((c) => c.id))
107
+ const short = (v: unknown, type: { kind: string; choices?: string[] }): boolean | number | string | null => {
108
+ if (v === undefined || v === null) return null
109
+ if (typeof v === "boolean" || typeof v === "number") return v
110
+ const t = String(v)
111
+ if (type.kind === "choice" && t !== "partly" && !type.choices!.includes(t)) return "other"
112
+ return clip(t, 40)
113
+ }
114
+ return {
115
+ paired: st.paired,
116
+ lastSyncAt: st.lastSyncAt ?? null,
117
+ waiting: st.waiting,
118
+ service: serviceState(home),
119
+ rows: rows.slice(0, 400).map((r): SettingsRow => {
120
+ const t = r.family.type
121
+ return {
122
+ id: r.id,
123
+ family: r.family.key,
124
+ tool: r.tool ?? null,
125
+ type: t.kind,
126
+ ...(t.kind === "choice" ? { choices: t.choices } : {}),
127
+ ...(t.kind === "number" ? { min: t.min, max: t.max, unit: t.unit } : {}),
128
+ value: short(r.value, t),
129
+ from: r.from,
130
+ ...(r.entry ? { by: r.entry.source, at: r.entry.at, ...(r.entry.command ? { command: clip(r.entry.command, 60) } : {}) } : {}),
131
+ actual: short(r.actual.value, t),
132
+ state: r.state,
133
+ // Never a key, a file's contents or a home path, whatever a tool's files said.
134
+ ...(r.error ? { error: clip(cleanText(r.error, home), 300) } : {}),
135
+ ...(r.notes.length ? { notes: r.notes.slice(0, 5).map((n) => clip(cleanText(n, home), 300)) } : {}),
136
+ remote: r.family.remote === "yes",
137
+ ...(st.paired && unsent.has(r.id) ? { pending: true as const } : {}),
138
+ }
139
+ }),
140
+ }
141
+ } catch {
142
+ return undefined
143
+ }
55
144
  }
56
145
 
57
146
  /** The computer's name: os.hostname() without ".local". */
@@ -134,6 +223,8 @@ export async function countAndKeep(home: string, o: { timeoutMs?: number; reader
134
223
 
135
224
  async function memorySummary(home: string, env: Env): Promise<BrainReport["memory"]> {
136
225
  try {
226
+ // Turned off on this computer (brain settings): memory is not used here, whatever is stored.
227
+ if (!memoryOn(home)) return { available: false }
137
228
  const org = env.BRAIN_ORG !== "off" && readOrgConfig(home)
138
229
  // No store yet: say whether memory can be used, without creating one.
139
230
  if (!org && !existsSync(memoryDbPath(home))) return chittaCandidates().some((f) => existsSync(f)) ? { available: true, projects: 0, items: 0 } : { available: false }
@@ -251,6 +342,7 @@ export async function buildReport(home: string, o: { counts?: Record<string, Too
251
342
  const env = o.env ?? process.env
252
343
  const counts = o.counts ?? readCache(home).counts?.tools
253
344
  const { list, total, byMonth } = readConversions(home, MAX_CONVERSIONS)
345
+ const settings = await settingsSection(home, env)
254
346
  return {
255
347
  schema: SCHEMA,
256
348
  sentAt: (o.now ?? new Date()).toISOString(),
@@ -260,6 +352,7 @@ export async function buildReport(home: string, o: { counts?: Record<string, Too
260
352
  memory: await memorySummary(home, env),
261
353
  conversions: list,
262
354
  totals: { conversions: total, byMonth },
355
+ ...(settings ? { settings } : {}),
263
356
  }
264
357
  }
265
358
 
@@ -10,7 +10,7 @@
10
10
  import { CLI_PATH } from "../self.ts"
11
11
  import { DEFAULT_URL, type Env, readKey, statePath } from "../gateway/common.ts"
12
12
  import { readFileSync } from "node:fs"
13
- import { readCache, readSettings, updateCache, updateSettings } from "./state.ts"
13
+ import { readCache, readMachineToken, readSettings, updateCache, updateSettings } from "./state.ts"
14
14
  import type { BrainReport } from "./report.ts"
15
15
 
16
16
  /** Where the person sees what was sent. */
@@ -53,6 +53,9 @@ export function safeUrl(url: string): boolean {
53
53
 
54
54
  export const signedIn = (home: string) => readKey(home).length > 0
55
55
 
56
+ /** Whether this computer has a way to send its report: the gateway key, or its own token from `brain pair`. */
57
+ export const canReport = (home: string) => signedIn(home) || !!readMachineToken(home)
58
+
56
59
  /** Why nothing is sent right now, or undefined when reports go out (signed in or not is checked separately). */
57
60
  export function sendingOff(home: string, env: Env = process.env): string | undefined {
58
61
  const v = env.BRAIN_NO_REPORT
@@ -101,7 +104,20 @@ export async function sendReport(home: string, report: BrainReport, o: { fetchIm
101
104
 
102
105
  async function post(home: string, report: BrainReport, o: { fetchImpl?: typeof fetch; timeoutMs?: number }): Promise<SendResult> {
103
106
  const key = readKey(home)
104
- if (!key) return { ok: false, why: "not signed in (brain gateway login)" }
107
+ // A paired computer sends with its own token (it needs no gateway key); one the account no longer takes (the computer
108
+ // was removed in the dashboard) falls back to the gateway key, as before pairing.
109
+ const token = readMachineToken(home)
110
+ if (!key && !token) return { ok: false, why: "not signed in (brain gateway login)" }
111
+ if (token) {
112
+ const r = await postWith(home, report, token, o)
113
+ if (r.ok || !key || !("status" in r) || (r.status !== 401 && r.status !== 403)) return strip(r)
114
+ }
115
+ return strip(await postWith(home, report, key, o))
116
+ }
117
+
118
+ const strip = (r: SendResult & { status?: number }): SendResult => (r.ok ? r : { ok: false, why: r.why })
119
+
120
+ async function postWith(home: string, report: BrainReport, key: string, o: { fetchImpl?: typeof fetch; timeoutMs?: number }): Promise<SendResult & { status?: number }> {
105
121
  const url = reportUrl(home)
106
122
  if (!safeUrl(url)) return { ok: false, why: `the gateway address ${gatewayUrl(home)} is not https; the key is never sent over it` }
107
123
  const { reportBody } = await import("./report.ts")
@@ -130,7 +146,7 @@ async function post(home: string, report: BrainReport, o: { fetchImpl?: typeof f
130
146
  : res.status === 429 ? "too many reports for now; the next one carries everything"
131
147
  : res.status >= 300 && res.status < 400 ? `the gateway answered with a redirect (HTTP ${res.status}); not followed`
132
148
  : `the gateway answered HTTP ${res.status}`
133
- return { ok: false, why: said ? `${why}: ${String(said).slice(0, 200)}` : why }
149
+ return { ok: false, why: said ? `${why}: ${String(said).slice(0, 200)}` : why, status: res.status }
134
150
  } catch (e) {
135
151
  const name = (e as Error)?.name
136
152
  return { ok: false, why: name === "TimeoutError" || name === "AbortError" ? `the gateway did not answer within ${Math.round(timeoutMs / 1000)} s` : `could not reach ${gatewayUrl(home)}` }
@@ -154,7 +170,7 @@ export function planPush(p: { lastStartMs?: number; pendingUntilMs?: number }, n
154
170
  export function reportInBackground(home: string, o: { env?: Env; cli?: string; say?: (line: string) => void; now?: number } = {}): boolean {
155
171
  const env = o.env ?? process.env
156
172
  try {
157
- if (!signedIn(home) || sendingOff(home, env)) return false
173
+ if (!canReport(home) || sendingOff(home, env)) return false
158
174
  noticeOnce(home, o.say ?? ((l) => process.stderr.write(`${l}\n`)))
159
175
  const plan = planPush(readCache(home).push ?? {}, o.now ?? Date.now(), reportEvery(env))
160
176
  if (!plan) return false
@@ -1,10 +1,13 @@
1
1
  // The small files the dashboard report keeps under <home>/.brain (all 0600, in a 0700 folder):
2
- // machine.json { id, createdAt }: this machine's random id, made once (the console tells machines apart by it)
2
+ // machine.json { id, createdAt, hw }: this machine's random id, made once (the console tells machines apart by
3
+ // it), and a hash of the computer's hardware id: a ~/.brain copied to another computer (Migration
4
+ // Assistant, dotfiles) gets its own id there, and drops this computer's token (machine.key)
3
5
  // dashboard.json the person's choice: { off?, noticeShownAt? }. Written only by the person's own commands, never
4
6
  // by a background push, so a push can never undo `brain dashboard off`.
5
7
  // dashboard-cache.json what brain works out or remembers by itself: the last session counts, and the push bookkeeping
6
8
  // (when the last report went out, a push that is waiting to run).
7
9
  // Every write re-reads the file first and changes only its own fields, then replaces the file atomically.
10
+ import { createHash } from "node:crypto"
8
11
  import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"
9
12
  import { dirname, join } from "node:path"
10
13
 
@@ -83,13 +86,72 @@ export function updateCache(home: string, change: (c: DashboardCache) => void):
83
86
 
84
87
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
85
88
 
86
- /** This machine's id: made once, kept in <home>/.brain/machine.json. A missing or damaged file gets a new one. */
89
+ /** The computer's own token for the account's settings (written by `brain pair`), 0600. */
90
+ export const machineKeyPath = (home: string) => join(brainDir(home), "machine.key")
91
+
92
+ /** A computer's token: `100x_brain_` and 43 letters and digits. */
93
+ export const MACHINE_TOKEN = /^100x_brain_[A-Za-z0-9]{43}$/
94
+
95
+ /** This computer's token, if it is paired (never printed). */
96
+ export function readMachineToken(home: string): string | undefined {
97
+ try {
98
+ const t = readFileSync(machineKeyPath(home), "utf8").trim()
99
+ return MACHINE_TOKEN.test(t) ? t : undefined
100
+ } catch {
101
+ return undefined
102
+ }
103
+ }
104
+
105
+ let hw: string | null | undefined
106
+ /**
107
+ * A hash of this computer's hardware id (macOS: IOPlatformUUID; Linux: /etc/machine-id), never the id itself; null where
108
+ * there is none to read. BRAIN_HW_ID replaces the hardware id (tests). Read once per process.
109
+ */
110
+ export function hardwareHash(env: Record<string, string | undefined> = process.env): string | null {
111
+ if (env.BRAIN_HW_ID !== undefined) return env.BRAIN_HW_ID ? createHash("sha256").update(`brain-hw:${env.BRAIN_HW_ID}`).digest("hex") : null
112
+ if (hw !== undefined) return hw
113
+ let raw = ""
114
+ try {
115
+ if (process.platform === "darwin") {
116
+ const p = Bun.spawnSync(["ioreg", "-rd1", "-c", "IOPlatformExpertDevice"], { stdout: "pipe", stderr: "ignore", timeout: 3_000 })
117
+ raw = /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(p.stdout.toString())?.[1] ?? ""
118
+ } else if (process.platform === "linux") {
119
+ for (const f of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
120
+ try {
121
+ raw = readFileSync(f, "utf8").trim()
122
+ if (raw) break
123
+ } catch {}
124
+ }
125
+ }
126
+ } catch {}
127
+ hw = raw ? createHash("sha256").update(`brain-hw:${raw}`).digest("hex") : null
128
+ return hw
129
+ }
130
+
131
+ /**
132
+ * This machine's id: made once, kept in <home>/.brain/machine.json. A missing or damaged file gets a new one. A file made on
133
+ * another computer (its hardware hash differs) gets a new id too, and that computer's token is dropped here: the copy is
134
+ * a computer of its own, and is paired on its own.
135
+ */
87
136
  export function machineId(home: string): string {
88
- const m = readJson<{ id?: unknown; createdAt?: unknown }>(machinePath(home))
89
- if (typeof m.id === "string" && UUID.test(m.id)) return m.id
137
+ const m = readJson<{ id?: unknown; createdAt?: unknown; hw?: unknown }>(machinePath(home))
138
+ const here = hardwareHash()
139
+ if (typeof m.id === "string" && UUID.test(m.id)) {
140
+ if (!here || m.hw === here) return m.id
141
+ if (m.hw === undefined) {
142
+ // Made by a brain before the hardware hash: this computer is taken to be the one that made it.
143
+ try {
144
+ writePrivateJson(machinePath(home), { ...m, hw: here })
145
+ } catch {}
146
+ return m.id
147
+ }
148
+ try {
149
+ rmSync(machineKeyPath(home), { force: true })
150
+ } catch {}
151
+ }
90
152
  const id = crypto.randomUUID()
91
153
  try {
92
- writePrivateJson(machinePath(home), { id, createdAt: new Date().toISOString() })
154
+ writePrivateJson(machinePath(home), { id, createdAt: new Date().toISOString(), ...(here ? { hw: here } : {}) })
93
155
  } catch {}
94
156
  return id
95
157
  }
package/src/fsutil.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"
1
+ import { chmodSync, existsSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
2
2
  import { join, resolve } from "node:path"
3
3
  import type { Project } from "./session/types.ts"
4
4
 
@@ -12,6 +12,24 @@ export const appSupport = (home: string) => join(home, "Library", "Application S
12
12
 
13
13
  export const exists = (p: string) => existsSync(p)
14
14
 
15
+ /**
16
+ * Replaces a file's content in one step: the text goes to a temporary file beside it (a name no other process uses,
17
+ * created with `mode` when given, so a private file is never readable by others even for a moment), which then takes the
18
+ * file's place. A reader sees the old content or the new, never half of it.
19
+ */
20
+ export function writeAtomic(path: string, text: string, mode?: number): void {
21
+ const tmp = `${path}.${process.pid}-${Math.random().toString(36).slice(2, 10)}.brain-tmp`
22
+ // Earlier brains used one fixed name; a copy an interrupted run left there (with whatever mode it had) goes.
23
+ rmSync(`${path}.brain-tmp`, { force: true })
24
+ try {
25
+ writeFileSync(tmp, text, mode === undefined ? {} : { mode })
26
+ if (mode !== undefined) chmodSync(tmp, mode)
27
+ renameSync(tmp, path)
28
+ } finally {
29
+ rmSync(tmp, { force: true })
30
+ }
31
+ }
32
+
15
33
  export function isDir(p: string): boolean {
16
34
  try {
17
35
  return statSync(p).isDirectory()