@100xprompt/brain 0.0.0-stage → 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/LICENSE +48 -0
- package/README.md +235 -2
- package/package.json +48 -4
- package/src/adapters/aider/index.ts +8 -0
- package/src/adapters/aider/reader.ts +129 -0
- package/src/adapters/aider/records.ts +220 -0
- package/src/adapters/aider/writer.ts +269 -0
- package/src/adapters/amazon-q-cli/index.ts +8 -0
- package/src/adapters/amazon-q-cli/reader.ts +84 -0
- package/src/adapters/amazon-q-cli/records.ts +191 -0
- package/src/adapters/amazon-q-cli/writer.ts +370 -0
- package/src/adapters/amazon-q-ide/index.ts +9 -0
- package/src/adapters/amazon-q-ide/reader.ts +131 -0
- package/src/adapters/amazon-q-ide/records.ts +141 -0
- package/src/adapters/amazon-q-ide/writer.ts +325 -0
- package/src/adapters/antigravity/index.ts +9 -0
- package/src/adapters/antigravity/proto.ts +194 -0
- package/src/adapters/antigravity/reader.ts +424 -0
- package/src/adapters/antigravity/records.ts +236 -0
- package/src/adapters/antigravity/writer.ts +463 -0
- package/src/adapters/auggie/index.ts +36 -0
- package/src/adapters/claude-code/index.ts +8 -0
- package/src/adapters/claude-code/reader.ts +639 -0
- package/src/adapters/claude-code/records.ts +88 -0
- package/src/adapters/claude-code/writer.ts +329 -0
- package/src/adapters/cline/index.ts +12 -0
- package/src/adapters/cline/reader.ts +432 -0
- package/src/adapters/cline/records.ts +151 -0
- package/src/adapters/cline/vscdb.ts +56 -0
- package/src/adapters/cline/writer.ts +656 -0
- package/src/adapters/cline/xml.ts +177 -0
- package/src/adapters/cline4/index.ts +9 -0
- package/src/adapters/cline4/reader.ts +179 -0
- package/src/adapters/cline4/records.ts +92 -0
- package/src/adapters/cline4/writer.ts +361 -0
- package/src/adapters/codebuff/index.ts +8 -0
- package/src/adapters/codebuff/project.ts +207 -0
- package/src/adapters/codebuff/reader.ts +215 -0
- package/src/adapters/codebuff/records.ts +173 -0
- package/src/adapters/codebuff/writer.ts +383 -0
- package/src/adapters/codex/index.ts +8 -0
- package/src/adapters/codex/native.ts +239 -0
- package/src/adapters/codex/reader.ts +747 -0
- package/src/adapters/codex/records.ts +114 -0
- package/src/adapters/codex/writer.ts +479 -0
- package/src/adapters/continue/index.ts +9 -0
- package/src/adapters/continue/reader.ts +68 -0
- package/src/adapters/continue/records.ts +418 -0
- package/src/adapters/continue/writer.ts +162 -0
- package/src/adapters/copilot-cli/index.ts +9 -0
- package/src/adapters/copilot-cli/reader.ts +186 -0
- package/src/adapters/copilot-cli/records.ts +241 -0
- package/src/adapters/copilot-cli/writer.ts +353 -0
- package/src/adapters/crush/index.ts +8 -0
- package/src/adapters/crush/reader.ts +180 -0
- package/src/adapters/crush/records.ts +169 -0
- package/src/adapters/crush/writer.ts +333 -0
- package/src/adapters/cursor-cli/index.ts +405 -0
- package/src/adapters/cursor-ide/agentkv.ts +95 -0
- package/src/adapters/cursor-ide/index.ts +501 -0
- package/src/adapters/cursor-ide/transcripts.ts +122 -0
- package/src/adapters/cursor-ide/workspaces.ts +119 -0
- package/src/adapters/cursor-ide/writer.ts +170 -0
- package/src/adapters/devin/index.ts +8 -0
- package/src/adapters/devin/reader.ts +305 -0
- package/src/adapters/devin/records.ts +277 -0
- package/src/adapters/devin/writer.ts +523 -0
- package/src/adapters/factory-droid/index.ts +12 -0
- package/src/adapters/factory-droid/reader.ts +184 -0
- package/src/adapters/factory-droid/records.ts +79 -0
- package/src/adapters/factory-droid/writer.ts +350 -0
- package/src/adapters/gemini-cli/exchanges.ts +228 -0
- package/src/adapters/gemini-cli/index.ts +396 -0
- package/src/adapters/gemini-cli/records.ts +521 -0
- package/src/adapters/gemini-cli/writer.ts +354 -0
- package/src/adapters/goose/index.ts +8 -0
- package/src/adapters/goose/reader.ts +138 -0
- package/src/adapters/goose/records.ts +199 -0
- package/src/adapters/goose/writer.ts +340 -0
- package/src/adapters/grok-cli/image.ts +44 -0
- package/src/adapters/grok-cli/index.ts +8 -0
- package/src/adapters/grok-cli/reader.ts +302 -0
- package/src/adapters/grok-cli/records.ts +172 -0
- package/src/adapters/grok-cli/writer.ts +329 -0
- package/src/adapters/hermes/index.ts +8 -0
- package/src/adapters/hermes/reader.ts +249 -0
- package/src/adapters/hermes/records.ts +285 -0
- package/src/adapters/hermes/writer.ts +452 -0
- package/src/adapters/iflow-cli/index.ts +72 -0
- package/src/adapters/iflow-cli/records.ts +215 -0
- package/src/adapters/iflow-cli/writer.ts +204 -0
- package/src/adapters/index.ts +112 -0
- package/src/adapters/kilo-cli/index.ts +21 -0
- package/src/adapters/kilo-code/index.ts +11 -0
- package/src/adapters/kilo-code/writer.ts +92 -0
- package/src/adapters/kimi/index.ts +9 -0
- package/src/adapters/kimi/reader.ts +92 -0
- package/src/adapters/kimi/records.ts +221 -0
- package/src/adapters/kimi/writer.ts +367 -0
- package/src/adapters/kiro-cli/index.ts +8 -0
- package/src/adapters/kiro-cli/reader.ts +87 -0
- package/src/adapters/kiro-cli/records.ts +260 -0
- package/src/adapters/kiro-cli/writer.ts +305 -0
- package/src/adapters/kiro-ide/index.ts +8 -0
- package/src/adapters/kiro-ide/writer.ts +52 -0
- package/src/adapters/letta/index.ts +8 -0
- package/src/adapters/letta/reader.ts +177 -0
- package/src/adapters/letta/records.ts +119 -0
- package/src/adapters/letta/writer.ts +413 -0
- package/src/adapters/mastracode/index.ts +8 -0
- package/src/adapters/mastracode/reader.ts +323 -0
- package/src/adapters/mastracode/records.ts +336 -0
- package/src/adapters/mastracode/writer.ts +385 -0
- package/src/adapters/mistral-vibe/index.ts +9 -0
- package/src/adapters/mistral-vibe/reader.ts +62 -0
- package/src/adapters/mistral-vibe/records.ts +104 -0
- package/src/adapters/mistral-vibe/writer.ts +359 -0
- package/src/adapters/muse/index.ts +10 -0
- package/src/adapters/muse/reader.ts +332 -0
- package/src/adapters/muse/records.ts +104 -0
- package/src/adapters/muse/writer.ts +387 -0
- package/src/adapters/omp/index.ts +13 -0
- package/src/adapters/omp/reader.ts +314 -0
- package/src/adapters/omp/records.ts +153 -0
- package/src/adapters/omp/writer.ts +216 -0
- package/src/adapters/opencode/ids.ts +52 -0
- package/src/adapters/opencode/index.ts +31 -0
- package/src/adapters/opencode/note.ts +8 -0
- package/src/adapters/opencode/reader.ts +452 -0
- package/src/adapters/opencode/tools.ts +174 -0
- package/src/adapters/opencode/v2.ts +367 -0
- package/src/adapters/opencode/writer.ts +379 -0
- package/src/adapters/openhands/index.ts +8 -0
- package/src/adapters/openhands/reader.ts +222 -0
- package/src/adapters/openhands/records.ts +136 -0
- package/src/adapters/openhands/writer.ts +404 -0
- package/src/adapters/pearai/index.ts +10 -0
- package/src/adapters/pi/index.ts +28 -0
- package/src/adapters/pi/reader.ts +261 -0
- package/src/adapters/pi/records.ts +99 -0
- package/src/adapters/pi/writer.ts +362 -0
- package/src/adapters/qoder-cli/index.ts +8 -0
- package/src/adapters/qoder-cli/reader.ts +83 -0
- package/src/adapters/qoder-cli/records.ts +45 -0
- package/src/adapters/qoder-cli/writer.ts +72 -0
- package/src/adapters/qwen-code/index.ts +84 -0
- package/src/adapters/qwen-code/records.ts +194 -0
- package/src/adapters/qwen-code/writer.ts +224 -0
- package/src/adapters/tabnine-cli/index.ts +8 -0
- package/src/adapters/tabnine-cli/reader.ts +106 -0
- package/src/adapters/tabnine-cli/records.ts +33 -0
- package/src/adapters/tabnine-cli/writer.ts +156 -0
- package/src/adapters/types.ts +73 -0
- package/src/adapters/void/index.ts +9 -0
- package/src/adapters/void/reader.ts +48 -0
- package/src/adapters/void/records.ts +285 -0
- package/src/adapters/void/writer.ts +93 -0
- package/src/adapters/vscode-copilot/index.ts +9 -0
- package/src/adapters/vscode-copilot/reader.ts +212 -0
- package/src/adapters/vscode-copilot/records.ts +388 -0
- package/src/adapters/vscode-copilot/shared.ts +68 -0
- package/src/adapters/vscode-copilot/writer.ts +546 -0
- package/src/adapters/zed/index.ts +8 -0
- package/src/adapters/zed/reader.ts +198 -0
- package/src/adapters/zed/records.ts +87 -0
- package/src/adapters/zed/writer.ts +341 -0
- package/src/brief-writers.ts +71 -0
- package/src/brief.ts +238 -0
- package/src/cli.ts +351 -0
- 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/fsutil.ts +133 -0
- package/src/gateway/common.ts +232 -0
- package/src/gateway/connect.ts +847 -0
- 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/src/gateway/signin.ts +323 -0
- package/src/handoff.ts +104 -0
- package/src/hooks.ts +455 -0
- package/src/memory/audit.ts +514 -0
- package/src/memory/blobs.ts +94 -0
- package/src/memory/cli.ts +117 -0
- package/src/memory/distill.ts +381 -0
- package/src/memory/externalize.ts +435 -0
- package/src/memory/lookups.ts +70 -0
- package/src/memory/mcp-install.ts +401 -0
- package/src/memory/mcp-server.ts +296 -0
- package/src/memory/prepare.ts +179 -0
- package/src/memory/preview.ts +154 -0
- package/src/memory/project.ts +35 -0
- package/src/memory/redact.ts +72 -0
- package/src/memory/relevance.ts +187 -0
- package/src/memory/repomap.ts +516 -0
- package/src/memory/store.ts +270 -0
- package/src/memory/sync.ts +166 -0
- package/src/memory/tidy.ts +193 -0
- package/src/memory/types.ts +118 -0
- package/src/org/admin-ops.ts +96 -0
- package/src/org/auth.ts +112 -0
- package/src/org/backup.ts +115 -0
- package/src/org/cli.ts +438 -0
- package/src/org/client.ts +404 -0
- package/src/org/config.ts +44 -0
- package/src/org/crypto.ts +537 -0
- package/src/org/db.ts +435 -0
- package/src/org/graph.ts +318 -0
- package/src/org/oidc.ts +435 -0
- package/src/org/policy.ts +398 -0
- package/src/org/server.ts +920 -0
- package/src/org/sso.ts +316 -0
- package/src/org/tls.ts +66 -0
- package/src/org/types.ts +193 -0
- package/src/self.ts +68 -0
- package/src/session/attachments.ts +53 -0
- package/src/session/peekcache.ts +50 -0
- package/src/session/types.ts +104 -0
- package/src/session/util.ts +130 -0
- package/src/sqlite.ts +48 -0
|
@@ -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
|
+
}
|
package/src/fsutil.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"
|
|
2
|
+
import { join, resolve } from "node:path"
|
|
3
|
+
import type { Project } from "./session/types.ts"
|
|
4
|
+
|
|
5
|
+
/** The home folder readers and writers work in: $BRAIN_HOME in tests, else the real one. */
|
|
6
|
+
export function homeDir(): string {
|
|
7
|
+
return process.env.BRAIN_HOME ?? process.env.HOME ?? ""
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** macOS application-support folder for a home. */
|
|
11
|
+
export const appSupport = (home: string) => join(home, "Library", "Application Support")
|
|
12
|
+
|
|
13
|
+
export const exists = (p: string) => existsSync(p)
|
|
14
|
+
|
|
15
|
+
export function isDir(p: string): boolean {
|
|
16
|
+
try {
|
|
17
|
+
return statSync(p).isDirectory()
|
|
18
|
+
} catch {
|
|
19
|
+
return false
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function mtime(p: string): string | undefined {
|
|
24
|
+
try {
|
|
25
|
+
return statSync(p).mtime.toISOString()
|
|
26
|
+
} catch {
|
|
27
|
+
return undefined
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Directory entries, or none when the directory is missing or unreadable. */
|
|
32
|
+
export function ls(dir: string): string[] {
|
|
33
|
+
try {
|
|
34
|
+
return readdirSync(dir)
|
|
35
|
+
} catch {
|
|
36
|
+
return []
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Files under `dir` whose names match, depth-first, at most `depth` levels down. */
|
|
41
|
+
export function findFiles(dir: string, match: (name: string) => boolean, depth = 4): string[] {
|
|
42
|
+
const out: string[] = []
|
|
43
|
+
const walk = (d: string, left: number) => {
|
|
44
|
+
for (const name of ls(d)) {
|
|
45
|
+
const p = join(d, name)
|
|
46
|
+
if (isDir(p)) {
|
|
47
|
+
if (left > 0) walk(p, left - 1)
|
|
48
|
+
} else if (match(name)) out.push(p)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
walk(dir, depth)
|
|
52
|
+
return out
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** JSON from a file, or undefined when it is missing or not JSON. */
|
|
56
|
+
export function readJson<T = unknown>(p: string): T | undefined {
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(readFileSync(p, "utf8")) as T
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Each line of a JSONL file parsed, skipping lines that are not JSON (a half-written last line, say); `bad` counts them. */
|
|
65
|
+
export function readJsonl(p: string): { rows: Array<Record<string, any>>; bad: number } {
|
|
66
|
+
let text = ""
|
|
67
|
+
try {
|
|
68
|
+
text = readFileSync(p, "utf8")
|
|
69
|
+
} catch {
|
|
70
|
+
return { rows: [], bad: 0 }
|
|
71
|
+
}
|
|
72
|
+
const rows: Array<Record<string, any>> = []
|
|
73
|
+
let bad = 0
|
|
74
|
+
for (const line of text.split("\n")) {
|
|
75
|
+
if (!line.trim()) continue
|
|
76
|
+
try {
|
|
77
|
+
rows.push(JSON.parse(line))
|
|
78
|
+
} catch {
|
|
79
|
+
bad++
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { rows, bad }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Git remote, branch and commit for a folder, read from .git without running git; empty when it is not a
|
|
87
|
+
* repository. The walk up stops below the home folder: a dotfiles repository at ~/.git would otherwise make
|
|
88
|
+
* every project under home look like one project.
|
|
89
|
+
*/
|
|
90
|
+
export function gitInfo(cwd: string | undefined, home = homeDir()): Omit<Project, "cwd"> {
|
|
91
|
+
if (!cwd) return {}
|
|
92
|
+
const stop = home.replace(/\/+$/, "")
|
|
93
|
+
let dir = cwd
|
|
94
|
+
for (let i = 0; i < 30 && dir && dir !== "/" && dir !== stop; i++) {
|
|
95
|
+
const git = join(dir, ".git")
|
|
96
|
+
if (exists(git)) {
|
|
97
|
+
try {
|
|
98
|
+
// A worktree's .git is a file naming its git dir, absolute or relative to the worktree.
|
|
99
|
+
const gitDir = isDir(git) ? git : resolve(dir, readFileSync(git, "utf8").replace(/^gitdir:\s*/, "").trim())
|
|
100
|
+
const head = readFileSync(join(gitDir, "HEAD"), "utf8").trim()
|
|
101
|
+
const branch = head.startsWith("ref: refs/heads/") ? head.slice(16) : undefined
|
|
102
|
+
let commit: string | undefined = head.startsWith("ref:") ? undefined : head
|
|
103
|
+
if (branch) {
|
|
104
|
+
const ref = join(gitDir, "refs", "heads", branch)
|
|
105
|
+
commit = exists(ref) ? readFileSync(ref, "utf8").trim() : undefined
|
|
106
|
+
}
|
|
107
|
+
const config = exists(join(gitDir, "config")) ? readFileSync(join(gitDir, "config"), "utf8") : ""
|
|
108
|
+
const remote = config.match(/\[remote "origin"\][^[]*?url\s*=\s*(\S+)/)?.[1]
|
|
109
|
+
return { ...(remote ? { gitRemote: remote } : {}), ...(branch ? { gitBranch: branch } : {}), ...(commit ? { gitCommit: commit } : {}) }
|
|
110
|
+
} catch {
|
|
111
|
+
return {}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
dir = dir.slice(0, dir.lastIndexOf("/"))
|
|
115
|
+
}
|
|
116
|
+
return {}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Whether a process with this exact name is running (a desktop app whose store must not be written while open). */
|
|
120
|
+
export function isRunning(processName: string): boolean {
|
|
121
|
+
const p = Bun.spawnSync(["pgrep", "-x", processName], { stdout: "pipe", stderr: "pipe" })
|
|
122
|
+
return p.exitCode === 0
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The first line of `<bin> --version`, or undefined when the binary is missing. */
|
|
126
|
+
export function versionOf(bin: string): string | undefined {
|
|
127
|
+
try {
|
|
128
|
+
const p = Bun.spawnSync([bin, "--version"], { env: process.env, stdout: "pipe", stderr: "pipe", timeout: 10_000 })
|
|
129
|
+
return p.exitCode === 0 ? p.stdout.toString().trim().split("\n")[0] : undefined
|
|
130
|
+
} catch {
|
|
131
|
+
return undefined
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// What every gateway connector shares: the settings, the shape of a connector, where the key lives, and the helpers a
|
|
2
|
+
// connector needs to describe its edits. connect.ts does the reading, writing, recording and undoing; a connector only
|
|
3
|
+
// says which files it changes and to what (src/gateway/connectors/<tool>.ts).
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
5
|
+
import { join } from "node:path"
|
|
6
|
+
|
|
7
|
+
export type Json = Record<string, any>
|
|
8
|
+
export type Env = Record<string, string | undefined>
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_URL = "https://proxy.100xprompt.com"
|
|
11
|
+
export const DEFAULT_ROUTE = "flash"
|
|
12
|
+
export const DEFAULT_MODEL = "gemini-3.8-flash"
|
|
13
|
+
export const EDIT_HEADER = "x-100x-context-edit"
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* What 100xprompt calls the models brain may connect a tool to, as the 100xprompt CLI shows them: tools are given the
|
|
17
|
+
* gateway's model id (that is what the gateway serves), and everything brain prints uses the product name.
|
|
18
|
+
*/
|
|
19
|
+
export const MODEL_NAMES: Record<string, string> = { "gemini-3.8-flash": "100xprompt Pro", "gemini-3.7-flash": "100xprompt Flash", "gemini-3.6-flash": "100xprompt Flash" }
|
|
20
|
+
/** `--model pro` / `--model flash`: the product names, turned into the gateway's model ids. */
|
|
21
|
+
export const MODEL_ALIASES: Record<string, string> = { pro: "gemini-3.8-flash", flash: "gemini-3.7-flash" }
|
|
22
|
+
export const modelName = (id: string) => MODEL_NAMES[id] ?? id
|
|
23
|
+
export const modelId = (given: string) => MODEL_ALIASES[given.trim().toLowerCase()] ?? given.trim()
|
|
24
|
+
|
|
25
|
+
export interface GatewaySettings {
|
|
26
|
+
url: string
|
|
27
|
+
route: string
|
|
28
|
+
/** The gateway's model id (gemini-3.8-flash for 100xprompt Pro). Show it to people with modelName(). */
|
|
29
|
+
model: string
|
|
30
|
+
/** Send `x-100x-context-edit: on` (the gateway clears old tool results when that saves money). */
|
|
31
|
+
contextEdit: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const brainDir = (home: string) => join(home, ".brain")
|
|
35
|
+
export const statePath = (home: string) => join(brainDir(home), "gateway.json")
|
|
36
|
+
/** The gateway key, 0600, one line. A tool that can read a key from a file, a command or an env file should read it here. */
|
|
37
|
+
export const keyPath = (home: string) => join(brainDir(home), "gateway.key")
|
|
38
|
+
|
|
39
|
+
/** The stored key ("" when there is none: a dry run shows what would change without one). Never print it. */
|
|
40
|
+
export function readKey(home: string): string {
|
|
41
|
+
try {
|
|
42
|
+
return readFileSync(keyPath(home), "utf8").trim()
|
|
43
|
+
} catch {
|
|
44
|
+
return ""
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The gateway route's base address, e.g. https://proxy.100xprompt.com/flash. Under it the gateway serves, for every model:
|
|
50
|
+
* /v1/chat/completions OpenAI Chat Completions (Authorization: Bearer <key>)
|
|
51
|
+
* /v1/responses OpenAI Responses (Authorization: Bearer <key>)
|
|
52
|
+
* /v1/messages Anthropic Messages (x-api-key: <key>, or Authorization: Bearer)
|
|
53
|
+
* /v1beta/models/<id>:generateContent and :streamGenerateContent Gemini (x-goog-api-key: <key>)
|
|
54
|
+
* /v1/models answers 403 ("not served"), so a tool that insists on listing models first needs the model named in its config.
|
|
55
|
+
*/
|
|
56
|
+
export const routeUrl = (s: GatewaySettings) => `${s.url.replace(/\/+$/, "")}/${s.route}`
|
|
57
|
+
|
|
58
|
+
/** Single-quoted for a POSIX shell. */
|
|
59
|
+
export const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
|
|
60
|
+
|
|
61
|
+
/** An env-overridable folder that only counts when it exists (CLAUDE_CONFIG_DIR, CODEX_HOME and the like). */
|
|
62
|
+
export const dirFromEnv = (env: Env, name: string, fallback: string) => (env[name] && existsSync(env[name]!) ? env[name]! : fallback)
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A file format the core does not read itself (YAML, JSON with comments, a tool's own format): the connector reads and
|
|
66
|
+
* writes it. Keys are whatever the codec understands (for yamlTopLevel: a top-level key).
|
|
67
|
+
*/
|
|
68
|
+
export interface Codec {
|
|
69
|
+
/** undefined when the file can be changed safely; otherwise why not (the file is then left alone and nothing changes). */
|
|
70
|
+
check?(text: string): string | undefined
|
|
71
|
+
/** The value at `key` (undefined when absent), compared with what was written to tell whether it is still brain's. */
|
|
72
|
+
get(text: string, key: string): unknown
|
|
73
|
+
/** The whole text with `key` set to `value`, or removed when `value` is undefined. Everything else stays as it was. */
|
|
74
|
+
set(text: string, key: string, value: unknown): string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* json plain JSON object; keys are paths with "/" (env/ANTHROPIC_BASE_URL). A file with comments is left alone.
|
|
79
|
+
* toml Codex's config.toml handling (top-level keys and the [model_providers.brain-gateway] table only).
|
|
80
|
+
* dotenv KEY=value lines.
|
|
81
|
+
* custom the edit's `codec` reads and writes it.
|
|
82
|
+
*/
|
|
83
|
+
export type Format = "json" | "toml" | "dotenv" | "custom"
|
|
84
|
+
|
|
85
|
+
/** One file a connector changes, and the values it sets there. */
|
|
86
|
+
export interface FileEdit {
|
|
87
|
+
path: string
|
|
88
|
+
format: Format
|
|
89
|
+
/** Required with format "custom". */
|
|
90
|
+
codec?: Codec
|
|
91
|
+
values: Record<string, unknown>
|
|
92
|
+
/** The file holds the key: it is made private (0600). */
|
|
93
|
+
holdsKey?: boolean
|
|
94
|
+
/** Keys among `values` whose value contains the key: never shown or recorded, and replaced when the person signs in again. */
|
|
95
|
+
secretKeys?: string[]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface Connector {
|
|
99
|
+
/** The id people pass to --tool, the same id brain uses for the tool's sessions where it has one. */
|
|
100
|
+
id: string
|
|
101
|
+
label: string
|
|
102
|
+
/** The main file it edits (shown in status), absolute. */
|
|
103
|
+
file(home: string, env: Env): string
|
|
104
|
+
/** Folders or files (relative to home) whose presence shows the tool has run here, besides the main file or its folder. */
|
|
105
|
+
probe: string[]
|
|
106
|
+
/** Every file it changes, with the values to set. Called often (status, dry runs, disconnect): no side effects. */
|
|
107
|
+
edits(home: string, s: GatewaySettings, env: Env): FileEdit[]
|
|
108
|
+
/** How this tool sends the key, for the free key check before connecting. */
|
|
109
|
+
authHeader: "x-api-key" | "authorization" | "x-goog-api-key"
|
|
110
|
+
/** What the person should know (shown on connect). */
|
|
111
|
+
notes: string[]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---- JSON paths --------------------------------------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
const split = (k: string) => k.split("/")
|
|
117
|
+
export function getAt(o: Json, key: string): unknown {
|
|
118
|
+
let cur: any = o
|
|
119
|
+
for (const p of split(key)) {
|
|
120
|
+
if (!cur || typeof cur !== "object" || !(p in cur)) return undefined
|
|
121
|
+
cur = cur[p]
|
|
122
|
+
}
|
|
123
|
+
return cur
|
|
124
|
+
}
|
|
125
|
+
export function setAt(o: Json, key: string, v: unknown) {
|
|
126
|
+
const ps = split(key)
|
|
127
|
+
let cur: any = o
|
|
128
|
+
for (const p of ps.slice(0, -1)) {
|
|
129
|
+
if (!cur[p] || typeof cur[p] !== "object" || Array.isArray(cur[p])) cur[p] = {}
|
|
130
|
+
cur = cur[p]
|
|
131
|
+
}
|
|
132
|
+
cur[ps[ps.length - 1]!] = v
|
|
133
|
+
}
|
|
134
|
+
export function deleteAt(o: Json, key: string) {
|
|
135
|
+
const ps = split(key)
|
|
136
|
+
const parents: any[] = [o]
|
|
137
|
+
let cur: any = o
|
|
138
|
+
for (const p of ps.slice(0, -1)) {
|
|
139
|
+
if (!cur || typeof cur !== "object") return
|
|
140
|
+
cur = cur[p]
|
|
141
|
+
parents.push(cur)
|
|
142
|
+
}
|
|
143
|
+
if (cur && typeof cur === "object") delete cur[ps[ps.length - 1]!]
|
|
144
|
+
// Drop objects this left empty (env: {} after the last key went), never the root.
|
|
145
|
+
for (let i = ps.length - 1; i >= 1; i--) {
|
|
146
|
+
const parent = parents[i - 1]
|
|
147
|
+
const child = parent?.[ps[i - 1]!]
|
|
148
|
+
if (child && typeof child === "object" && !Array.isArray(child) && !Object.keys(child).length) delete parent[ps[i - 1]!]
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---- YAML, top-level keys only ------------------------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A YAML scalar or flow collection. A plain string is written as it is when YAML reads it back as the same string
|
|
156
|
+
* (`local-model`, `https://host/v1`); anything else is JSON (valid YAML), which keeps "on", "no" and "1.0" strings.
|
|
157
|
+
*/
|
|
158
|
+
// Words and numbers YAML 1.1 readers (PyYAML: Aider, Hermes) take for booleans, null or numbers, though Bun's YAML 1.2
|
|
159
|
+
// reads them as strings: they are always quoted.
|
|
160
|
+
const YAML11_SPECIAL = /^(?:y|n|yes|no|on|off|true|false|null|~|[-+]?\.?(?:inf|nan)|[-+]?(?:0x[0-9a-f_]+|0o?[0-7_]+|0b[01_]+|[0-9][0-9_:]*(?:\.[0-9_]*)?(?:e[-+]?[0-9]+)?))$/i
|
|
161
|
+
|
|
162
|
+
function yamlValue(v: unknown): string {
|
|
163
|
+
if (typeof v === "string" && /^[A-Za-z0-9_./@-][A-Za-z0-9_./:@+=-]*$/.test(v) && !YAML11_SPECIAL.test(v)) {
|
|
164
|
+
try {
|
|
165
|
+
if ((Bun.YAML.parse(`k: ${v}`) as Json).k === v) return v
|
|
166
|
+
} catch {}
|
|
167
|
+
}
|
|
168
|
+
return JSON.stringify(v)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The ` # comment` at the end of a one-line YAML entry (a # after a space, outside quotes), with the spaces before it. */
|
|
172
|
+
function yamlComment(line: string): string {
|
|
173
|
+
let quote: string | undefined
|
|
174
|
+
for (let i = line.indexOf(":") + 1; i > 0 && i < line.length; i++) {
|
|
175
|
+
const c = line[i]!
|
|
176
|
+
if (quote) {
|
|
177
|
+
if (c === "\\" && quote === '"') i++
|
|
178
|
+
else if (c === quote) quote = undefined
|
|
179
|
+
} else if (c === '"' || c === "'") quote = c
|
|
180
|
+
else if (c === "#" && /[ \t]/.test(line[i - 1]!)) {
|
|
181
|
+
let j = i
|
|
182
|
+
while (j > 0 && /[ \t]/.test(line[j - 1]!)) j--
|
|
183
|
+
return line.slice(j).trimEnd()
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return ""
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Edits top-level `key: value` entries of a YAML file and leaves every other line (comments included) exactly as it was.
|
|
191
|
+
* A value is written on one line (strings quoted; objects and lists in flow style, which YAML reads like block style). An
|
|
192
|
+
* existing entry is replaced together with its indented block, if it had one; an entry that is not there is added at the
|
|
193
|
+
* end. Keys are top-level names only (no paths).
|
|
194
|
+
*/
|
|
195
|
+
export const yamlTopLevel: Codec = {
|
|
196
|
+
check(text) {
|
|
197
|
+
if (!text.trim()) return undefined
|
|
198
|
+
if (/^---\s*$/m.test(text.replace(/^---\s*\n/, ""))) return "it holds more than one YAML document; left as it is"
|
|
199
|
+
try {
|
|
200
|
+
const v = Bun.YAML.parse(text)
|
|
201
|
+
if (v !== null && (typeof v !== "object" || Array.isArray(v))) return "it is not a YAML mapping; left as it is"
|
|
202
|
+
} catch {
|
|
203
|
+
return "it is not valid YAML; left as it is"
|
|
204
|
+
}
|
|
205
|
+
return undefined
|
|
206
|
+
},
|
|
207
|
+
get(text, key) {
|
|
208
|
+
if (!text.trim()) return undefined
|
|
209
|
+
const v = Bun.YAML.parse(text) as Json | null
|
|
210
|
+
return v && typeof v === "object" ? v[key] : undefined
|
|
211
|
+
},
|
|
212
|
+
set(text, key, value) {
|
|
213
|
+
const lines = text ? text.replace(/\n$/, "").split("\n") : []
|
|
214
|
+
const esc = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
215
|
+
const head = new RegExp(`^(?:${esc}|"${esc}"|'${esc}')\\s*:(?:\\s|$)`)
|
|
216
|
+
const at = lines.findIndex((l) => head.test(l))
|
|
217
|
+
let end = at + 1
|
|
218
|
+
// The entry's block: the indented (or blank, or comment) lines that follow it, up to the next top-level line.
|
|
219
|
+
if (at >= 0) {
|
|
220
|
+
while (end < lines.length && (/^[ \t]/.test(lines[end]!) || lines[end]!.trim() === "" || /^[ \t]*-\s/.test(lines[end]!))) end++
|
|
221
|
+
// Blank lines and comments at the end of that run belong to whatever comes next, not to this entry.
|
|
222
|
+
while (end > at + 1 && (lines[end - 1]!.trim() === "" || /^\s*#/.test(lines[end - 1]!))) end--
|
|
223
|
+
}
|
|
224
|
+
// A one-line entry keeps its trailing comment, so a later restore gives the line back as it was.
|
|
225
|
+
const line = `${key}: ${yamlValue(value)}${at >= 0 && end === at + 1 ? yamlComment(lines[at]!) : ""}`
|
|
226
|
+
if (value === undefined) {
|
|
227
|
+
if (at >= 0) lines.splice(at, end - at)
|
|
228
|
+
} else if (at >= 0) lines.splice(at, end - at, line)
|
|
229
|
+
else lines.push(line)
|
|
230
|
+
return lines.length ? `${lines.join("\n")}\n` : ""
|
|
231
|
+
},
|
|
232
|
+
}
|