@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.
- package/README.md +66 -3
- package/package.json +1 -1
- package/src/cli.ts +28 -3
- package/src/dashboard/cli.ts +148 -0
- package/src/dashboard/conversions.ts +170 -0
- package/src/dashboard/report.ts +275 -0
- package/src/dashboard/send.ts +213 -0
- package/src/dashboard/state.ts +95 -0
- package/src/gateway/common.ts +232 -0
- package/src/gateway/connect.ts +164 -162
- package/src/gateway/connectors/aider.ts +356 -0
- package/src/gateway/connectors/amazon-q-cli.ts +6 -0
- package/src/gateway/connectors/amazon-q-ide.ts +6 -0
- package/src/gateway/connectors/antigravity.ts +6 -0
- package/src/gateway/connectors/auggie.ts +6 -0
- package/src/gateway/connectors/cline-sdk.ts +85 -0
- package/src/gateway/connectors/cline.ts +34 -0
- package/src/gateway/connectors/codebuff.ts +6 -0
- package/src/gateway/connectors/continue.ts +579 -0
- package/src/gateway/connectors/copilot-cli.ts +138 -0
- package/src/gateway/connectors/crush.ts +313 -0
- package/src/gateway/connectors/cursor-cli.ts +6 -0
- package/src/gateway/connectors/cursor.ts +6 -0
- package/src/gateway/connectors/devin.ts +6 -0
- package/src/gateway/connectors/factory-droid.ts +308 -0
- package/src/gateway/connectors/goose.ts +79 -0
- package/src/gateway/connectors/grok-cli.ts +82 -0
- package/src/gateway/connectors/hermes.ts +230 -0
- package/src/gateway/connectors/iflow-cli.ts +50 -0
- package/src/gateway/connectors/index.ts +86 -0
- package/src/gateway/connectors/kilo-cli.ts +434 -0
- package/src/gateway/connectors/kilo-code.ts +32 -0
- package/src/gateway/connectors/kimi-code.ts +241 -0
- package/src/gateway/connectors/kiro-cli.ts +6 -0
- package/src/gateway/connectors/kiro-ide.ts +6 -0
- package/src/gateway/connectors/letta-code.ts +170 -0
- package/src/gateway/connectors/mastracode.ts +91 -0
- package/src/gateway/connectors/mistral-vibe.ts +495 -0
- package/src/gateway/connectors/muse.ts +27 -0
- package/src/gateway/connectors/omp.ts +279 -0
- package/src/gateway/connectors/openhands.ts +296 -0
- package/src/gateway/connectors/pearai.ts +52 -0
- package/src/gateway/connectors/pi.ts +357 -0
- package/src/gateway/connectors/qoder-cli.ts +6 -0
- package/src/gateway/connectors/qwen-code.ts +411 -0
- package/src/gateway/connectors/roo.ts +43 -0
- package/src/gateway/connectors/tabnine-cli.ts +6 -0
- package/src/gateway/connectors/void.ts +6 -0
- package/src/gateway/connectors/vscode-copilot.ts +286 -0
- package/src/gateway/connectors/windsurf.ts +6 -0
- 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
|
+
}
|