@100xprompt/brain 0.1.2 → 0.1.4

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 (40) hide show
  1. package/README.md +59 -2
  2. package/package.json +1 -1
  3. package/src/cli.ts +74 -9
  4. package/src/dashboard/cli.ts +25 -9
  5. package/src/dashboard/report.ts +84 -0
  6. package/src/dashboard/send.ts +20 -4
  7. package/src/dashboard/state.ts +67 -5
  8. package/src/fsutil.ts +19 -1
  9. package/src/gateway/common.ts +56 -4
  10. package/src/gateway/connect.ts +256 -100
  11. package/src/gateway/connectors/aider.ts +3 -5
  12. package/src/gateway/connectors/cline-sdk.ts +2 -5
  13. package/src/gateway/connectors/continue.ts +5 -6
  14. package/src/gateway/connectors/crush.ts +2 -2
  15. package/src/gateway/connectors/factory-droid.ts +6 -1
  16. package/src/gateway/connectors/goose.ts +4 -4
  17. package/src/gateway/connectors/hermes.ts +7 -3
  18. package/src/gateway/connectors/kimi-code.ts +2 -2
  19. package/src/gateway/connectors/letta-code.ts +4 -7
  20. package/src/gateway/connectors/openhands.ts +9 -4
  21. package/src/gateway/connectors/qwen-code.ts +16 -2
  22. package/src/gateway/connectors/vscode-copilot.ts +4 -6
  23. package/src/gateway/signin.ts +44 -12
  24. package/src/hooks.ts +20 -9
  25. package/src/lock.ts +269 -0
  26. package/src/memory/audit.ts +6 -4
  27. package/src/memory/cli.ts +6 -0
  28. package/src/memory/externalize.ts +17 -4
  29. package/src/memory/mcp-install.ts +63 -7
  30. package/src/memory/mcp-server.ts +4 -0
  31. package/src/memory/prepare.ts +12 -4
  32. package/src/memory/sync.ts +3 -0
  33. package/src/settings/account.ts +336 -0
  34. package/src/settings/apply.ts +306 -0
  35. package/src/settings/catalog.ts +476 -0
  36. package/src/settings/cli.ts +439 -0
  37. package/src/settings/env.ts +101 -0
  38. package/src/settings/pair.ts +83 -0
  39. package/src/settings/store.ts +216 -0
  40. package/src/settings/values.ts +80 -0
@@ -12,15 +12,66 @@ export const DEFAULT_ROUTE = "flash"
12
12
  export const DEFAULT_MODEL = "gemini-3.8-flash"
13
13
  export const EDIT_HEADER = "x-100x-context-edit"
14
14
 
15
+ /**
16
+ * `--model auto`: the gateway's auto-routing (the router's docs/routing-contract.md, "The /auto route"). Its route serves
17
+ * only this model id, and this id only on its route: per kind of work (everyday asks, code, reasoning) the gateway serves
18
+ * a cheaper model where the company's own test in the Savings app proved it no worse, and 100xprompt Pro everywhere else.
19
+ */
20
+ export const AUTO_ROUTE = "auto"
21
+ export const AUTO_MODEL = "100xprompt-auto"
22
+
15
23
  /**
16
24
  * What 100xprompt calls the models brain may connect a tool to, as the 100xprompt CLI shows them: tools are given the
17
25
  * gateway's model id (that is what the gateway serves), and everything brain prints uses the product name.
18
26
  */
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" }
27
+ export const MODEL_NAMES: Record<string, string> = {
28
+ "gemini-3.8-flash": "100xprompt Pro",
29
+ "gemini-3.7-flash": "100xprompt Flash",
30
+ "gemini-3.6-flash": "100xprompt Flash",
31
+ "gemini-3.5-flash": "100xprompt Flash",
32
+ [AUTO_MODEL]: "100xprompt Auto",
33
+ }
34
+ /** `--model pro` / `--model flash` / `--model auto`: the product names, turned into the gateway's model ids. */
35
+ export const MODEL_ALIASES: Record<string, string> = { pro: "gemini-3.8-flash", flash: "gemini-3.7-flash", auto: AUTO_MODEL }
22
36
  export const modelName = (id: string) => MODEL_NAMES[id] ?? id
23
37
  export const modelId = (given: string) => MODEL_ALIASES[given.trim().toLowerCase()] ?? given.trim()
38
+ /**
39
+ * Whether a connect uses auto-routing. No tool knows this id: a connector whose tool sizes its context window (or picks
40
+ * its capabilities) from the model id it is given tells it, for auto, what every model auto may serve takes (limitsFor).
41
+ */
42
+ export const isAuto = (s: Pick<GatewaySettings, "model">) => s.model === AUTO_MODEL
43
+ /** What the Gemini Flash models behind 100xprompt Pro and Flash take: 1,048,576 tokens in, 65,536 out. */
44
+ export const GEMINI_LIMITS = { context: 1_048_576, output: 65_536 } as const
45
+ /**
46
+ * What every model auto-routing may serve takes (the contract's table: the Gemini Flash models and DeepSeek V4 Flash): the
47
+ * smaller window, DeepSeek V4 Flash's 1,000,000 tokens, and Gemini's 65,536 out (DeepSeek allows more). Both take text and
48
+ * images; audio, video and PDF only Gemini, so a tool told what it may send gets images only.
49
+ */
50
+ export const AUTO_LIMITS = { context: 1_000_000, output: 65_536 } as const
51
+ /** The context window and output limit a connector tells its tool, for the model it connects. */
52
+ export const limitsFor = (s: Pick<GatewaySettings, "model">) => (isAuto(s) ? AUTO_LIMITS : GEMINI_LIMITS)
53
+
54
+ /**
55
+ * The route and model a connect uses, from the flags given (undefined: not given) and the settings stored by the last
56
+ * connect. The auto model and the auto route go together: `--model auto` picks the auto route (a different --route with
57
+ * it is refused), `--route auto` picks the auto model, and another model or route after an auto connect goes back to the
58
+ * default route (flash) or model (100xprompt Pro).
59
+ */
60
+ export function routeAndModel(given: { route?: string; model?: string }, stored: { route?: string; model?: string } = {}): { route: string; model: string } | { why: string } {
61
+ const model = given.model !== undefined ? modelId(given.model) : undefined
62
+ const route = given.route
63
+ if (model === AUTO_MODEL) {
64
+ if (route !== undefined && route !== AUTO_ROUTE) return { why: `--model auto uses the gateway's ${AUTO_ROUTE} route, so it cannot be combined with --route ${route}; leave out --route` }
65
+ return { route: AUTO_ROUTE, model }
66
+ }
67
+ if (route === AUTO_ROUTE) {
68
+ if (model !== undefined) return { why: `the ${AUTO_ROUTE} route chooses the model itself, so it cannot be combined with --model ${given.model!.trim()}; use --model auto` }
69
+ return { route: AUTO_ROUTE, model: AUTO_MODEL }
70
+ }
71
+ const wasAuto = stored.model === AUTO_MODEL || stored.route === AUTO_ROUTE
72
+ if (route === undefined && model === undefined) return wasAuto ? { route: AUTO_ROUTE, model: AUTO_MODEL } : { route: stored.route ?? DEFAULT_ROUTE, model: stored.model ?? DEFAULT_MODEL }
73
+ return { route: route ?? (wasAuto ? DEFAULT_ROUTE : (stored.route ?? DEFAULT_ROUTE)), model: model ?? (wasAuto ? DEFAULT_MODEL : (stored.model ?? DEFAULT_MODEL)) }
74
+ }
24
75
 
25
76
  export interface GatewaySettings {
26
77
  url: string
@@ -46,7 +97,8 @@ export function readKey(home: string): string {
46
97
  }
47
98
 
48
99
  /**
49
- * The gateway route's base address, e.g. https://proxy.100xprompt.com/flash. Under it the gateway serves, for every model:
100
+ * The gateway route's base address, e.g. https://proxy.100xprompt.com/flash (or /auto, which takes the same paths). Under
101
+ * it the gateway serves, for every model:
50
102
  * /v1/chat/completions OpenAI Chat Completions (Authorization: Bearer <key>)
51
103
  * /v1/responses OpenAI Responses (Authorization: Bearer <key>)
52
104
  * /v1/messages Anthropic Messages (x-api-key: <key>, or Authorization: Bearer)
@@ -15,9 +15,10 @@
15
15
  // stores the key that comes back in <home>/.brain/gateway.key (0600). For scripts and CI, --key-env VAR takes it from an
16
16
  // environment variable instead; --from-cli reuses the 100xprompt CLI's sign-in. It is never printed. Tools read it at run time where they can (Claude Code's apiKeyHelper,
17
17
  // OpenCode's {file:...}); Codex and Gemini CLI can only take it in their own config, which is then made private (0600).
18
- import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, writeFileSync } from "node:fs"
18
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmdirSync, rmSync, statSync } from "node:fs"
19
19
  import { basename, dirname, join, resolve } from "node:path"
20
20
  import {
21
+ AUTO_ROUTE,
21
22
  brainDir,
22
23
  type Codec,
23
24
  type Connector,
@@ -34,9 +35,9 @@ import {
34
35
  type Json,
35
36
  keyPath,
36
37
  MODEL_NAMES,
37
- modelId,
38
38
  modelName,
39
39
  readKey,
40
+ routeAndModel,
40
41
  routeUrl,
41
42
  setAt,
42
43
  shellQuote,
@@ -44,8 +45,14 @@ import {
44
45
  } from "./common.ts"
45
46
  import { EXTRA_CONNECTORS } from "./connectors/index.ts"
46
47
  import { askHidden, browserSignIn, type SignInDeps } from "./signin.ts"
48
+ import { writeAtomic } from "../fsutil.ts"
49
+ import { withBrainLockSync } from "../lock.ts"
50
+ import { toolGatewayPrefs } from "../settings/values.ts"
51
+ import { readMachineToken } from "../dashboard/state.ts"
47
52
 
48
53
  export {
54
+ AUTO_MODEL,
55
+ AUTO_ROUTE,
49
56
  DEFAULT_MODEL,
50
57
  DEFAULT_ROUTE,
51
58
  DEFAULT_URL,
@@ -76,7 +83,17 @@ interface State {
76
83
  keySource?: string
77
84
  /** Who signed in (from the sign-in page), shown by status. */
78
85
  email?: string
79
- tools: Record<string, { at: string; changes: Change[]; created?: string[]; createdDirs?: string[]; modes?: Record<string, number> }>
86
+ tools: Record<string, ToolRecord>
87
+ }
88
+
89
+ interface ToolRecord {
90
+ at: string
91
+ changes: Change[]
92
+ created?: string[]
93
+ createdDirs?: string[]
94
+ modes?: Record<string, number>
95
+ /** The settings this tool was connected with (older records have none: `settings` above is the last connect's). */
96
+ settings?: GatewaySettings
80
97
  }
81
98
 
82
99
  function loadState(home: string): State {
@@ -95,11 +112,7 @@ function saveState(home: string, s: State) {
95
112
 
96
113
  /** Atomic write with mode 0600 (the key and the record of what was changed). */
97
114
  function writePrivate(path: string, text: string) {
98
- const tmp = `${path}.brain-tmp`
99
- rmSync(tmp, { force: true })
100
- writeFileSync(tmp, text, { mode: 0o600 })
101
- chmodSync(tmp, 0o600)
102
- renameSync(tmp, path)
115
+ writeAtomic(path, text, 0o600)
103
116
  }
104
117
 
105
118
  /**
@@ -122,11 +135,7 @@ function writeToolFile(home: string, tool: string, path: string, text: string, m
122
135
  keep = statSync(real).mode & 0o777
123
136
  } catch {}
124
137
  }
125
- const tmp = `${real}.brain-tmp`
126
- rmSync(tmp, { force: true })
127
- writeFileSync(tmp, text, keep === undefined ? {} : { mode: keep })
128
- if (keep !== undefined) chmodSync(tmp, keep)
129
- renameSync(tmp, real)
138
+ writeAtomic(real, text, keep)
130
139
  }
131
140
 
132
141
  // ---- the key ----------------------------------------------------------------------------------------------------------
@@ -167,6 +176,10 @@ export function hasKey(home: string): boolean {
167
176
  * on the old one while status names the new. Returns the tools whose copy was replaced.
168
177
  */
169
178
  export function storeKey(home: string, key: string, source: string, email?: string, env: Env = process.env): string[] {
179
+ return withBrainLockSync(home, () => storeKeyLocked(home, key, source, email, env), { what: "storing the gateway key" })
180
+ }
181
+
182
+ function storeKeyLocked(home: string, key: string, source: string, email: string | undefined, env: Env): string[] {
170
183
  const old = readKey(home)
171
184
  mkdirSync(brainDir(home), { recursive: true, mode: 0o700 })
172
185
  writePrivate(keyPath(home), `${key}\n`)
@@ -251,6 +264,10 @@ export async function signIn(home: string, d: SignInDeps, env: Env = process.env
251
264
 
252
265
  /** Forgets the key. Tools still connected stop working until the next login, which is said. */
253
266
  export function logout(home: string, env: Record<string, string | undefined> = process.env): string[] {
267
+ return withBrainLockSync(home, () => logoutLocked(home, env), { what: "signing out" })
268
+ }
269
+
270
+ function logoutLocked(home: string, env: Record<string, string | undefined>): string[] {
254
271
  const had = hasKey(home)
255
272
  rmSync(keyPath(home), { force: true })
256
273
  const s = loadState(home)
@@ -575,6 +592,8 @@ export async function probeKey(home: string, c: Connector, s: GatewaySettings, f
575
592
  export interface Result {
576
593
  ok: boolean
577
594
  lines: string[]
595
+ /** connect: the settings the tool was connected with. */
596
+ settings?: GatewaySettings
578
597
  }
579
598
 
580
599
  export function installedHere(c: Connector, home: string, env: Record<string, string | undefined>): boolean {
@@ -583,103 +602,219 @@ export function installedHere(c: Connector, home: string, env: Record<string, st
583
602
  return existsSync(f) || (resolve(dirname(f)) !== resolve(home) && existsSync(dirname(f))) || c.probe.some((p) => existsSync(join(home, p)))
584
603
  }
585
604
 
605
+ /** Whether a tool is on the gateway now, read from its files (gateway.json alone can be wrong after a hand edit). */
606
+ export interface Connection {
607
+ /** brain's values are all in the tool's files, some of them, or none (or brain never connected it). */
608
+ state: "connected" | "partial" | "not-connected"
609
+ /** The settings it was connected with; undefined when an older brain connected it and they cannot be told. */
610
+ settings?: GatewaySettings
611
+ /** What no longer holds brain's value, when it is not all there. */
612
+ why?: string
613
+ /** The values brain set that the files no longer hold (JSON), to tell which part was changed. */
614
+ changed?: string[]
615
+ }
616
+
617
+ /** Every string in a value (and every key of an object in it). */
618
+ const stringsOf = (v: unknown): string[] =>
619
+ typeof v === "string" ? [v] : Array.isArray(v) ? v.flatMap(stringsOf) : v && typeof v === "object" ? Object.entries(v).flatMap(([k, x]) => [k, ...stringsOf(x)]) : []
620
+
621
+ /**
622
+ * The settings a tool was connected with. Brains before 0.1.4 kept only the last connect's settings (for every tool): those
623
+ * are this tool's only when it was the last one connected. Otherwise they are read back from what connect wrote in the
624
+ * tool's files: the gateway address and route, the model, and whether the context-editing header is there.
625
+ */
626
+ function settingsOf(st: State, id: string, values: unknown[]): GatewaySettings | undefined {
627
+ const rec = st.tools[id]
628
+ if (!rec) return undefined
629
+ if (rec.settings) return rec.settings
630
+ const newest = Object.entries(st.tools).sort((a, b) => (b[1].at ?? "").localeCompare(a[1].at ?? ""))[0]?.[0]
631
+ if (newest === id && st.settings) return st.settings
632
+ let url: string | undefined
633
+ let route: string | undefined
634
+ let model: string | undefined
635
+ let edit = false
636
+ for (const t of values.flatMap(stringsOf)) {
637
+ if (t.includes(EDIT_HEADER)) edit = true
638
+ model ??= Object.keys(MODEL_NAMES).find((m) => t === m || t.endsWith(`/${m}`))
639
+ const m = /^(https?:\/\/\S+?)\/([a-z0-9-]{1,40})(?:\/v1)?\/?$/.exec(t)
640
+ if (m && !url) {
641
+ url = m[1]
642
+ route = m[2]
643
+ }
644
+ }
645
+ return url && route && model ? { url, route, model, contextEdit: edit } : undefined
646
+ }
647
+
648
+ export function connectionState(home: string, toolId: string, env: Env = process.env): Connection {
649
+ const c = connectorFor(toolId)
650
+ if (!c) return { state: "not-connected" }
651
+ const st = loadState(home)
652
+ const rec = st.tools[c.id]
653
+ if (!rec) return { state: "not-connected" }
654
+ const codecs = codecsOf(c, home, st, env)
655
+ const docs = new Map<string, Doc | { error: string }>()
656
+ let held = 0
657
+ let first: string | undefined
658
+ const changed: string[] = []
659
+ // What connect wrote: its recorded values, and for entries holding the key, what the file has (the key itself is never kept).
660
+ const written: unknown[] = []
661
+ for (const x of rec.changes) {
662
+ if (!docs.has(x.file)) docs.set(x.file, load(x.format, x.file, codecs.get(x.file)))
663
+ const d = docs.get(x.file)!
664
+ const current = "error" in d ? undefined : getKey(d, x.key)
665
+ const secret = x.after === "(contains the key)"
666
+ const ours = !("error" in d) && (secret ? current !== undefined : same(current, x.after))
667
+ written.push(secret ? current : x.after)
668
+ if (ours) held++
669
+ else {
670
+ first ??= `${basename(x.file)}: ${x.key.replace(/\//g, ".")}`
671
+ changed.push(JSON.stringify(x.after))
672
+ }
673
+ }
674
+ const settings = settingsOf(st, c.id, written)
675
+ const withSettings = settings ? { settings } : {}
676
+ if (held === rec.changes.length) return { state: "connected", ...withSettings }
677
+ if (held === 0) return { state: "not-connected", ...withSettings, why: `brain's settings are no longer in ${c.label}'s files (changed by hand?)`, changed }
678
+ return { state: "partial", ...withSettings, why: `${first} changed since brain connected it`, changed }
679
+ }
680
+
586
681
  export async function connect(
587
682
  home: string,
588
683
  toolId: string,
589
- o: { settings?: Partial<GatewaySettings>; dryRun?: boolean; probe?: boolean; env?: Record<string, string | undefined>; fetchImpl?: typeof fetch } = {},
684
+ o: {
685
+ settings?: Partial<GatewaySettings>
686
+ dryRun?: boolean
687
+ probe?: boolean
688
+ env?: Record<string, string | undefined>
689
+ fetchImpl?: typeof fetch
690
+ /** Where a flag leaves the model or context editing open, use the ones saved for this tool with `brain settings`. */
691
+ usePrefs?: boolean
692
+ /** Called after the files are written, still under the brain lock, with the settings used (to record them). */
693
+ onWritten?: (s: GatewaySettings) => void
694
+ } = {},
590
695
  ): Promise<Result> {
591
696
  const env = o.env ?? process.env
592
697
  const c = connectorFor(toolId)
593
698
  if (!c) return { ok: false, lines: [`unknown tool "${toolId}"; \`brain gateway tools\` lists the ones that can be connected`] }
594
699
  if (!hasKey(home) && !o.dryRun) return { ok: false, lines: ["not signed in to the gateway yet: run `brain gateway login`"] }
595
- const st = loadState(home)
596
- const s: GatewaySettings = {
597
- url: o.settings?.url ?? st.settings?.url ?? DEFAULT_URL,
598
- route: o.settings?.route ?? st.settings?.route ?? DEFAULT_ROUTE,
599
- model: o.settings?.model !== undefined ? modelId(o.settings.model) : (st.settings?.model ?? DEFAULT_MODEL),
600
- contextEdit: o.settings?.contextEdit ?? st.settings?.contextEdit ?? true,
601
- }
602
- if (!/^https?:\/\/[^\s]+$/.test(s.url) || (/^http:/.test(s.url) && !/^http:\/\/(127\.0\.0\.1|localhost)(:|\/|$)/.test(s.url))) return { ok: false, lines: ["--url must be https:// (http:// only for a gateway on this machine)"] }
603
- if (!/^[a-z0-9-]{1,40}$/.test(s.route)) return { ok: false, lines: ["--route must be a gateway route name such as flash or pro"] }
604
- if (!/^[\w.:/-]{1,80}$/.test(s.model)) return { ok: false, lines: ["--model must be pro, flash or a plain model name"] }
700
+ // The settings to connect with, from the flags, then (with usePrefs) what is saved for this tool, then the last connect's.
701
+ // Worked out again inside the lock: another brain may have changed them while the key was checked.
702
+ const resolveSettings = (st: State): GatewaySettings | { why: string } => {
703
+ const given = o.settings ?? {}
704
+ const prefs = o.usePrefs ? toolGatewayPrefs(home, c.id) : {}
705
+ // With --route, a saved model is used only when neither is auto (the auto model and the auto route go together).
706
+ const model = given.model ?? (prefs.model !== undefined && (given.route === undefined || (given.route !== AUTO_ROUTE && prefs.model !== "auto")) ? prefs.model : undefined)
707
+ // --model auto goes with the auto route (and back to flash after it): common.ts routeAndModel.
708
+ const rm = routeAndModel({ route: given.route, model }, st.settings ?? {})
709
+ if ("why" in rm) return rm
710
+ const s: GatewaySettings = { url: given.url ?? st.settings?.url ?? DEFAULT_URL, route: rm.route, model: rm.model, contextEdit: given.contextEdit ?? prefs.contextEdit ?? st.settings?.contextEdit ?? true }
711
+ if (!/^https?:\/\/[^\s]+$/.test(s.url) || (/^http:/.test(s.url) && !/^http:\/\/(127\.0\.0\.1|localhost)(:|\/|$)/.test(s.url))) return { why: "--url must be https:// (http:// only for a gateway on this machine)" }
712
+ if (!/^[a-z0-9-]{1,40}$/.test(s.route)) return { why: "--route must be a gateway route name such as flash or pro" }
713
+ if (!/^[\w.:/-]{1,80}$/.test(s.model)) return { why: "--model must be pro, flash, auto or a plain model name" }
714
+ return s
715
+ }
716
+ const first = resolveSettings(loadState(home))
717
+ if ("why" in first) return { ok: false, lines: [first.why] }
718
+ const checked: GatewaySettings = first
605
719
  const main = c.file(home, env)
606
720
  if (!installedHere(c, home, env)) return { ok: false, lines: [`${c.label} has not been run on this machine (nothing at ${main}); run it once, then connect it`] }
607
721
  let limit: string | undefined
608
- if (o.probe !== false && !o.dryRun) {
609
- const p = await probeKey(home, c, s, o.fetchImpl)
722
+ const probed = o.probe !== false && !o.dryRun
723
+ if (probed) {
724
+ const p = await probeKey(home, c, checked, o.fetchImpl)
610
725
  limit = p.limit
611
- if (p.verdict === "rejected") return { ok: false, lines: [`the gateway at ${routeUrl(s)} refused the key the way ${c.label} sends it (${c.authHeader}); nothing was changed`] }
612
- if (p.verdict === "unreachable") return { ok: false, lines: [`could not reach ${routeUrl(s)}; nothing was changed (pass --no-probe to connect anyway)`] }
613
- }
614
- // Read and check every file first: nothing is written unless all of them can be changed.
615
- const edits = c.edits(home, s, env)
616
- const docs: Array<{ e: FileEdit; d: Doc }> = []
617
- for (const e of edits) {
618
- const d = load(e.format, e.path, e.codec)
619
- if ("error" in d) return { ok: false, lines: [`${e.path}: ${d.error}; nothing was changed`] }
620
- docs.push({ e, d })
621
- }
622
- const prior = st.tools[c.id]?.changes ?? []
623
- const changes: Change[] = []
624
- const shown: string[] = []
625
- try {
626
- for (const { e, d } of docs) {
627
- for (const [key, after] of Object.entries(e.values)) {
628
- const current = getKey(d, key)
629
- // A reconnect keeps the value from before the FIRST connect, so disconnect still restores the person's own.
630
- const was = prior.find((x) => x.file === e.path && x.key === key)
631
- const secret = isSecret(e, key)
632
- changes.push({ file: e.path, format: e.format, key, before: was ? was.before : current, after: secret ? "(contains the key)" : after })
633
- if (!same(current, after)) setKey(d, key, after)
634
- shown.push(` ${basename(e.path)}: ${key.replace(/\//g, ".")} = ${secret ? "(gateway settings and key)" : shownValue(after)}`)
635
- }
636
- // A key an earlier connect set that this one no longer wants (--no-context-edit after a connect with it): put it back now.
637
- for (const x of prior) {
638
- if (x.file !== e.path || x.key in e.values) continue
639
- const current = getKey(d, x.key)
640
- if (x.after === "(contains the key)" ? current !== undefined : same(current, x.after)) setKey(d, x.key, x.before)
726
+ if (p.verdict === "rejected") return { ok: false, lines: [`the gateway at ${routeUrl(checked)} refused the key the way ${c.label} sends it (${c.authHeader}); nothing was changed`] }
727
+ if (p.verdict === "unreachable") return { ok: false, lines: [`could not reach ${routeUrl(checked)}; nothing was changed (pass --no-probe to connect anyway)`] }
728
+ }
729
+ // Read, change and write under the brain lock (the network check above is done before taking it), from the state
730
+ // as it is now: another brain may have changed it while the key was checked.
731
+ const write = (): Result => {
732
+ const st = loadState(home)
733
+ // Signed out while the key was checked: nothing to put in the tool's files.
734
+ if (!o.dryRun && !hasKey(home)) return { ok: false, lines: ["not signed in to the gateway any more (signed out meanwhile); nothing was changed"] }
735
+ const fresh = resolveSettings(st)
736
+ if ("why" in fresh) return { ok: false, lines: [fresh.why] }
737
+ if (probed && routeUrl(fresh) !== routeUrl(checked)) return { ok: false, lines: [`the gateway address changed to ${routeUrl(fresh)} while the key was checked at ${routeUrl(checked)}; nothing was changed: run it again`] }
738
+ const s = fresh
739
+ // Read and check every file first: nothing is written unless all of them can be changed.
740
+ const edits = c.edits(home, s, env)
741
+ const docs: Array<{ e: FileEdit; d: Doc }> = []
742
+ for (const e of edits) {
743
+ const d = load(e.format, e.path, e.codec)
744
+ if ("error" in d) return { ok: false, lines: [`${e.path}: ${d.error}; nothing was changed`] }
745
+ docs.push({ e, d })
746
+ }
747
+ const prior = st.tools[c.id]?.changes ?? []
748
+ const changes: Change[] = []
749
+ const shown: string[] = []
750
+ try {
751
+ for (const { e, d } of docs) {
752
+ for (const [key, after] of Object.entries(e.values)) {
753
+ const current = getKey(d, key)
754
+ // A reconnect keeps the value from before the FIRST connect, so disconnect still restores the person's own.
755
+ const was = prior.find((x) => x.file === e.path && x.key === key)
756
+ const secret = isSecret(e, key)
757
+ changes.push({ file: e.path, format: e.format, key, before: was ? was.before : current, after: secret ? "(contains the key)" : after })
758
+ if (!same(current, after)) setKey(d, key, after)
759
+ shown.push(` ${basename(e.path)}: ${key.replace(/\//g, ".")} = ${secret ? "(gateway settings and key)" : shownValue(after)}`)
760
+ }
761
+ // A key an earlier connect set that this one no longer wants (--no-context-edit after a connect with it): put it back now.
762
+ for (const x of prior) {
763
+ if (x.file !== e.path || x.key in e.values) continue
764
+ const current = getKey(d, x.key)
765
+ if (x.after === "(contains the key)" ? current !== undefined : same(current, x.after)) setKey(d, x.key, x.before)
766
+ }
641
767
  }
768
+ } catch (err) {
769
+ // A file format the connector's codec could not change after all: nothing has been written yet.
770
+ return { ok: false, lines: [`could not change ${c.label}'s settings (${(err as Error).message.slice(0, 200)}); nothing was changed`] }
642
771
  }
643
- } catch (err) {
644
- // A file format the connector's codec could not change after all: nothing has been written yet.
645
- return { ok: false, lines: [`could not change ${c.label}'s settings (${(err as Error).message.slice(0, 200)}); nothing was changed`] }
646
- }
647
- const named = MODEL_NAMES[s.model] ? [`note: models are shown by their 100xprompt names; the files get the gateway's id for ${modelName(s.model)}`] : []
648
- if (o.dryRun) return { ok: true, lines: [`would change:`, ...shown, ...named, ...c.notes.map((n) => `note: ${n}`)] }
649
- const created = new Set(st.tools[c.id]?.created ?? [])
650
- for (const { e } of docs) if (!existsSync(e.path)) created.add(e.path)
651
- // Folders connect has to create (Goose's custom_providers/), so disconnect can take them away again when they are empty.
652
- const createdDirs = new Set(st.tools[c.id]?.createdDirs ?? [])
653
- for (const { e } of docs) for (let dir = dirname(e.path); !existsSync(dir) && dir.startsWith(`${resolve(home)}/`); dir = dirname(dir)) createdDirs.add(dir)
654
- // A file that is made private (it gets the key) goes back to the mode it had when disconnect gives it back.
655
- const modes: Record<string, number> = { ...(st.tools[c.id]?.modes ?? {}) }
656
- for (const { e } of docs) {
657
- if (!e.holdsKey || !existsSync(e.path) || modes[e.path] !== undefined) continue
772
+ const named = MODEL_NAMES[s.model] ? [`note: models are shown by their 100xprompt names; the files get the gateway's id for ${modelName(s.model)}`] : []
773
+ if (o.dryRun) return { ok: true, lines: [`would change:`, ...shown, ...named, ...c.notes.map((n) => `note: ${n}`)] }
774
+ const created = new Set(st.tools[c.id]?.created ?? [])
775
+ for (const { e } of docs) if (!existsSync(e.path)) created.add(e.path)
776
+ // Folders connect has to create (Goose's custom_providers/), so disconnect can take them away again when they are empty.
777
+ const createdDirs = new Set(st.tools[c.id]?.createdDirs ?? [])
778
+ for (const { e } of docs) for (let dir = dirname(e.path); !existsSync(dir) && dir.startsWith(`${resolve(home)}/`); dir = dirname(dir)) createdDirs.add(dir)
779
+ // A file that is made private (it gets the key) goes back to the mode it had when disconnect gives it back.
780
+ const modes: Record<string, number> = { ...(st.tools[c.id]?.modes ?? {}) }
781
+ for (const { e } of docs) {
782
+ if (!e.holdsKey || !existsSync(e.path) || modes[e.path] !== undefined) continue
783
+ try {
784
+ modes[e.path] = statSync(e.path).mode & 0o777
785
+ } catch {}
786
+ }
787
+ const first = !st.tools[c.id]
788
+ for (const { e, d } of docs) writeToolFile(home, c.id, e.path, render(d), e.holdsKey ? 0o600 : undefined, first)
789
+ st.settings = s
790
+ st.tools[c.id] = { at: new Date().toISOString(), changes, ...(created.size ? { created: [...created] } : {}), ...(createdDirs.size ? { createdDirs: [...createdDirs] } : {}), ...(Object.keys(modes).length ? { modes } : {}), settings: s }
791
+ saveState(home, st)
658
792
  try {
659
- modes[e.path] = statSync(e.path).mode & 0o777
793
+ o.onWritten?.(s)
660
794
  } catch {}
795
+ return {
796
+ ok: true,
797
+ settings: s,
798
+ lines: [
799
+ `connected ${c.label} to ${routeUrl(s)} (${modelName(s.model)}${s.contextEdit ? ", context editing on" : ""})`,
800
+ ...(limit ? [`warning: the gateway accepts the key, but this account cannot use it right now: ${limit}`, `warning: ${c.label} will get that error on every request until the limit resets (or brain gateway disconnect --tool ${c.id} puts it back).`] : []),
801
+ "changed:",
802
+ ...shown,
803
+ ...c.notes.map((n) => `note: ${n}`),
804
+ `undo: brain gateway disconnect --tool ${c.id}`,
805
+ ],
806
+ }
661
807
  }
662
- const first = !st.tools[c.id]
663
- for (const { e, d } of docs) writeToolFile(home, c.id, e.path, render(d), e.holdsKey ? 0o600 : undefined, first)
664
- st.settings = s
665
- st.tools[c.id] = { at: new Date().toISOString(), changes, ...(created.size ? { created: [...created] } : {}), ...(createdDirs.size ? { createdDirs: [...createdDirs] } : {}), ...(Object.keys(modes).length ? { modes } : {}) }
666
- saveState(home, st)
667
- return {
668
- ok: true,
669
- lines: [
670
- `connected ${c.label} to ${routeUrl(s)} (${modelName(s.model)}${s.contextEdit ? ", context editing on" : ""})`,
671
- ...(limit ? [`warning: the gateway accepts the key, but this account cannot use it right now: ${limit}`, `warning: ${c.label} will get that error on every request until the limit resets (or brain gateway disconnect --tool ${c.id} puts it back).`] : []),
672
- "changed:",
673
- ...shown,
674
- ...c.notes.map((n) => `note: ${n}`),
675
- `undo: brain gateway disconnect --tool ${c.id}`,
676
- ],
677
- }
808
+ return o.dryRun ? write() : withBrainLockSync(home, write, { what: `connecting ${c.label}` })
678
809
  }
679
810
 
680
811
  export function disconnect(home: string, toolId: string, o: { dryRun?: boolean; env?: Env } = {}): Result {
681
812
  const c = connectorFor(toolId)
682
813
  if (!c) return { ok: false, lines: [`unknown tool "${toolId}"`] }
814
+ return o.dryRun ? disconnectLocked(home, c, o) : withBrainLockSync(home, () => disconnectLocked(home, c, o), { what: `disconnecting ${c.label}` })
815
+ }
816
+
817
+ function disconnectLocked(home: string, c: Connector, o: { dryRun?: boolean; env?: Env }): Result {
683
818
  const st = loadState(home)
684
819
  const rec = st.tools[c.id]
685
820
  if (!rec) return { ok: true, lines: [`${c.label} is not connected by brain; nothing to undo`] }
@@ -744,11 +879,12 @@ export function status(home: string, env: Record<string, string | undefined> = p
744
879
 
745
880
  // ---- the command --------------------------------------------------------------------------------------------------------
746
881
 
882
+
747
883
  const USAGE = [
748
884
  "brain gateway login [--no-browser] sign in to 100xprompt in your browser, as `100xprompt auth login` does",
749
885
  "brain gateway login --key-env VAR | --from-cli take the key from VAR (scripts, CI) or from the 100xprompt CLI's sign-in",
750
886
  "brain gateway logout forget the key",
751
- "brain gateway connect --tool <id> [--model pro|flash] [--route flash] [--url https://...] [--no-context-edit | --context-edit] [--dry-run] [--no-probe]",
887
+ "brain gateway connect --tool <id> [--model pro|flash|auto] [--route flash] [--url https://...] [--no-context-edit | --context-edit] [--dry-run] [--no-probe]",
752
888
  "brain gateway disconnect --tool <id> [--dry-run]",
753
889
  "brain gateway status | tools",
754
890
  ]
@@ -782,6 +918,8 @@ export async function gatewayCommand(
782
918
  }
783
919
  const refreshedNote = (labels: string[]) => {
784
920
  if (labels.length) io.out(`the new key also replaced the old one in ${labels.join(" and ")}'s own config`)
921
+ // Signed in but not paired: the one step that puts this computer's settings in the dashboard.
922
+ if (!readMachineToken(home)) io.out("to see this computer's settings in your dashboard: brain pair")
785
923
  }
786
924
  // The browser sign-in, with a hidden paste prompt when there is a terminal to ask in.
787
925
  const browserLogin = async () => {
@@ -814,25 +952,43 @@ export async function gatewayCommand(
814
952
  io.out(`usage: ${USAGE[sub === "connect" ? 3 : 4]}`)
815
953
  return 2
816
954
  }
817
- if (sub === "disconnect") return print(disconnect(home, tool, { dryRun: has("dry-run"), env }))
955
+ // What connect and disconnect do is recorded as this computer's settings (`brain settings` shows who set what),
956
+ // inside the same hold of the brain lock as the change itself. Loaded here, before connecting: the settings modules
957
+ // import this one.
958
+ const { recordConnected } = await import("../settings/apply.ts")
959
+ if (sub === "disconnect") {
960
+ if (has("dry-run")) return print(disconnect(home, tool, { dryRun: true, env }))
961
+ const r = withBrainLockSync(
962
+ home,
963
+ () => {
964
+ const d = disconnect(home, tool, { env })
965
+ if (d.ok && connectorFor(tool)) recordConnected(home, connectorFor(tool)!.id, undefined)
966
+ return d
967
+ },
968
+ { what: "disconnecting a tool" },
969
+ )
970
+ return print(r)
971
+ }
818
972
  const settings = { ...(flag("url") ? { url: flag("url")! } : {}), ...(flag("route") ? { route: flag("route")! } : {}), ...(flag("model") ? { model: flag("model")! } : {}), ...(has("no-context-edit") ? { contextEdit: false } : has("context-edit") ? { contextEdit: true } : {}) }
819
973
  // Not signed in yet: sign in first, as the 100xprompt CLI would, then connect. Everything that can be checked here
820
974
  // (the tool, its files, the flags) is checked first, so nobody signs in for a connect that was bound to fail.
821
975
  if (!hasKey(home) && !has("dry-run")) {
822
- const pre = await connect(home, tool, { settings, dryRun: true, env })
976
+ const pre = await connect(home, tool, { settings, dryRun: true, env, usePrefs: true })
823
977
  if (!pre.ok) return print(pre)
824
978
  io.out("not signed in to the gateway yet; signing in first")
825
979
  if (!(await browserLogin())) return 1
826
980
  }
827
- return print(
828
- await connect(home, tool, {
829
- settings,
830
- dryRun: has("dry-run"),
831
- probe: !has("no-probe"),
832
- env,
833
- ...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}),
834
- }),
835
- )
981
+ const id = connectorFor(tool)?.id
982
+ const r = await connect(home, tool, {
983
+ settings,
984
+ dryRun: has("dry-run"),
985
+ probe: !has("no-probe"),
986
+ env,
987
+ usePrefs: true,
988
+ ...(id ? { onWritten: (s: GatewaySettings) => recordConnected(home, id, s) } : {}),
989
+ ...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}),
990
+ })
991
+ return print(r)
836
992
  }
837
993
  case "status":
838
994
  status(home, env).forEach((l) => io.out(l))
@@ -29,15 +29,12 @@
29
29
  // Docs: https://aider.chat/docs/config/adv-model-settings.html (extra_params, extra_headers, metadata file),
30
30
  // https://aider.chat/docs/llms/openai-compat.html (openai/<model> against an OpenAI-compatible API).
31
31
  import { join } from "node:path"
32
- import { type Codec, type Connector, EDIT_HEADER, type Json, readKey, routeUrl, yamlTopLevel } from "../common.ts"
32
+ import { type Codec, type Connector, EDIT_HEADER, type Json, limitsFor, readKey, routeUrl, yamlTopLevel } from "../common.ts"
33
33
 
34
34
  /** The one key of brain's own entry in the settings list and in the metadata object. */
35
35
  export const AIDER_ENTRY = "brain-gateway"
36
36
  /** Marks brain's entry in each file (a YAML comment, a JSON5 comment), so it is told apart from the person's own. */
37
37
  export const AIDER_MARK = "100xprompt gateway, added by brain gateway connect --tool aider"
38
- /** What Aider is told about the gateway's model: the Gemini Flash models' window and output limit. */
39
- const CONTEXT = 1_048_576
40
- const OUTPUT = 65_536
41
38
 
42
39
  const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
43
40
 
@@ -337,7 +334,8 @@ export const connectors: Connector[] = [
337
334
  path: join(home, ".aider.model.metadata.json"),
338
335
  format: "custom",
339
336
  codec: modelMetadataJson,
340
- values: { [AIDER_ENTRY]: { [model]: { max_input_tokens: CONTEXT, max_output_tokens: OUTPUT, max_tokens: OUTPUT, litellm_provider: "openai", mode: "chat" } } },
337
+ // The Gemini Flash models' window and output limit, or for 100xprompt Auto what every model it may serve takes.
338
+ values: { [AIDER_ENTRY]: { [model]: { max_input_tokens: limitsFor(s).context, max_output_tokens: limitsFor(s).output, max_tokens: limitsFor(s).output, litellm_provider: "openai", mode: "chat" } } },
341
339
  },
342
340
  {
343
341
  path: join(home, ".aider.conf.yml"),
@@ -26,12 +26,9 @@
26
26
  // not followed; CLINE_DIR and CLINE_DATA_DIR are, when they exist. The VS Code extension (id `cline`, see cline.ts) shares
27
27
  // ~/.cline/data but chooses its provider from its own globalState.json, so this connector does not change it.
28
28
  import { join } from "node:path"
29
- import { type Connector, dirFromEnv, EDIT_HEADER, type Env, modelName, readKey, routeUrl } from "../common.ts"
29
+ import { type Connector, dirFromEnv, EDIT_HEADER, type Env, limitsFor, modelName, readKey, routeUrl } from "../common.ts"
30
30
 
31
31
  const ID = "brain-gateway"
32
- /** Gemini 3.x Flash, which the gateway's models are: a 1M-token window, 64K out. */
33
- const CONTEXT_WINDOW = 1_048_576
34
- const MAX_OUTPUT = 65_536
35
32
  /** Cline requires an ISO date here; a fixed one keeps edits() free of side effects (Cline sets its own when it saves). */
36
33
  const UPDATED_AT = "2026-01-01T00:00:00.000Z"
37
34
 
@@ -56,7 +53,7 @@ export const connectors: Connector[] = [
56
53
  version: 1,
57
54
  [`providers/${ID}`]: {
58
55
  provider: { name: "100xprompt gateway", baseUrl, defaultModelId: s.model, protocol: "openai-chat", client: "openai-compatible", capabilities: ["tools", "streaming"] },
59
- models: { [s.model]: { id: s.model, name: modelName(s.model), contextWindow: CONTEXT_WINDOW, maxTokens: MAX_OUTPUT, capabilities: ["tools", "streaming", "images"] } },
56
+ models: { [s.model]: { id: s.model, name: modelName(s.model), contextWindow: limitsFor(s).context, maxTokens: limitsFor(s).output, capabilities: ["tools", "streaming", "images"] } },
60
57
  },
61
58
  },
62
59
  },