@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.
- package/README.md +59 -2
- package/package.json +1 -1
- package/src/cli.ts +74 -9
- package/src/dashboard/cli.ts +25 -9
- package/src/dashboard/report.ts +84 -0
- package/src/dashboard/send.ts +20 -4
- package/src/dashboard/state.ts +67 -5
- package/src/fsutil.ts +19 -1
- package/src/gateway/common.ts +56 -4
- package/src/gateway/connect.ts +256 -100
- package/src/gateway/connectors/aider.ts +3 -5
- package/src/gateway/connectors/cline-sdk.ts +2 -5
- package/src/gateway/connectors/continue.ts +5 -6
- package/src/gateway/connectors/crush.ts +2 -2
- package/src/gateway/connectors/factory-droid.ts +6 -1
- package/src/gateway/connectors/goose.ts +4 -4
- package/src/gateway/connectors/hermes.ts +7 -3
- package/src/gateway/connectors/kimi-code.ts +2 -2
- package/src/gateway/connectors/letta-code.ts +4 -7
- package/src/gateway/connectors/openhands.ts +9 -4
- package/src/gateway/connectors/qwen-code.ts +16 -2
- package/src/gateway/connectors/vscode-copilot.ts +4 -6
- package/src/gateway/signin.ts +44 -12
- package/src/hooks.ts +20 -9
- package/src/lock.ts +269 -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/settings/account.ts +336 -0
- package/src/settings/apply.ts +306 -0
- package/src/settings/catalog.ts +476 -0
- package/src/settings/cli.ts +439 -0
- package/src/settings/env.ts +101 -0
- package/src/settings/pair.ts +83 -0
- package/src/settings/store.ts +216 -0
- 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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
+
}
|
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,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
|
|
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()
|