@100xprompt/brain 0.1.0 → 0.1.2

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 (51) hide show
  1. package/README.md +66 -3
  2. package/package.json +1 -1
  3. package/src/cli.ts +28 -3
  4. package/src/dashboard/cli.ts +148 -0
  5. package/src/dashboard/conversions.ts +170 -0
  6. package/src/dashboard/report.ts +275 -0
  7. package/src/dashboard/send.ts +213 -0
  8. package/src/dashboard/state.ts +95 -0
  9. package/src/gateway/common.ts +232 -0
  10. package/src/gateway/connect.ts +164 -162
  11. package/src/gateway/connectors/aider.ts +356 -0
  12. package/src/gateway/connectors/amazon-q-cli.ts +6 -0
  13. package/src/gateway/connectors/amazon-q-ide.ts +6 -0
  14. package/src/gateway/connectors/antigravity.ts +6 -0
  15. package/src/gateway/connectors/auggie.ts +6 -0
  16. package/src/gateway/connectors/cline-sdk.ts +85 -0
  17. package/src/gateway/connectors/cline.ts +34 -0
  18. package/src/gateway/connectors/codebuff.ts +6 -0
  19. package/src/gateway/connectors/continue.ts +579 -0
  20. package/src/gateway/connectors/copilot-cli.ts +138 -0
  21. package/src/gateway/connectors/crush.ts +313 -0
  22. package/src/gateway/connectors/cursor-cli.ts +6 -0
  23. package/src/gateway/connectors/cursor.ts +6 -0
  24. package/src/gateway/connectors/devin.ts +6 -0
  25. package/src/gateway/connectors/factory-droid.ts +308 -0
  26. package/src/gateway/connectors/goose.ts +79 -0
  27. package/src/gateway/connectors/grok-cli.ts +82 -0
  28. package/src/gateway/connectors/hermes.ts +230 -0
  29. package/src/gateway/connectors/iflow-cli.ts +50 -0
  30. package/src/gateway/connectors/index.ts +86 -0
  31. package/src/gateway/connectors/kilo-cli.ts +434 -0
  32. package/src/gateway/connectors/kilo-code.ts +32 -0
  33. package/src/gateway/connectors/kimi-code.ts +241 -0
  34. package/src/gateway/connectors/kiro-cli.ts +6 -0
  35. package/src/gateway/connectors/kiro-ide.ts +6 -0
  36. package/src/gateway/connectors/letta-code.ts +170 -0
  37. package/src/gateway/connectors/mastracode.ts +91 -0
  38. package/src/gateway/connectors/mistral-vibe.ts +495 -0
  39. package/src/gateway/connectors/muse.ts +27 -0
  40. package/src/gateway/connectors/omp.ts +279 -0
  41. package/src/gateway/connectors/openhands.ts +296 -0
  42. package/src/gateway/connectors/pearai.ts +52 -0
  43. package/src/gateway/connectors/pi.ts +357 -0
  44. package/src/gateway/connectors/qoder-cli.ts +6 -0
  45. package/src/gateway/connectors/qwen-code.ts +411 -0
  46. package/src/gateway/connectors/roo.ts +43 -0
  47. package/src/gateway/connectors/tabnine-cli.ts +6 -0
  48. package/src/gateway/connectors/void.ts +6 -0
  49. package/src/gateway/connectors/vscode-copilot.ts +286 -0
  50. package/src/gateway/connectors/windsurf.ts +6 -0
  51. package/src/gateway/connectors/zed.ts +29 -0
@@ -0,0 +1,275 @@
1
+ // The report of this machine for the Brain view of the 100xprompt console, built exactly as docs/dashboard-contract.md
2
+ // says: the machine, the coding tools present here (sessions, gateway, MCP tools, hooks), the gateway settings, memory
3
+ // counts, and the conversions brain made. Summaries plus each chat's title and project folder name: never chat text, file
4
+ // contents, full paths or keys.
5
+ import { existsSync, readFileSync } from "node:fs"
6
+ import { hostname } from "node:os"
7
+ import { join } from "node:path"
8
+ import type { Reader } from "../adapters/types.ts"
9
+ import { toolLabel } from "../brief.ts"
10
+ import { DEFAULT_MODEL, DEFAULT_ROUTE, type Env, type GatewaySettings, modelName, statePath } from "../gateway/common.ts"
11
+ import { CONNECTORS, cliAuthPath, hasKey, installedHere } from "../gateway/connect.ts"
12
+ import { HOOK_TOOLS, hookInstalled } from "../hooks.ts"
13
+ import { MCP_TOOLS, statusMcp } from "../memory/mcp-install.ts"
14
+ import { chittaCandidates, memoryDbPath, openMemory } from "../memory/store.ts"
15
+ import { readOrgConfig } from "../org/config.ts"
16
+ import { VERSION } from "../self.ts"
17
+ import { clip, type Conversion, readConversions, toolId } from "./conversions.ts"
18
+ import { machineId, readCache, type ToolCount, updateCache } from "./state.ts"
19
+
20
+ export const SCHEMA = "brain-report/v1"
21
+ /** The gateway takes at most 500 conversions and 512 KB; a report is cut to fit below that. */
22
+ export const MAX_CONVERSIONS = 500
23
+ export const MAX_BYTES = 500_000
24
+
25
+ export interface Harness {
26
+ id: string
27
+ label: string
28
+ /** Sessions brain can read for it here; null when never counted (`brain dashboard` counts them). */
29
+ sessions: number | null
30
+ lastSessionAt: string | null
31
+ gateway: "connected" | "available" | "unsupported"
32
+ /** brain_recall / brain_expand installed in it; null when brain has no MCP installer for it. */
33
+ mcp: boolean | null
34
+ /** The session-start hook installed; null when brain has no hook for it. */
35
+ hooks: boolean | null
36
+ }
37
+
38
+ export interface BrainReport {
39
+ schema: typeof SCHEMA
40
+ sentAt: string
41
+ machine: { id: string; name: string; os: string; brainVersion: string }
42
+ harnesses: Harness[]
43
+ gateway: {
44
+ signedIn: boolean
45
+ email?: string
46
+ route: string
47
+ model: string
48
+ contextEdit: boolean
49
+ connected: Array<{ tool: string; label: string; since: string | null }>
50
+ }
51
+ memory: { available: true; projects: number; items: number } | { available: false }
52
+ conversions: Conversion[]
53
+ /** byMonth: conversions per month, UTC "YYYY-MM", the latest twelve months that have any. */
54
+ totals: { conversions: number; byMonth: Record<string, number> }
55
+ }
56
+
57
+ /** The computer's name: os.hostname() without ".local". */
58
+ export const machineName = (h: string = hostname()) => clip(h.replace(/\.local\.?$/i, "").trim(), 80) || "this machine"
59
+
60
+ const isoOrNull = (v: unknown): string | null => {
61
+ if (typeof v !== "string" && typeof v !== "number") return null
62
+ const t = typeof v === "number" ? v : Date.parse(v)
63
+ return Number.isFinite(t) ? new Date(t).toISOString() : null
64
+ }
65
+
66
+ interface GatewayState {
67
+ settings?: Partial<GatewaySettings>
68
+ email?: string
69
+ tools?: Record<string, { at?: string }>
70
+ }
71
+
72
+ /** <home>/.brain/gateway.json as `brain gateway` keeps it (read only here). */
73
+ export function gatewayState(home: string): GatewayState {
74
+ try {
75
+ const s = JSON.parse(readFileSync(statePath(home), "utf8"))
76
+ return s && typeof s === "object" ? s : {}
77
+ } catch {
78
+ return {}
79
+ }
80
+ }
81
+
82
+ /** What the MCP installer calls a tool, as the readers (and so the report) call it. */
83
+ const MCP_IDS: Record<string, string> = { cursor: "cursor-ide", gemini: "gemini-cli", vscode: "vscode-copilot" }
84
+ const mcpToolId = (id: string) => MCP_IDS[id] ?? id
85
+
86
+ async function withTimeout<T>(p: Promise<T>, ms: number): Promise<T | undefined> {
87
+ let timer: ReturnType<typeof setTimeout> | undefined
88
+ const late = new Promise<undefined>((r) => {
89
+ timer = setTimeout(() => r(undefined), ms)
90
+ })
91
+ try {
92
+ return await Promise.race([p.catch(() => undefined), late])
93
+ } finally {
94
+ clearTimeout(timer)
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Sessions per tool, from every reader at once, within `timeoutMs`; a reader that fails or takes longer is left out (its
100
+ * last count stays in the cache). Some readers copy a big database to read it, so this runs only for `brain dashboard`.
101
+ */
102
+ export async function countSessions(home: string, o: { timeoutMs?: number; readers?: Reader[] } = {}): Promise<Record<string, ToolCount>> {
103
+ const readers = o.readers ?? (await import("../adapters/index.ts")).READERS
104
+ const out: Record<string, ToolCount> = {}
105
+ let timer: ReturnType<typeof setTimeout> | undefined
106
+ const deadline = new Promise<"late">((r) => {
107
+ timer = setTimeout(r, o.timeoutMs ?? 20_000, "late" as const)
108
+ })
109
+ try {
110
+ await Promise.all(
111
+ readers.map(async (r) => {
112
+ const refs = await Promise.race([r.list(home).catch(() => undefined), deadline])
113
+ if (!refs || refs === "late") return
114
+ const id = toolId(r.tool)
115
+ const newest = refs.map((x) => isoOrNull(x.updatedAt)).filter((x): x is string => !!x).sort().pop() ?? null
116
+ const had = out[id]
117
+ out[id] = { sessions: (had?.sessions ?? 0) + refs.length, lastSessionAt: [had?.lastSessionAt, newest].filter((x): x is string => !!x).sort().pop() ?? null }
118
+ }),
119
+ )
120
+ } finally {
121
+ clearTimeout(timer)
122
+ }
123
+ return out
124
+ }
125
+
126
+ /** Counts sessions now and keeps the counts for later background reports (merged over the last ones). */
127
+ export async function countAndKeep(home: string, o: { timeoutMs?: number; readers?: Reader[] } = {}): Promise<Record<string, ToolCount>> {
128
+ const fresh = await countSessions(home, o)
129
+ const c = updateCache(home, (c) => {
130
+ c.counts = { at: new Date().toISOString(), tools: { ...(c.counts?.tools ?? {}), ...fresh } }
131
+ })
132
+ return c.counts!.tools
133
+ }
134
+
135
+ async function memorySummary(home: string, env: Env): Promise<BrainReport["memory"]> {
136
+ try {
137
+ const org = env.BRAIN_ORG !== "off" && readOrgConfig(home)
138
+ // No store yet: say whether memory can be used, without creating one.
139
+ if (!org && !existsSync(memoryDbPath(home))) return chittaCandidates().some((f) => existsSync(f)) ? { available: true, projects: 0, items: 0 } : { available: false }
140
+ const mem = openMemory(home)
141
+ try {
142
+ const r = await withTimeout(
143
+ (async () => {
144
+ const s = await mem.status()
145
+ if (!s.available) return { available: false as const }
146
+ const projects = await mem.projects()
147
+ return { available: true as const, projects: projects.length, items: projects.reduce((n, p) => n + (Number(p.items) || 0), 0) }
148
+ })(),
149
+ 5_000,
150
+ )
151
+ return r ?? { available: false }
152
+ } finally {
153
+ mem.close()
154
+ }
155
+ } catch {
156
+ return { available: false }
157
+ }
158
+ }
159
+
160
+ /** Whether a tool the MCP installer knows has run here (its config or folder exists), as the installer decides it. */
161
+ function mcpToolHere(t: (typeof MCP_TOOLS)[number], home: string, env: Env): boolean {
162
+ if (t.id === "claude-code" && env.CLAUDE_CONFIG_DIR && existsSync(env.CLAUDE_CONFIG_DIR)) return true
163
+ return t.probe.some((p) => existsSync(join(home, p)))
164
+ }
165
+
166
+ /**
167
+ * Every coding tool brain knows (its readers, gateway connectors, MCP installer and hooks) that is present here: found by
168
+ * the connector's or the installer's probe, connected, with brain's MCP tools or hook in it, or with sessions counted.
169
+ */
170
+ export async function harnesses(home: string, counts: Record<string, ToolCount> | undefined, env: Env = process.env): Promise<Harness[]> {
171
+ const { READERS } = await import("../adapters/index.ts")
172
+ const st = gatewayState(home)
173
+ const connected = st.tools && typeof st.tools === "object" ? st.tools : {}
174
+ let mcpInstalled = new Map<string, boolean>()
175
+ try {
176
+ mcpInstalled = new Map(statusMcp(home).map((c) => [mcpToolId(c.tool), c.action === "installed"]))
177
+ } catch {}
178
+ const hookOn = (id: string) => {
179
+ try {
180
+ return hookInstalled(id, home)
181
+ } catch {
182
+ return false
183
+ }
184
+ }
185
+ /**
186
+ * The 100xprompt CLI is the gateway's own client: there is nothing for brain to connect, and it is on the gateway
187
+ * whenever it is signed in (its auth.json holds a key; only its presence is looked at). Signed out, it can be
188
+ * (`100xprompt auth login`). Any other tool: undefined, decided by brain's connectors.
189
+ */
190
+ const ownClient = (id: string, h: string, e: Env): Harness["gateway"] | undefined => {
191
+ if (id !== "100xprompt") return undefined
192
+ try {
193
+ const k = JSON.parse(readFileSync(cliAuthPath(h, e), "utf8"))?.["100xprompt"]?.key
194
+ return typeof k === "string" && k.trim() ? "connected" : "available"
195
+ } catch {
196
+ return "available"
197
+ }
198
+ }
199
+ const known = new Map<string, { label: string; present: boolean }>()
200
+ const add = (id: string, label: string, present: boolean) => {
201
+ const k = known.get(id)
202
+ if (k) k.present ||= present
203
+ else known.set(id, { label, present })
204
+ }
205
+ for (const r of READERS) add(toolId(r.tool), r.label, (counts?.[toolId(r.tool)]?.sessions ?? 0) > 0)
206
+ for (const c of CONNECTORS) {
207
+ let here = false
208
+ try {
209
+ here = installedHere(c, home, env)
210
+ } catch {}
211
+ add(c.id, c.label, here || !!connected[c.id])
212
+ }
213
+ for (const t of MCP_TOOLS) add(mcpToolId(t.id), t.label, mcpToolHere(t, home, env) || mcpInstalled.get(mcpToolId(t.id)) === true)
214
+ for (const id of HOOK_TOOLS) add(id, toolLabel(id), hookOn(id))
215
+ const mcpIds = new Set(MCP_TOOLS.map((t) => mcpToolId(t.id)))
216
+ const connectorIds = new Set(CONNECTORS.map((c) => c.id))
217
+ return [...known.entries()]
218
+ .filter(([, k]) => k.present)
219
+ .map(([id, k]): Harness => ({
220
+ id,
221
+ label: clip(k.label, 80),
222
+ sessions: counts?.[id] ? counts[id]!.sessions : null,
223
+ lastSessionAt: counts?.[id]?.lastSessionAt ?? null,
224
+ gateway: connected[id] ? "connected" : (ownClient(id, home, env) ?? (connectorIds.has(id) ? "available" : "unsupported")),
225
+ mcp: mcpIds.has(id) ? mcpInstalled.get(id) === true : null,
226
+ hooks: HOOK_TOOLS.includes(id) ? hookOn(id) : null,
227
+ }))
228
+ .sort((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id))
229
+ }
230
+
231
+ /** The gateway part: signed in or not, the settings `brain gateway status` shows, and the tools connected to it. */
232
+ export function gatewayPart(home: string): BrainReport["gateway"] {
233
+ const st = gatewayState(home)
234
+ const s = st.settings && typeof st.settings === "object" ? st.settings : {}
235
+ const label = (id: string) => CONNECTORS.find((c) => c.id === id)?.label ?? toolLabel(id)
236
+ return {
237
+ signedIn: hasKey(home),
238
+ ...(typeof st.email === "string" && st.email ? { email: clip(st.email, 200) } : {}),
239
+ route: typeof s.route === "string" && s.route ? s.route : DEFAULT_ROUTE,
240
+ model: modelName(typeof s.model === "string" && s.model ? s.model : DEFAULT_MODEL),
241
+ contextEdit: typeof s.contextEdit === "boolean" ? s.contextEdit : true,
242
+ connected: Object.entries(st.tools && typeof st.tools === "object" ? st.tools : {}).map(([tool, rec]) => ({ tool, label: clip(label(tool), 80), since: isoOrNull(rec?.at) })),
243
+ }
244
+ }
245
+
246
+ /**
247
+ * The whole report. Session counts are `counts` when given (`brain dashboard` just counted), else the last ones kept
248
+ * (null for a tool never counted); nothing here lists sessions, so a background report stays cheap.
249
+ */
250
+ export async function buildReport(home: string, o: { counts?: Record<string, ToolCount>; now?: Date; env?: Env } = {}): Promise<BrainReport> {
251
+ const env = o.env ?? process.env
252
+ const counts = o.counts ?? readCache(home).counts?.tools
253
+ const { list, total, byMonth } = readConversions(home, MAX_CONVERSIONS)
254
+ return {
255
+ schema: SCHEMA,
256
+ sentAt: (o.now ?? new Date()).toISOString(),
257
+ machine: { id: machineId(home), name: machineName(), os: process.platform, brainVersion: VERSION },
258
+ harnesses: await harnesses(home, counts, env),
259
+ gateway: gatewayPart(home),
260
+ memory: await memorySummary(home, env),
261
+ conversions: list,
262
+ totals: { conversions: total, byMonth },
263
+ }
264
+ }
265
+
266
+ /** The report as it is sent: at most MAX_BYTES of JSON, the oldest conversions dropped first if it is bigger. */
267
+ export function reportBody(r: BrainReport, maxBytes = MAX_BYTES): string {
268
+ let body = JSON.stringify(r)
269
+ let list = r.conversions
270
+ while (Buffer.byteLength(body) > maxBytes && list.length) {
271
+ list = list.slice(0, Math.floor(list.length * 0.9))
272
+ body = JSON.stringify({ ...r, conversions: list })
273
+ }
274
+ return body
275
+ }
@@ -0,0 +1,213 @@
1
+ // Sending the report (report.ts) to the person's 100xprompt account: POST <gateway>/api/brain/report with the brain's
2
+ // gateway key, best effort, 10 s at most. docs/dashboard-contract.md has the rules:
3
+ // - only when signed in (`brain gateway login`), and not turned off (`brain dashboard off`, or BRAIN_NO_REPORT=1);
4
+ // - in the background after a command that changes what the report says, never slowing that command: the command starts
5
+ // a detached `brain dashboard push --quiet` and returns. At most one background push every 30 s: a command within 30 s
6
+ // of the last one schedules the next for 30 s after it, and a push already waiting covers later commands too, because a
7
+ // push reads the state when it runs;
8
+ // - the first time a report is about to go out, a notice says once what is sent and how to stop it.
9
+ // This file stays cheap to import (every command that changes something loads it); report.ts is loaded only to send.
10
+ import { CLI_PATH } from "../self.ts"
11
+ import { DEFAULT_URL, type Env, readKey, statePath } from "../gateway/common.ts"
12
+ import { readFileSync } from "node:fs"
13
+ import { readCache, readSettings, updateCache, updateSettings } from "./state.ts"
14
+ import type { BrainReport } from "./report.ts"
15
+
16
+ /** Where the person sees what was sent. */
17
+ export const DASHBOARD_URL = "https://100xprompt.com/dashboard?view=brain"
18
+ export const REPORT_PATH = "/api/brain/report"
19
+ export const SEND_TIMEOUT_MS = 10_000
20
+ /** The least time between two background pushes. */
21
+ export const REPORT_EVERY_MS = 30_000
22
+ /** A waiting push that has not cleared its mark this long after its time has died: the mark is ignored. */
23
+ const PENDING_STALE_MS = 60_000
24
+
25
+ /** BRAIN_REPORT_EVERY_MS shortens or lengthens the 30 s between background pushes (tests use a short one). */
26
+ export function reportEvery(env: Env = process.env): number {
27
+ const n = Number(env.BRAIN_REPORT_EVERY_MS)
28
+ return env.BRAIN_REPORT_EVERY_MS !== undefined && env.BRAIN_REPORT_EVERY_MS !== "" && Number.isFinite(n) && n >= 0 ? n : REPORT_EVERY_MS
29
+ }
30
+
31
+ /** The gateway address: the one `brain gateway connect --url` keeps in gateway.json, else https://proxy.100xprompt.com. */
32
+ export function gatewayUrl(home: string): string {
33
+ try {
34
+ const u = JSON.parse(readFileSync(statePath(home), "utf8"))?.settings?.url
35
+ if (typeof u === "string" && u.trim()) return u.trim().replace(/\/+$/, "")
36
+ } catch {}
37
+ return DEFAULT_URL
38
+ }
39
+
40
+ export const reportUrl = (home: string) => `${gatewayUrl(home)}${REPORT_PATH}`
41
+
42
+ /** The key goes over https only (plain http only to a gateway on this machine), as `brain gateway connect` allows. */
43
+ export function safeUrl(url: string): boolean {
44
+ try {
45
+ const u = new URL(url)
46
+ if (u.username || u.password) return false
47
+ if (u.protocol === "https:") return true
48
+ return u.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(u.hostname)
49
+ } catch {
50
+ return false
51
+ }
52
+ }
53
+
54
+ export const signedIn = (home: string) => readKey(home).length > 0
55
+
56
+ /** Why nothing is sent right now, or undefined when reports go out (signed in or not is checked separately). */
57
+ export function sendingOff(home: string, env: Env = process.env): string | undefined {
58
+ const v = env.BRAIN_NO_REPORT
59
+ if (v !== undefined && v !== "" && v !== "0" && v.toLowerCase() !== "false") return "BRAIN_NO_REPORT is set"
60
+ if (readSettings(home).off) return "turned off with `brain dashboard off`"
61
+ return undefined
62
+ }
63
+
64
+ /** What the first-time notice says. */
65
+ export const NOTICE = [
66
+ "brain: this machine now sends a report to your 100xprompt account, for the Brain view of the console",
67
+ ` (${DASHBOARD_URL}). It holds: this computer's name, OS and brain version; the coding tools found`,
68
+ " here, their session counts, and which are connected to the gateway or have brain's MCP tools or hooks; your gateway",
69
+ " settings and memory counts; and each chat brain moved between tools: when, from and to which tool, its title and its",
70
+ " project folder's name. Never chat text, file contents, full paths or keys.",
71
+ " Stop it: brain dashboard off",
72
+ ]
73
+
74
+ /** Prints the notice the first time a report is about to go out, and remembers that it did. */
75
+ export function noticeOnce(home: string, print: (line: string) => void): boolean {
76
+ if (readSettings(home).noticeShownAt) return false
77
+ NOTICE.forEach((l) => print(l))
78
+ try {
79
+ updateSettings(home, (s) => {
80
+ s.noticeShownAt = new Date().toISOString()
81
+ })
82
+ } catch {}
83
+ return true
84
+ }
85
+
86
+ export type SendResult = { ok: true; machines?: number } | { ok: false; why: string }
87
+
88
+ /** POSTs the report. Never throws and never says the key; records when it went out (or why not) for `brain dashboard status`. */
89
+ export async function sendReport(home: string, report: BrainReport, o: { fetchImpl?: typeof fetch; timeoutMs?: number } = {}): Promise<SendResult> {
90
+ const r = await post(home, report, o)
91
+ updateCache(home, (c) => {
92
+ const at = new Date().toISOString()
93
+ c.push = { ...c.push, lastAttemptAt: at }
94
+ if (r.ok) {
95
+ c.push.lastSentAt = at
96
+ delete c.push.lastError
97
+ } else c.push.lastError = r.why
98
+ })
99
+ return r
100
+ }
101
+
102
+ async function post(home: string, report: BrainReport, o: { fetchImpl?: typeof fetch; timeoutMs?: number }): Promise<SendResult> {
103
+ const key = readKey(home)
104
+ if (!key) return { ok: false, why: "not signed in (brain gateway login)" }
105
+ const url = reportUrl(home)
106
+ if (!safeUrl(url)) return { ok: false, why: `the gateway address ${gatewayUrl(home)} is not https; the key is never sent over it` }
107
+ const { reportBody } = await import("./report.ts")
108
+ const timeoutMs = o.timeoutMs ?? SEND_TIMEOUT_MS
109
+ try {
110
+ const res = await (o.fetchImpl ?? fetch)(url, {
111
+ method: "POST",
112
+ headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
113
+ body: reportBody(report),
114
+ // A redirect is not followed: it could carry the key to another host.
115
+ redirect: "manual",
116
+ signal: AbortSignal.timeout(timeoutMs),
117
+ })
118
+ if (res.ok) {
119
+ const j = (await res.json().catch(() => undefined)) as { machines?: unknown } | undefined
120
+ return { ok: true, ...(typeof j?.machines === "number" ? { machines: j.machines } : {}) }
121
+ }
122
+ const said = await res
123
+ .json()
124
+ .then((j: any) => (typeof j?.error === "string" ? j.error : typeof j?.error?.message === "string" ? j.error.message : typeof j?.reason === "string" ? j.reason : ""))
125
+ .catch(() => "")
126
+ const why =
127
+ res.status === 401 || res.status === 403 ? "the gateway refused the key (sign in again: brain gateway login)"
128
+ : res.status === 404 ? "this gateway does not take brain reports yet"
129
+ : res.status === 413 ? "the report was too big for the gateway"
130
+ : res.status === 429 ? "too many reports for now; the next one carries everything"
131
+ : res.status >= 300 && res.status < 400 ? `the gateway answered with a redirect (HTTP ${res.status}); not followed`
132
+ : `the gateway answered HTTP ${res.status}`
133
+ return { ok: false, why: said ? `${why}: ${String(said).slice(0, 200)}` : why }
134
+ } catch (e) {
135
+ const name = (e as Error)?.name
136
+ return { ok: false, why: name === "TimeoutError" || name === "AbortError" ? `the gateway did not answer within ${Math.round(timeoutMs / 1000)} s` : `could not reach ${gatewayUrl(home)}` }
137
+ }
138
+ }
139
+
140
+ /**
141
+ * When the next background push may run, given the last one: now, or 30 s after the last one started. undefined when one
142
+ * is already waiting (it reads the state when it runs, so it carries this change too).
143
+ */
144
+ export function planPush(p: { lastStartMs?: number; pendingUntilMs?: number }, now: number, every = REPORT_EVERY_MS): { at: number } | undefined {
145
+ if (typeof p.pendingUntilMs === "number" && now < p.pendingUntilMs + PENDING_STALE_MS) return undefined
146
+ const last = typeof p.lastStartMs === "number" && p.lastStartMs <= now ? p.lastStartMs : -Infinity
147
+ return { at: Math.max(now, last + every) }
148
+ }
149
+
150
+ /**
151
+ * After a command that changed what the report says: starts `brain dashboard push --quiet` in a detached process and
152
+ * returns at once. Nothing at all when not signed in or turned off. Never throws.
153
+ */
154
+ export function reportInBackground(home: string, o: { env?: Env; cli?: string; say?: (line: string) => void; now?: number } = {}): boolean {
155
+ const env = o.env ?? process.env
156
+ try {
157
+ if (!signedIn(home) || sendingOff(home, env)) return false
158
+ noticeOnce(home, o.say ?? ((l) => process.stderr.write(`${l}\n`)))
159
+ const plan = planPush(readCache(home).push ?? {}, o.now ?? Date.now(), reportEvery(env))
160
+ if (!plan) return false
161
+ updateCache(home, (c) => {
162
+ c.push = { ...c.push, pendingUntilMs: plan.at }
163
+ })
164
+ try {
165
+ const child = Bun.spawn([process.execPath, o.cli ?? CLI_PATH, "dashboard", "push", "--quiet", "--at", String(plan.at)], {
166
+ cwd: home,
167
+ detached: true,
168
+ stdio: ["ignore", "ignore", "ignore"],
169
+ env: { ...env, BRAIN_HOME: home },
170
+ })
171
+ child.unref()
172
+ } catch {
173
+ // Nothing started: the mark must not hold up the next command's push.
174
+ updateCache(home, (c) => {
175
+ if (c.push?.pendingUntilMs === plan.at) delete c.push.pendingUntilMs
176
+ })
177
+ return false
178
+ }
179
+ return true
180
+ } catch {
181
+ return false
182
+ }
183
+ }
184
+
185
+ /** The start of a background push: waits for its time, then clears its mark, so a later command schedules the next. */
186
+ export async function startScheduled(home: string, at: number, env: Env = process.env): Promise<void> {
187
+ const wait = Math.min(at - Date.now(), reportEvery(env) + 5_000)
188
+ if (wait > 0) await Bun.sleep(wait)
189
+ const now = Date.now()
190
+ updateCache(home, (c) => {
191
+ c.push = { ...c.push, lastStartMs: now }
192
+ if (typeof c.push.pendingUntilMs === "number" && c.push.pendingUntilMs <= Math.max(at, now)) delete c.push.pendingUntilMs
193
+ })
194
+ }
195
+
196
+ /**
197
+ * The last report before `brain gateway logout` forgets the key: the same snapshot, marked signed out, so the console does
198
+ * not go on showing this machine as signed in. Sent while the key is still there (after logout nothing can be sent),
199
+ * waited for briefly, best effort; nothing when reports are off or the person is not signed in.
200
+ */
201
+ export async function reportSignedOut(home: string, o: { env?: Env; fetchImpl?: typeof fetch; timeoutMs?: number } = {}): Promise<SendResult | undefined> {
202
+ const env = o.env ?? process.env
203
+ if (sendingOff(home, env) || !readKey(home)) return undefined
204
+ try {
205
+ const { buildReport } = await import("./report.ts")
206
+ const report = await buildReport(home, { env })
207
+ report.gateway = { ...report.gateway, signedIn: false }
208
+ delete (report.gateway as { email?: string }).email
209
+ return await sendReport(home, report, { timeoutMs: o.timeoutMs ?? 3_000, ...(o.fetchImpl ? { fetchImpl: o.fetchImpl } : {}) })
210
+ } catch {
211
+ return undefined
212
+ }
213
+ }
@@ -0,0 +1,95 @@
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)
3
+ // dashboard.json the person's choice: { off?, noticeShownAt? }. Written only by the person's own commands, never
4
+ // by a background push, so a push can never undo `brain dashboard off`.
5
+ // dashboard-cache.json what brain works out or remembers by itself: the last session counts, and the push bookkeeping
6
+ // (when the last report went out, a push that is waiting to run).
7
+ // Every write re-reads the file first and changes only its own fields, then replaces the file atomically.
8
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"
9
+ import { dirname, join } from "node:path"
10
+
11
+ export const brainDir = (home: string) => join(home, ".brain")
12
+ export const machinePath = (home: string) => join(brainDir(home), "machine.json")
13
+ export const settingsPath = (home: string) => join(brainDir(home), "dashboard.json")
14
+ export const cachePath = (home: string) => join(brainDir(home), "dashboard-cache.json")
15
+
16
+ export interface DashboardSettings {
17
+ /** `brain dashboard off`: nothing is sent. */
18
+ off?: boolean
19
+ /** When the first-time notice (what is sent, how to stop it) was shown. */
20
+ noticeShownAt?: string
21
+ }
22
+
23
+ export interface ToolCount {
24
+ sessions: number
25
+ lastSessionAt: string | null
26
+ }
27
+
28
+ export interface DashboardCache {
29
+ /** Sessions per tool id, from the last `brain dashboard` (counting is expensive: background reports reuse these). */
30
+ counts?: { at: string; tools: Record<string, ToolCount> }
31
+ push?: {
32
+ /** When the last background push started (epoch ms): the next one waits until 30 s after it. */
33
+ lastStartMs?: number
34
+ /** A background push is waiting to run at this time (epoch ms); it reads the state when it runs. */
35
+ pendingUntilMs?: number
36
+ lastAttemptAt?: string
37
+ lastSentAt?: string
38
+ /** Why the last attempt failed (cleared by a success). */
39
+ lastError?: string
40
+ }
41
+ }
42
+
43
+ function readJson<T extends object>(path: string): T {
44
+ try {
45
+ const v = JSON.parse(readFileSync(path, "utf8"))
46
+ return v && typeof v === "object" && !Array.isArray(v) ? (v as T) : ({} as T)
47
+ } catch {
48
+ return {} as T
49
+ }
50
+ }
51
+
52
+ /** Atomic write, 0600, in a 0700 <home>/.brain. */
53
+ export function writePrivateJson(path: string, value: unknown) {
54
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
55
+ const tmp = `${path}.${process.pid}.brain-tmp`
56
+ try {
57
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 })
58
+ chmodSync(tmp, 0o600)
59
+ renameSync(tmp, path)
60
+ } finally {
61
+ rmSync(tmp, { force: true })
62
+ }
63
+ }
64
+
65
+ export const readSettings = (home: string): DashboardSettings => readJson<DashboardSettings>(settingsPath(home))
66
+ export function updateSettings(home: string, change: (s: DashboardSettings) => void): DashboardSettings {
67
+ const s = readSettings(home)
68
+ change(s)
69
+ writePrivateJson(settingsPath(home), s)
70
+ return s
71
+ }
72
+
73
+ export const readCache = (home: string): DashboardCache => readJson<DashboardCache>(cachePath(home))
74
+ /** Best effort: bookkeeping that cannot be written is not a reason to fail. */
75
+ export function updateCache(home: string, change: (c: DashboardCache) => void): DashboardCache {
76
+ const c = readCache(home)
77
+ change(c)
78
+ try {
79
+ writePrivateJson(cachePath(home), c)
80
+ } catch {}
81
+ return c
82
+ }
83
+
84
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
85
+
86
+ /** This machine's id: made once, kept in <home>/.brain/machine.json. A missing or damaged file gets a new one. */
87
+ 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
90
+ const id = crypto.randomUUID()
91
+ try {
92
+ writePrivateJson(machinePath(home), { id, createdAt: new Date().toISOString() })
93
+ } catch {}
94
+ return id
95
+ }