@100xprompt/brain 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +59 -2
  2. package/package.json +1 -1
  3. package/src/cli.ts +74 -9
  4. package/src/dashboard/cli.ts +25 -9
  5. package/src/dashboard/report.ts +84 -0
  6. package/src/dashboard/send.ts +20 -4
  7. package/src/dashboard/state.ts +67 -5
  8. package/src/fsutil.ts +19 -1
  9. package/src/gateway/common.ts +56 -4
  10. package/src/gateway/connect.ts +256 -100
  11. package/src/gateway/connectors/aider.ts +3 -5
  12. package/src/gateway/connectors/cline-sdk.ts +2 -5
  13. package/src/gateway/connectors/continue.ts +5 -6
  14. package/src/gateway/connectors/crush.ts +2 -2
  15. package/src/gateway/connectors/factory-droid.ts +6 -1
  16. package/src/gateway/connectors/goose.ts +4 -4
  17. package/src/gateway/connectors/hermes.ts +7 -3
  18. package/src/gateway/connectors/kimi-code.ts +2 -2
  19. package/src/gateway/connectors/letta-code.ts +4 -7
  20. package/src/gateway/connectors/openhands.ts +9 -4
  21. package/src/gateway/connectors/qwen-code.ts +16 -2
  22. package/src/gateway/connectors/vscode-copilot.ts +4 -6
  23. package/src/gateway/signin.ts +44 -12
  24. package/src/hooks.ts +20 -9
  25. package/src/lock.ts +269 -0
  26. package/src/memory/audit.ts +6 -4
  27. package/src/memory/cli.ts +6 -0
  28. package/src/memory/externalize.ts +17 -4
  29. package/src/memory/mcp-install.ts +63 -7
  30. package/src/memory/mcp-server.ts +4 -0
  31. package/src/memory/prepare.ts +12 -4
  32. package/src/memory/sync.ts +3 -0
  33. package/src/settings/account.ts +336 -0
  34. package/src/settings/apply.ts +306 -0
  35. package/src/settings/catalog.ts +476 -0
  36. package/src/settings/cli.ts +439 -0
  37. package/src/settings/env.ts +101 -0
  38. package/src/settings/pair.ts +83 -0
  39. package/src/settings/store.ts +216 -0
  40. package/src/settings/values.ts +80 -0
package/README.md CHANGED
@@ -96,6 +96,9 @@ brain gateway logout
96
96
  - Connecting changes which model the tool uses: it runs on 100xprompt Pro by default (`--model flash` for 100xprompt
97
97
  Flash), billed to your 100xprompt account. Context editing is on by default; `--no-context-edit` turns it off and is
98
98
  remembered for later connects until `--context-edit`.
99
+ - `--model auto` connects the tool to 100xprompt Auto (the gateway's `/auto` route): each kind of work (everyday asks,
100
+ code, reasoning) moves to a cheaper model only when your company's own test of its logs (in the Savings app) proved it
101
+ no worse, and stays on 100xprompt Pro until then. `--model pro` or `--model flash` switches back.
99
102
  - Before changing anything, `connect` checks that the gateway accepts your key the way that tool sends it (a free check
100
103
  that generates nothing). If your account's quota is used up, it still connects and tells you, in the gateway's words.
101
104
  - Only the settings brain owns are written; the value each had before is recorded and put back by `disconnect`. The key
@@ -164,10 +167,60 @@ brain dashboard off # send nothing from this machine (brain dashboard o
164
167
  ```
165
168
 
166
169
  The report goes out in the background, at most every 30 seconds, after a command that changes it (`gateway login`,
167
- `connect`, `disconnect`, `session continue`, `handoff`, `mcp install`/`uninstall`, `session hooks install`/`uninstall`),
168
- 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.
169
173
  `BRAIN_NO_REPORT=1` also turns it off. The conversion log is `~/.brain/logs/conversions.jsonl`, readable only by you.
170
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
+ Pairing gives this computer its own token (`~/.brain/machine.key`, readable only by you), separate from the gateway key
214
+ your tools use: model keys never read or change settings. A paired computer sends the changes made here to your account
215
+ and takes the ones made there when you run `brain status`, `brain settings` or `brain sync` (a few seconds at most; the
216
+ changes wait when you are offline). Hooks and reports stay this-computer-only. Removing the computer in the dashboard
217
+ revokes its token; a `~/.brain` copied to another computer is not paired there.
218
+
219
+ Settings are kept in `~/.brain/settings.json`, readable only by you. Commands you run also note where your tools keep their
220
+ files (`CLAUDE_CONFIG_DIR`, `CODEX_HOME` and the like) and brain's own opt-outs in `~/.brain/env.json`, kept for a
221
+ background brain (coming) to use the same folders; never keys. One brain at a time changes a tool's files: another waits its
222
+ turn (`~/.brain/lock`).
223
+
171
224
  ## Teams
172
225
 
173
226
  Memory can live on one shared server instead of each person's machine, with who may see what enforced there.
@@ -209,6 +262,10 @@ brain mcp serve | install | uninstall | status
209
262
  brain tokens audit | lookups
210
263
  brain gateway login | logout | connect | disconnect | status | tools
211
264
  brain dashboard [push | on | off | status]
265
+ brain status
266
+ brain settings [list | get | set | unset | history | ids]
267
+ brain sync
268
+ brain pair [status | forget]
212
269
  brain org login | logout | whoami | status | promote | demote | forget | admin ...
213
270
  brain expand <id>
214
271
  brain --version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@100xprompt/brain",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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,10 @@ 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
44
48
  brain --version the version, and where this brain runs from
45
49
  brain expand <blob id> the full text of a tool output that was replaced by a digest`
46
50
 
@@ -212,6 +216,34 @@ async function main() {
212
216
  out(`running from ${CLI_PATH}${isEphemeral() ? " (a temporary bunx copy: hooks and MCP entries it installs run the pinned package)" : ""}`)
213
217
  return
214
218
  }
219
+ // A command the person runs records where their tools keep their files (env.json), for a brain that runs without their shell.
220
+ {
221
+ const { isPersonsCommand, recordEnv } = await import("./settings/env.ts")
222
+ if (isPersonsCommand(process.argv.slice(2))) recordEnv(home)
223
+ }
224
+ if (cmd === "settings") {
225
+ const { settingsCommand } = await import("./settings/cli.ts")
226
+ let changed = false
227
+ const code = await settingsCommand(rest, home, undefined, undefined, { changed: () => (changed = true) })
228
+ // The report carries the settings: sent when one was saved, never after a refusal.
229
+ if (changed) await reportLater()
230
+ process.exit(code)
231
+ }
232
+ if (cmd === "status") {
233
+ const { statusCommand } = await import("./settings/cli.ts")
234
+ process.exit(await statusCommand(home))
235
+ }
236
+ if (cmd === "pair") {
237
+ const { pairCommand } = await import("./settings/pair.ts")
238
+ const code = await pairCommand(rest, home)
239
+ // A newly paired computer reports at once, so the dashboard shows it as paired.
240
+ if (code === 0 && !rest.some((a) => !a.startsWith("-"))) await reportLater()
241
+ process.exit(code)
242
+ }
243
+ if (cmd === "sync") {
244
+ const { syncCommand } = await import("./settings/cli.ts")
245
+ process.exit(await syncCommand(home))
246
+ }
215
247
  // Commands with their own flags: `mcp serve` must keep stdout clean for the protocol, so they run before option parsing.
216
248
  if (cmd === "gateway") {
217
249
  const { gatewayCommand } = await import("./gateway/connect.ts")
@@ -227,7 +259,10 @@ async function main() {
227
259
  }
228
260
  if (cmd === "dashboard") {
229
261
  const { dashboardCommand } = await import("./dashboard/cli.ts")
230
- process.exit(await dashboardCommand(rest, home))
262
+ const code = await dashboardCommand(rest, home)
263
+ // Turned back on: the dashboard gets this computer as it is now.
264
+ if (code === 0 && rest[0] === "on") await reportLater()
265
+ process.exit(code)
231
266
  }
232
267
  if (cmd === "memory" && rest[0] === "sync") {
233
268
  const { syncMemory } = await import("./memory/sync.ts")
@@ -235,6 +270,7 @@ async function main() {
235
270
  const limitAt = rest.indexOf("--limit")
236
271
  const r = await syncMemory({ home, ...(here ? { cwd: process.cwd() } : {}), ...(limitAt >= 0 ? { limit: Number(rest[limitAt + 1]) || 8 } : {}) })
237
272
  if (!rest.includes("--quiet")) {
273
+ if (r.ingested) await reportLater()
238
274
  if (r.unavailable) out(`memory sync: memory is not available (${r.unavailable})`)
239
275
  else if (r.busy) out("memory sync: another sync is running")
240
276
  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 +279,10 @@ async function main() {
243
279
  }
244
280
  if (cmd === "memory") {
245
281
  const { memoryCommand } = await import("./memory/cli.ts")
246
- process.exit(await memoryCommand(rest, home, async (id) => readRef(await resolveRef(id)), out))
282
+ const code = await memoryCommand(rest, home, async (id) => readRef(await resolveRef(id)), out)
283
+ // The report carries memory's counts.
284
+ if (code === 0 && (rest[0] === "ingest" || rest[0] === "forget")) await reportLater()
285
+ process.exit(code)
247
286
  }
248
287
  if (cmd === "tokens") {
249
288
  const { tokensCommand } = await import("./memory/audit.ts")
@@ -251,7 +290,13 @@ async function main() {
251
290
  }
252
291
  if (cmd === "org") {
253
292
  const { orgCommand } = await import("./org/cli.ts")
254
- process.exit(await orgCommand(rest, home, { out }))
293
+ const { orgConfigPath } = await import("./org/config.ts")
294
+ const { existsSync } = await import("node:fs")
295
+ const was = existsSync(orgConfigPath(home))
296
+ const code = await orgCommand(rest, home, { out })
297
+ // Joining or leaving team memory changes the report's memory part (a logout that left nothing changes nothing).
298
+ if (code === 0 && (rest[0] === "login" || (rest[0] === "logout" && was))) await reportLater()
299
+ process.exit(code)
255
300
  }
256
301
  if (cmd === "expand") {
257
302
  const id = rest[0] ?? fail("expand needs a blob id (from a digest: \"id=...\")")
@@ -329,13 +374,26 @@ async function main() {
329
374
  if (sub === "hooks") {
330
375
  const action = positionals[0] ?? "status"
331
376
  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`)
377
+ if (!["install", "uninstall", "status"].includes(action)) fail(`hooks ${action}? use install, uninstall or status`)
378
+ const change = action !== "status" && !values["dry-run"]
379
+ // A real install or uninstall is recorded as this computer's setting (tools.<tool>.hooks) in the same hold of the brain
380
+ // lock as the change, so `brain settings` shows who set it.
381
+ const { recordChanges } = change ? await import("./settings/apply.ts") : { recordChanges: undefined }
382
+ const { withBrainLock } = await import("./lock.ts")
383
+ const run = async () => {
384
+ const cs = action === "install" ? await installHooks(tools, home, { dryRun: !!values["dry-run"] })
385
+ : action === "uninstall" ? await uninstallHooks(tools, home, { dryRun: !!values["dry-run"] })
386
+ : await installHooks(tools, home, { dryRun: true })
387
+ if (recordChanges) {
388
+ const done = cs.filter((c) => (action === "install" ? ["installed", "already-installed"] : ["removed", "not-installed"]).includes(c.action))
389
+ recordChanges(home, done.map((c) => ({ id: `tools.${c.tool}.hooks`, value: action === "install" })), `brain session hooks ${action}`)
390
+ }
391
+ return cs
392
+ }
393
+ const changes = change ? await withBrainLock(home, run, { what: `${action === "install" ? "installing" : "removing"} session hooks` }) : await run()
336
394
  for (const c of changes) out(`${c.tool.padEnd(14)} ${c.action}${c.why ? ` (${c.why})` : ""} ${short(c.path)}`)
337
395
  if (!changes.length) out("no tools with session-start hooks found")
338
- if ((action === "install" || action === "uninstall") && !values["dry-run"]) await reportLater()
396
+ if (change) await reportLater()
339
397
  return
340
398
  }
341
399
  if (sub === "hook") {
@@ -348,4 +406,11 @@ async function main() {
348
406
  fail(`unknown command "${sub}"\n\n${USAGE}`)
349
407
  }
350
408
 
351
- await main()
409
+ try {
410
+ await main()
411
+ } catch (e) {
412
+ // Another brain holding the lock too long, or a settings file from a newer brain: a sentence, not a stack trace.
413
+ const name = (e as Error)?.name
414
+ if (name === "BrainBusy" || (e as Error)?.constructor?.name === "SettingsUnwritable") fail((e as Error).message)
415
+ throw e
416
+ }
@@ -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,85 @@ 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
+ }
80
+
81
+ export interface SettingsSection {
82
+ paired: boolean
83
+ lastSyncAt: string | null
84
+ waiting: number
85
+ rows: SettingsRow[]
86
+ }
87
+
88
+ /**
89
+ * The report's settings: each setting's value, where it comes from and its state, as `brain settings` shows them. A
90
+ * model is pro, flash, auto, other or partly, never a gateway id; notes and errors were cleaned when they were kept.
91
+ */
92
+ export async function settingsSection(home: string, env: Env): Promise<SettingsSection | undefined> {
93
+ try {
94
+ const { listSettings } = await import("../settings/apply.ts")
95
+ const { cleanText } = await import("../settings/catalog.ts")
96
+ const { exchangeState } = await import("../settings/account.ts")
97
+ const rows = await withTimeout(listSettings(home, env), 5_000)
98
+ if (!rows) return undefined
99
+ const st = exchangeState(home)
100
+ const short = (v: unknown, type: { kind: string; choices?: string[] }): boolean | number | string | null => {
101
+ if (v === undefined || v === null) return null
102
+ if (typeof v === "boolean" || typeof v === "number") return v
103
+ const t = String(v)
104
+ if (type.kind === "choice" && t !== "partly" && !type.choices!.includes(t)) return "other"
105
+ return clip(t, 40)
106
+ }
107
+ return {
108
+ paired: st.paired,
109
+ lastSyncAt: st.lastSyncAt ?? null,
110
+ waiting: st.waiting,
111
+ rows: rows.slice(0, 400).map((r): SettingsRow => {
112
+ const t = r.family.type
113
+ return {
114
+ id: r.id,
115
+ family: r.family.key,
116
+ tool: r.tool ?? null,
117
+ type: t.kind,
118
+ ...(t.kind === "choice" ? { choices: t.choices } : {}),
119
+ ...(t.kind === "number" ? { min: t.min, max: t.max, unit: t.unit } : {}),
120
+ value: short(r.value, t),
121
+ from: r.from,
122
+ ...(r.entry ? { by: r.entry.source, at: r.entry.at, ...(r.entry.command ? { command: clip(r.entry.command, 60) } : {}) } : {}),
123
+ actual: short(r.actual.value, t),
124
+ state: r.state,
125
+ // Never a key, a file's contents or a home path, whatever a tool's files said.
126
+ ...(r.error ? { error: clip(cleanText(r.error, home), 300) } : {}),
127
+ ...(r.notes.length ? { notes: r.notes.slice(0, 5).map((n) => clip(cleanText(n, home), 300)) } : {}),
128
+ remote: r.family.remote === "yes",
129
+ }
130
+ }),
131
+ }
132
+ } catch {
133
+ return undefined
134
+ }
55
135
  }
56
136
 
57
137
  /** The computer's name: os.hostname() without ".local". */
@@ -134,6 +214,8 @@ export async function countAndKeep(home: string, o: { timeoutMs?: number; reader
134
214
 
135
215
  async function memorySummary(home: string, env: Env): Promise<BrainReport["memory"]> {
136
216
  try {
217
+ // Turned off on this computer (brain settings): memory is not used here, whatever is stored.
218
+ if (!memoryOn(home)) return { available: false }
137
219
  const org = env.BRAIN_ORG !== "off" && readOrgConfig(home)
138
220
  // No store yet: say whether memory can be used, without creating one.
139
221
  if (!org && !existsSync(memoryDbPath(home))) return chittaCandidates().some((f) => existsSync(f)) ? { available: true, projects: 0, items: 0 } : { available: false }
@@ -251,6 +333,7 @@ export async function buildReport(home: string, o: { counts?: Record<string, Too
251
333
  const env = o.env ?? process.env
252
334
  const counts = o.counts ?? readCache(home).counts?.tools
253
335
  const { list, total, byMonth } = readConversions(home, MAX_CONVERSIONS)
336
+ const settings = await settingsSection(home, env)
254
337
  return {
255
338
  schema: SCHEMA,
256
339
  sentAt: (o.now ?? new Date()).toISOString(),
@@ -260,6 +343,7 @@ export async function buildReport(home: string, o: { counts?: Record<string, Too
260
343
  memory: await memorySummary(home, env),
261
344
  conversions: list,
262
345
  totals: { conversions: total, byMonth },
346
+ ...(settings ? { settings } : {}),
263
347
  }
264
348
  }
265
349
 
@@ -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()