@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.
- 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 +261 -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
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 #
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|