@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 +70 -2
- package/package.json +1 -1
- package/src/cli.ts +82 -9
- package/src/dashboard/cli.ts +25 -9
- package/src/dashboard/report.ts +93 -0
- package/src/dashboard/send.ts +20 -4
- package/src/dashboard/state.ts +67 -5
- package/src/fsutil.ts +19 -1
- package/src/gateway/connect.ts +252 -101
- package/src/gateway/connectors/hermes.ts +1 -1
- package/src/gateway/signin.ts +44 -12
- package/src/hooks.ts +20 -9
- package/src/lock.ts +272 -0
- package/src/memory/audit.ts +6 -4
- package/src/memory/cli.ts +6 -0
- package/src/memory/externalize.ts +17 -4
- package/src/memory/mcp-install.ts +63 -7
- package/src/memory/mcp-server.ts +4 -0
- package/src/memory/prepare.ts +12 -4
- package/src/memory/sync.ts +3 -0
- package/src/service.ts +473 -0
- package/src/settings/account.ts +391 -0
- package/src/settings/apply.ts +306 -0
- package/src/settings/catalog.ts +476 -0
- package/src/settings/cli.ts +446 -0
- package/src/settings/env.ts +103 -0
- package/src/settings/pair.ts +86 -0
- package/src/settings/store.ts +216 -0
- package/src/settings/values.ts +80 -0
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
|
-
|
|
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
|
+
"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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
+
}
|
package/src/dashboard/cli.ts
CHANGED
|
@@ -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 (!
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/dashboard/report.ts
CHANGED
|
@@ -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
|
|
package/src/dashboard/send.ts
CHANGED
|
@@ -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
|
-
|
|
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 (!
|
|
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
|
package/src/dashboard/state.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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()
|