@100xprompt/brain 0.1.0 → 0.1.1

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 +261 -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
package/README.md CHANGED
@@ -87,7 +87,7 @@ you sign in to 100xprompt and confirm, and the key comes back to brain by itself
87
87
  ```bash
88
88
  brain gateway login # sign in in the browser
89
89
  brain gateway connect --tool codex --dry-run # show what would change
90
- brain gateway connect --tool codex # Claude Code, Codex, OpenCode, Gemini CLI (signs in first if needed)
90
+ brain gateway connect --tool codex # any tool in the table below (signs in first if needed)
91
91
  brain gateway status
92
92
  brain gateway disconnect --tool codex # put back exactly what was there
93
93
  brain gateway logout
@@ -103,8 +103,70 @@ brain gateway logout
103
103
  - On a machine without a browser (SSH): `brain gateway login --no-browser` prints the address. Open it anywhere, then
104
104
  paste the key from the page's Copy key button (input is hidden). For scripts and CI: `--key-env VAR`. To reuse the
105
105
  100xprompt CLI's sign-in: `--from-cli`.
106
- - Each sign-in makes a new key on your account. Signing in again also updates the copy Codex and Gemini CLI keep in their
107
- own config.
106
+ - Each sign-in makes a new key on your account. Signing in again also updates every copy of the key that a connected tool
107
+ keeps in its own config.
108
+
109
+ | Tool (`--tool`) | How it gets the key | Context editing | Checked with the real tool |
110
+ |---|---|---|---|
111
+ | Claude Code (`claude-code`) | reads brain's key file at start | yes | yes |
112
+ | Codex (`codex`) | copy in its config, made private | yes | yes |
113
+ | OpenCode (`opencode`) | reads brain's key file | yes | yes |
114
+ | Gemini CLI (`gemini-cli`) | copy in `~/.gemini/.env`, private | no | yes (needs the gateway update that accepts `x-goog-api-key`) |
115
+ | Aider (`aider`) | copy in its model settings, private | yes | yes |
116
+ | Cline CLI (`cline-sdk`) | copy in its providers file, private | yes | yes |
117
+ | Continue (`continue`) | copy in `~/.continue/.env`, private | yes | yes (`cn`) |
118
+ | Copilot CLI (`copilot-cli`) | copy in its providers file, private | yes | yes |
119
+ | Copilot Chat in VS Code (`vscode-copilot`) | copy in VS Code's model list, private | yes | from the extension's code; pick "100xprompt Pro" once in the model picker |
120
+ | Crush (`crush`) | reads brain's key file | yes | yes |
121
+ | Factory Droid (`factory-droid`) | reads brain's key file | yes | yes |
122
+ | Goose (`goose`) | reads brain's key file | yes | yes |
123
+ | Grok CLI (`grok-cli`) | copy in its config, private | yes | yes |
124
+ | Hermes Agent (`hermes`) | copy in `~/.hermes/.env`, private | yes | yes |
125
+ | iFlow CLI (`iflow-cli`) | copy in its settings, private | no | yes |
126
+ | Kilo CLI and Kilo Code (`kilo-cli`, `kilo-code`) | reads brain's key file | yes | yes |
127
+ | Kimi Code (`kimi-code`) | copy in its config, private | yes | yes |
128
+ | Letta Code (`letta-code`) | reads brain's key file (a provider mod; local agents only) | yes | from its source |
129
+ | Mastra Code (`mastracode`) | copy in its settings, private | no | from its source |
130
+ | Mistral Vibe (`mistral-vibe`) | copy in `~/.vibe/.env`, private | yes | yes |
131
+ | Oh My Pi (`omp`) | reads brain's key file | yes | from its source |
132
+ | OpenHands (`openhands`) | copy in its settings, private | yes | yes |
133
+ | PearAI (`pearai`) | copy in `~/.pearai/.env`, private | yes | from its source |
134
+ | Pi (`pi`) | reads brain's key file | yes | yes |
135
+ | Qwen Code (`qwen-code`) | copy in `~/.qwen/.env`, private | yes | yes |
136
+
137
+ "Checked with the real tool" means the real program, pointed at a local stand-in gateway, sent its request to the gateway's
138
+ route with the key and the gateway's model, and `disconnect` gave every file back byte for byte (its original mode too).
139
+
140
+ Some tools cannot be pointed at the gateway from a file brain can safely write and cleanly undo, so they have no connector:
141
+ Cursor and the Cursor CLI, Windsurf, Antigravity, Amazon Q, Kiro, Auggie, Tabnine, Devin and Muse Code (their vendor's own
142
+ servers only); Zed (the key only in the keychain or its launch environment); Void (settings encrypted in the app's state);
143
+ Roo Code and the Cline extension (they keep the key in the editor's own storage, so disconnect could not take it back);
144
+ Codebuff and Qoder (custom endpoints only through their own sign-in or the keychain). The reason and its source are in each
145
+ tool's file under `src/gateway/connectors/`.
146
+
147
+ Gemini CLI loads only the first `.env` it finds from the project folder up: in a project with its own `.env`, put the
148
+ gateway lines there or export them. Letta Code uses the gateway for its local agents only (cloud agents call models from
149
+ Letta's servers). Tools that read brain's key file stop working after `logout` until the next `login`; Kilo refuses to start
150
+ without it.
151
+
152
+ ## Dashboard
153
+
154
+ Signed in to the gateway, brain sends a short report of this machine to your 100xprompt account, shown in the console's
155
+ Brain view (https://100xprompt.com/dashboard?view=brain): the coding tools found here, their session counts, which are
156
+ connected to the gateway or have brain's MCP tools and hooks, your gateway settings, memory counts, and every chat brain
157
+ moved from one tool into another (when, from and to which tool, the way, its title and its project folder's name). Never
158
+ chat text, file contents, full paths or keys.
159
+
160
+ ```bash
161
+ brain dashboard # count sessions, send the report now, open the Brain view (--no-open prints the address)
162
+ brain dashboard status # on or off, and when the last report went out
163
+ brain dashboard off # send nothing from this machine (brain dashboard on to start again)
164
+ ```
165
+
166
+ The report goes out in the background, at most every 30 seconds, after a command that changes it (`gateway login`,
167
+ `connect`, `disconnect`, `session continue`, `handoff`, `mcp install`/`uninstall`, `session hooks install`/`uninstall`),
168
+ and never makes that command wait or fail. The first time, brain prints what it sends and how to stop it.
169
+ `BRAIN_NO_REPORT=1` also turns it off. The conversion log is `~/.brain/logs/conversions.jsonl`, readable only by you.
108
170
 
109
171
  ## Teams
110
172
 
@@ -146,6 +208,7 @@ brain memory ingest | search | status | forget | sync
146
208
  brain mcp serve | install | uninstall | status
147
209
  brain tokens audit | lookups
148
210
  brain gateway login | logout | connect | disconnect | status | tools
211
+ brain dashboard [push | on | off | status]
149
212
  brain org login | logout | whoami | status | promote | demote | forget | admin ...
150
213
  brain expand <id>
151
214
  brain --version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@100xprompt/brain",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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
@@ -8,7 +8,7 @@ import { gitInfo, homeDir } from "./fsutil.ts"
8
8
  import { secureSelf } from "./self.ts"
9
9
  import { HOOK_TOOLS, installHooks, runHook, uninstallHooks } from "./hooks.ts"
10
10
  import type { Session } from "./session/types.ts"
11
- import { prepare } from "./memory/prepare.ts"
11
+ import { prepare, sessionTokens } from "./memory/prepare.ts"
12
12
  import { countTurns, firstUserText, toJsonl, unpaired } from "./session/util.ts"
13
13
 
14
14
  // brain session: one working session, any AI coding tool.
@@ -40,6 +40,7 @@ brain org login --server <url> --token <t> | logout | whoami | status | promote|
40
40
  brain org admin init|serve|user|token|team|project|policy|audit|retention shared memory for an organisation (roles, scopes, audit)
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
+ 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
43
44
  brain --version the version, and where this brain runs from
44
45
  brain expand <blob id> the full text of a tool output that was replaced by a digest`
45
46
 
@@ -55,6 +56,13 @@ const fail = (s: string): never => {
55
56
  process.stderr.write(`brain: ${s}\n`)
56
57
  process.exit(1)
57
58
  }
59
+ /** After a command that changes what the dashboard report says: a report in a detached process (src/dashboard/send.ts). */
60
+ async function reportLater() {
61
+ try {
62
+ const { reportInBackground } = await import("./dashboard/send.ts")
63
+ reportInBackground(home)
64
+ } catch {}
65
+ }
58
66
 
59
67
  async function allRefs(tool?: string): Promise<{ refs: SessionRef[]; errors: string[] }> {
60
68
  const { READERS } = await adapters()
@@ -160,6 +168,13 @@ async function continueIn(source: Session, target: string, opts: { way?: Way; cw
160
168
  for (const s of skipped) out(`(${s}; using the next way)`)
161
169
  const r = await w.write(session, wopts)
162
170
  report(r)
171
+ if (!r.dryRun) {
172
+ // The dashboard's conversion log: tools, way, title, project folder name and sizes; never the chat (src/dashboard/conversions.ts).
173
+ const { logConversion } = await import("./dashboard/conversions.ts")
174
+ const { readerFor } = await adapters()
175
+ const to = r.tool === "cursor" ? "cursor-ide" : r.tool
176
+ logConversion(home, { from: source.header.tool, fromLabel: readerFor(source.header.tool)?.label ?? toolLabel(source.header.tool), to: r.tool, toLabel: readerFor(to)?.label ?? w.label, way: r.way, title: source.header.title, cwd: source.header.project.cwd ?? cwd, turns: source.turns.length, tokens: sessionTokens(session), lean: prepared.lean })
177
+ }
163
178
  if (!r.dryRun && r.way === "brief") {
164
179
  const brief = Bun.file(r.written[0]!)
165
180
  // The clipboard where the system has one (pbcopy on macOS, wl-copy or xclip on Linux, clip on Windows); a missing tool is fine.
@@ -171,6 +186,7 @@ async function continueIn(source: Session, target: string, opts: { way?: Way; cw
171
186
  if (copied) out(" (the brief is also on the clipboard)")
172
187
  }
173
188
  if (!r.dryRun && opts.open && target === "cursor") Bun.spawnSync(["/bin/sh", "-c", r.open])
189
+ if (!r.dryRun) await reportLater()
174
190
  return
175
191
  }
176
192
  fail(`no way to continue in "${target}" right now: ${skipped.join("; ")}`)
@@ -199,11 +215,19 @@ async function main() {
199
215
  // Commands with their own flags: `mcp serve` must keep stdout clean for the protocol, so they run before option parsing.
200
216
  if (cmd === "gateway") {
201
217
  const { gatewayCommand } = await import("./gateway/connect.ts")
202
- process.exit(await gatewayCommand(rest, home))
218
+ const code = await gatewayCommand(rest, home)
219
+ if (code === 0 && ["login", "logout", "connect", "disconnect"].includes(rest[0] ?? "") && !rest.includes("--dry-run")) await reportLater()
220
+ process.exit(code)
203
221
  }
204
222
  if (cmd === "mcp") {
205
223
  const { mcpCommand } = await import("./memory/mcp-install.ts")
206
- process.exit(await mcpCommand(rest, home))
224
+ const code = await mcpCommand(rest, home)
225
+ if (code === 0 && ["install", "uninstall"].includes(rest[0] ?? "") && !rest.includes("--dry-run")) await reportLater()
226
+ process.exit(code)
227
+ }
228
+ if (cmd === "dashboard") {
229
+ const { dashboardCommand } = await import("./dashboard/cli.ts")
230
+ process.exit(await dashboardCommand(rest, home))
207
231
  }
208
232
  if (cmd === "memory" && rest[0] === "sync") {
209
233
  const { syncMemory } = await import("./memory/sync.ts")
@@ -311,6 +335,7 @@ async function main() {
311
335
  : fail(`hooks ${action}? use install, uninstall or status`)
312
336
  for (const c of changes) out(`${c.tool.padEnd(14)} ${c.action}${c.why ? ` (${c.why})` : ""} ${short(c.path)}`)
313
337
  if (!changes.length) out("no tools with session-start hooks found")
338
+ if ((action === "install" || action === "uninstall") && !values["dry-run"]) await reportLater()
314
339
  return
315
340
  }
316
341
  if (sub === "hook") {
@@ -0,0 +1,148 @@
1
+ // `brain dashboard [push|on|off|status] [--quiet] [--no-open]`: this machine's report for the Brain view of the 100xprompt
2
+ // console (https://100xprompt.com/dashboard?view=brain). The dashboard is on the website only; brain sends the report
3
+ // (report.ts, send.ts) and opens the page.
4
+ import type { Reader } from "../adapters/types.ts"
5
+ import type { Env } from "../gateway/common.ts"
6
+ import { openInBrowser } from "../gateway/signin.ts"
7
+ import { readConversions } from "./conversions.ts"
8
+ import { DASHBOARD_URL, gatewayUrl, noticeOnce, reportUrl, sendingOff, sendReport, signedIn, startScheduled } from "./send.ts"
9
+ import type { BrainReport } from "./report.ts"
10
+ import { readCache, readSettings, updateSettings } from "./state.ts"
11
+
12
+ const USAGE = [
13
+ "brain dashboard [--no-open] count this machine's sessions, send its report to your 100xprompt account now, and open the Brain view",
14
+ "brain dashboard push [--quiet] send the report now, without counting sessions (the last counts are reused)",
15
+ "brain dashboard off | on stop or start sending reports from this machine (kept in ~/.brain/dashboard.json)",
16
+ "brain dashboard status whether reports are sent, and when the last one went out",
17
+ ]
18
+
19
+ const FLAGS = new Set(["--quiet", "--no-open", "--at", "--help", "-h"])
20
+
21
+ function ago(iso: string | undefined, now = Date.now()): string {
22
+ const t = iso ? Date.parse(iso) : NaN
23
+ if (!Number.isFinite(t)) return ""
24
+ const m = Math.max(0, Math.round((now - t) / 60_000))
25
+ return m < 1 ? "just now" : m < 60 ? `${m}m ago` : m < 48 * 60 ? `${Math.round(m / 60)}h ago` : `${Math.round(m / 1440)}d ago`
26
+ }
27
+
28
+ const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
29
+
30
+ /** One line on what a report holds. */
31
+ export function summary(r: BrainReport): string {
32
+ const counted = r.harnesses.filter((h) => h.sessions !== null)
33
+ const sessions = counted.reduce((n, h) => n + (h.sessions ?? 0), 0)
34
+ const connected = r.harnesses.filter((h) => h.gateway === "connected").length
35
+ return `${plural(r.harnesses.length, "tool")} (${connected} connected to the gateway)${counted.length ? `, ${plural(sessions, "session")}` : ""}, ${plural(r.totals.conversions, "conversion")}`
36
+ }
37
+
38
+ export async function dashboardCommand(
39
+ args: string[],
40
+ home: string,
41
+ io: { out(l: string): void; err(l: string): void } = { out: (l) => process.stdout.write(`${l}\n`), err: (l) => process.stderr.write(`${l}\n`) },
42
+ deps: { env?: Env; fetchImpl?: typeof fetch; open?: (url: string) => void; countTimeoutMs?: number; readers?: Reader[] } = {},
43
+ ): Promise<number> {
44
+ const env = deps.env ?? process.env
45
+ const atAt = args.indexOf("--at")
46
+ const words = args.filter((a, i) => !a.startsWith("-") && !(atAt >= 0 && i === atAt + 1))
47
+ const sub = words[0]
48
+ const quiet = args.includes("--quiet")
49
+ const say = (l: string) => {
50
+ if (!quiet) io.out(l)
51
+ }
52
+ const unknown = args.find((a) => a.startsWith("-") && !FLAGS.has(a))
53
+ if (args.includes("--help") || args.includes("-h") || sub === "help") {
54
+ USAGE.forEach((l) => io.out(l))
55
+ return 0
56
+ }
57
+ if (unknown || words.length > 1) {
58
+ io.out(unknown ? `unknown option ${unknown}` : `unexpected "${words[1]}"`)
59
+ USAGE.forEach((l) => io.out(`usage: ${l}`))
60
+ return 2
61
+ }
62
+ const notSignedIn = "not signed in to the gateway: run `brain gateway login` first, then `brain dashboard`"
63
+ const send = async (counts: boolean): Promise<void> => {
64
+ // The notice goes where the person reads it: the output, or stderr when the output is asked to stay quiet.
65
+ noticeOnce(home, quiet ? io.err : io.out)
66
+ const { buildReport, countAndKeep } = await import("./report.ts")
67
+ const report = await buildReport(home, {
68
+ env,
69
+ ...(counts ? { counts: await countAndKeep(home, { timeoutMs: deps.countTimeoutMs ?? 20_000, ...(deps.readers ? { readers: deps.readers } : {}) }) } : {}),
70
+ })
71
+ const r = await sendReport(home, report, deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {})
72
+ say(r.ok ? `sent this machine's report to ${gatewayUrl(home)}: ${summary(report)}` : `could not send the report: ${r.why}; the dashboard keeps the last report it got`)
73
+ }
74
+
75
+ switch (sub) {
76
+ case undefined: {
77
+ if (!signedIn(home)) {
78
+ io.out(notSignedIn)
79
+ return 1
80
+ }
81
+ const off = sendingOff(home, env)
82
+ if (off) say(`reports are off (${off}): nothing was sent${readSettings(home).off ? "; `brain dashboard on` starts them again" : ""}`)
83
+ else await send(true)
84
+ if (args.includes("--no-open")) say(`the dashboard: ${DASHBOARD_URL}`)
85
+ else {
86
+ ;(deps.open ?? openInBrowser)(DASHBOARD_URL)
87
+ say(`opening ${DASHBOARD_URL}`)
88
+ }
89
+ return 0
90
+ }
91
+ case "push": {
92
+ // `--at <ms>`: a background push started by another command waits for its turn (send.ts), then reads the state.
93
+ const at = atAt >= 0 ? Number(args[atAt + 1]) : NaN
94
+ if (Number.isFinite(at)) await startScheduled(home, at, env)
95
+ if (!signedIn(home)) {
96
+ say(notSignedIn)
97
+ return quiet ? 0 : 1
98
+ }
99
+ const off = sendingOff(home, env)
100
+ if (off) {
101
+ say(`reports are off (${off}): nothing was sent`)
102
+ return 0
103
+ }
104
+ await send(false)
105
+ return 0
106
+ }
107
+ case "off":
108
+ updateSettings(home, (s) => {
109
+ s.off = true
110
+ })
111
+ 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
+ return 0
113
+ case "on": {
114
+ updateSettings(home, (s) => {
115
+ delete s.off
116
+ })
117
+ 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
+ say(`reports are on: brain sends this machine's report after commands that change it, and with \`brain dashboard\`${notes.length ? ` (${notes.join("; ")})` : ""}.`)
119
+ return 0
120
+ }
121
+ case "status": {
122
+ const { gatewayState, machineName } = await import("./report.ts")
123
+ const { machineId } = await import("./state.ts")
124
+ const off = sendingOff(home, env)
125
+ const push = readCache(home).push ?? {}
126
+ const counts = readCache(home).counts
127
+ const email = gatewayState(home).email
128
+ const lines = [
129
+ `reports: ${off ? `off (${off})` : "on"}`,
130
+ `signed in: ${signedIn(home) ? `yes${typeof email === "string" && email ? `, as ${email}` : ""}` : "no (brain gateway login)"}`,
131
+ `sends to: ${reportUrl(home)}`,
132
+ `last sent: ${push.lastSentAt ? `${push.lastSentAt} (${ago(push.lastSentAt)})` : "never"}`,
133
+ ...(push.lastError && push.lastAttemptAt ? [`last attempt: ${push.lastAttemptAt} (${ago(push.lastAttemptAt)}) failed: ${push.lastError}`] : []),
134
+ ...(typeof push.pendingUntilMs === "number" && push.pendingUntilMs > Date.now() ? [`next: a report waits to go out at ${new Date(push.pendingUntilMs).toISOString()}`] : []),
135
+ `sessions counted: ${counts?.at ? `${counts.at} (${ago(counts.at)}; \`brain dashboard\` counts them again)` : "never (`brain dashboard` counts them)"}`,
136
+ `conversions logged: ${readConversions(home, 0).total}`,
137
+ `machine: ${machineName()} (${machineId(home)})`,
138
+ `view: ${DASHBOARD_URL}`,
139
+ ]
140
+ lines.forEach((l) => io.out(l))
141
+ return 0
142
+ }
143
+ default:
144
+ io.out(`unknown dashboard command "${sub}"`)
145
+ USAGE.forEach((l) => io.out(`usage: ${l}`))
146
+ return 2
147
+ }
148
+ }
@@ -0,0 +1,170 @@
1
+ // The conversion log: one line per session brain carried from one tool into another (`session continue` / `handoff`, not
2
+ // dry runs), for the Brain view of the console. <home>/.brain/logs/conversions.jsonl (0600), append-only. What a line
3
+ // holds is exactly a conversion of the dashboard report (docs/dashboard-contract.md): which tools, how, when, the chat's
4
+ // title and its project FOLDER NAME. Never the chat text, never a full path.
5
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs"
6
+ import { homedir } from "node:os"
7
+ import { basename, join, resolve } from "node:path"
8
+ import type { Way } from "../adapters/types.ts"
9
+ import { redact } from "../memory/redact.ts"
10
+
11
+ export const conversionLog = (home: string) => join(home, ".brain", "logs", "conversions.jsonl")
12
+
13
+ export const TITLE_MAX = 200
14
+ export const PROJECT_MAX = 120
15
+ const LABEL_MAX = 80
16
+ const WAYS: readonly Way[] = ["native", "import", "brief"]
17
+
18
+ export interface Conversion {
19
+ /** Made when the conversion happened: the gateway drops a conversion it already has. */
20
+ id: string
21
+ at: string
22
+ from: string
23
+ fromLabel: string
24
+ to: string
25
+ toLabel: string
26
+ way: Way
27
+ title: string | null
28
+ /** The project folder's name only (basename), never its path. */
29
+ project: string | null
30
+ turns: number | null
31
+ /** Estimated tokens of the converted session. */
32
+ tokens: number | null
33
+ /** Big tool outputs were replaced by digests. */
34
+ lean: boolean
35
+ }
36
+
37
+ /** At most `n` UTF-16 units, never ending in half of a surrogate pair. */
38
+ export function clip(s: string, n: number): string {
39
+ if (s.length <= n) return s
40
+ const cut = s.slice(0, n)
41
+ return /[\uD800-\uDBFF]$/.test(cut) ? cut.slice(0, -1) : cut
42
+ }
43
+
44
+ /** A title as it may be sent: one line, secrets taken out, at most 200 characters; null when there is none. */
45
+ export function cleanTitle(t: unknown): string | null {
46
+ if (typeof t !== "string") return null
47
+ const one = redact(t.replace(/\s+/g, " ").trim())
48
+ return one ? clip(one, TITLE_MAX) : null
49
+ }
50
+
51
+ /** The project folder's name, from a folder path or a name; null for none, the root, or a home folder (not a project). */
52
+ export function projectName(cwd: unknown, homes: string[] = []): string | null {
53
+ if (typeof cwd !== "string" || !cwd.trim()) return null
54
+ const p = cwd.trim()
55
+ if (p.includes("/") || p.includes("\\")) {
56
+ const full = resolve(p)
57
+ if (full === "/" || homes.some((h) => h && resolve(h) === full)) return null
58
+ }
59
+ const name = basename(p.replace(/[\\/]+$/, "")).split("\\").pop() ?? ""
60
+ return name ? clip(name, PROJECT_MAX) : null
61
+ }
62
+
63
+ const count = (n: unknown): number | null => (typeof n === "number" && Number.isFinite(n) && n >= 0 ? Math.floor(n) : null)
64
+ const label = (s: unknown, fallback: string) => clip(typeof s === "string" && s.trim() ? s.trim() : fallback, LABEL_MAX)
65
+
66
+ /** What `continue` knows when a writer has written: the tools, the way, and the source session's header and size. */
67
+ export interface ConversionInput {
68
+ from: string
69
+ fromLabel: string
70
+ to: string
71
+ toLabel: string
72
+ way: Way
73
+ title?: string
74
+ /** The session's project folder (only its name is kept). */
75
+ cwd?: string
76
+ turns?: number
77
+ tokens?: number
78
+ lean: boolean
79
+ at?: string
80
+ }
81
+
82
+ /** "cursor" is what `continue --in` calls Cursor; everywhere else (readers, hooks, harnesses) it is "cursor-ide". */
83
+ export const toolId = (tool: string) => (tool === "cursor" ? "cursor-ide" : tool)
84
+
85
+ /** One line for the log, from what `continue` knows. */
86
+ export function conversionOf(c: ConversionInput, homes: string[] = [process.env.BRAIN_HOME ?? "", process.env.HOME ?? "", homedir()]): Conversion {
87
+ return {
88
+ id: crypto.randomUUID(),
89
+ at: c.at ?? new Date().toISOString(),
90
+ from: toolId(c.from),
91
+ fromLabel: label(c.fromLabel, c.from),
92
+ to: toolId(c.to),
93
+ toLabel: label(c.toLabel.replace(/^the /, ""), c.to),
94
+ way: c.way,
95
+ title: cleanTitle(c.title),
96
+ project: projectName(c.cwd, homes),
97
+ turns: count(c.turns),
98
+ tokens: count(c.tokens),
99
+ lean: !!c.lean,
100
+ }
101
+ }
102
+
103
+ /** Appends one conversion. Never throws: a log must not break a conversion that already happened. */
104
+ export function logConversion(home: string, c: ConversionInput): Conversion | undefined {
105
+ try {
106
+ const line = conversionOf(c)
107
+ const f = conversionLog(home)
108
+ mkdirSync(join(home, ".brain", "logs"), { recursive: true, mode: 0o700 })
109
+ const fresh = !existsSync(f)
110
+ appendFileSync(f, `${JSON.stringify(line)}\n`, { mode: 0o600 })
111
+ if (fresh) chmodSync(f, 0o600)
112
+ return line
113
+ } catch {
114
+ return undefined
115
+ }
116
+ }
117
+
118
+ const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/
119
+
120
+ /** A line of the log as it may be sent, or undefined for a line that is not a conversion (a damaged or foreign line). */
121
+ export function parseConversion(line: string): Conversion | undefined {
122
+ let v: any
123
+ try {
124
+ v = JSON.parse(line)
125
+ } catch {
126
+ return undefined
127
+ }
128
+ if (!v || typeof v !== "object" || typeof v.id !== "string" || !v.id || typeof v.at !== "string" || !ISO.test(v.at)) return undefined
129
+ if (typeof v.from !== "string" || !v.from || typeof v.to !== "string" || !v.to || !WAYS.includes(v.way)) return undefined
130
+ // Read back through the same filters it was written with: whatever is in the file, only these fields, clipped, leave.
131
+ return {
132
+ id: clip(v.id, 64),
133
+ at: v.at,
134
+ from: clip(v.from, LABEL_MAX),
135
+ fromLabel: label(v.fromLabel, v.from),
136
+ to: clip(v.to, LABEL_MAX),
137
+ toLabel: label(v.toLabel, v.to),
138
+ way: v.way,
139
+ title: cleanTitle(v.title),
140
+ project: projectName(v.project),
141
+ turns: count(v.turns),
142
+ tokens: count(v.tokens),
143
+ lean: v.lean === true,
144
+ }
145
+ }
146
+
147
+ /** Months (UTC, "YYYY-MM") counted in a report's totals.byMonth: the latest twelve that have any conversion. */
148
+ export const MONTHS_KEPT = 12
149
+
150
+ /** The newest `limit` conversions (newest first), how many the log holds in all, and how many per month (UTC). */
151
+ export function readConversions(home: string, limit = 500): { list: Conversion[]; total: number; byMonth: Record<string, number> } {
152
+ let text = ""
153
+ try {
154
+ text = readFileSync(conversionLog(home), "utf8")
155
+ } catch {
156
+ return { list: [], total: 0, byMonth: {} }
157
+ }
158
+ const all: Conversion[] = []
159
+ for (const line of text.split("\n")) {
160
+ if (!line.trim()) continue
161
+ const c = parseConversion(line)
162
+ if (c) all.push(c)
163
+ }
164
+ // The file is in the order they happened; a sort by time keeps that for lines written by machines with a skewed clock.
165
+ const newest = all.map((c, i) => ({ c, i })).sort((a, b) => b.c.at.localeCompare(a.c.at) || b.i - a.i).map((x) => x.c)
166
+ const months = new Map<string, number>()
167
+ for (const c of all) months.set(c.at.slice(0, 7), (months.get(c.at.slice(0, 7)) ?? 0) + 1)
168
+ const byMonth = Object.fromEntries([...months].sort((a, b) => b[0].localeCompare(a[0])).slice(0, MONTHS_KEPT))
169
+ return { list: newest.slice(0, Math.max(0, limit)), total: all.length, byMonth }
170
+ }