@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
@@ -30,7 +30,8 @@
30
30
  // apiKey: ${{ secrets.BRAIN_GATEWAY_KEY }}, roles chat/edit/apply/summarize,
31
31
  // capabilities tool_use and image_input (Continue only guesses tool support for provider openai from
32
32
  // gpt-/o- model names, toolSupport.ts ~277661, so without it Agent mode would be off),
33
- // contextLength 1048576 (an unknown model gets 32768, ~238017), and the context-edit header.
33
+ // contextLength 1048576 (an unknown model gets 32768, ~238017; for 100xprompt Auto the
34
+ // smallest window auto may serve, common.ts limitsFor), and the context-edit header.
34
35
  // First in the list makes it the default: the extension and the CLI take the first chat model unless
35
36
  // one was picked before (extension rectifySelectedModelsFromGlobalContext ~798703; CLI
36
37
  // services/ModelService.ts:53-120 and util/modelPersistence.ts, `cliSelectedModel`).
@@ -42,7 +43,7 @@
42
43
  // stay, and removing the entry gives back the exact bytes, including an empty `models: []` or a list that was not there.
43
44
  import { existsSync, readFileSync } from "node:fs"
44
45
  import { isAbsolute, join, resolve } from "node:path"
45
- import { type Codec, type Connector, EDIT_HEADER, type Env, type GatewaySettings, type Json, modelName, readKey, routeUrl, yamlTopLevel } from "../common.ts"
46
+ import { type Codec, type Connector, EDIT_HEADER, type Env, type GatewaySettings, type Json, limitsFor, modelName, readKey, routeUrl, yamlTopLevel } from "../common.ts"
46
47
 
47
48
  /** The name the key is kept under in the tool's .env, and that its config refers to. */
48
49
  export const SECRET = "BRAIN_GATEWAY_KEY"
@@ -50,8 +51,6 @@ export const SECRET = "BRAIN_GATEWAY_KEY"
50
51
  const OWN_CONFIG = "100xprompt gateway"
51
52
  /** The codec key of brain's entry in the models list (the entry itself is found by its apiKey, so a model switch keeps it). */
52
53
  export const ENTRY_KEY = "models/100xprompt gateway"
53
- /** Gemini models' context window; Continue assumes 32768 for a model it does not know. */
54
- const CONTEXT = 1048576
55
54
 
56
55
  const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
57
56
  const isPlainObject = (v: unknown): v is Json => !!v && typeof v === "object" && !Array.isArray(v)
@@ -521,7 +520,7 @@ const headers = (s: GatewaySettings) => (s.contextEdit ? { requestOptions: { hea
521
520
 
522
521
  /** The models entry in config.json form (older Continue, PearAI): `title`, the key by env name. */
523
522
  export function jsonEntry(s: GatewaySettings, extra: Json = {}): Json {
524
- return { title: modelName(s.model), provider: "openai", model: s.model, apiBase: `${routeUrl(s)}/v1`, apiKey: SECRET, contextLength: CONTEXT, ...extra, ...headers(s) }
523
+ return { title: modelName(s.model), provider: "openai", model: s.model, apiBase: `${routeUrl(s)}/v1`, apiKey: SECRET, contextLength: limitsFor(s).context, ...extra, ...headers(s) }
525
524
  }
526
525
 
527
526
  /** The .env edit both Continue and PearAI read their secret from. */
@@ -557,7 +556,7 @@ export const connectors: Connector[] = [
557
556
  apiKey: `\${{ secrets.${SECRET} }}`,
558
557
  roles: ["chat", "edit", "apply", "summarize"],
559
558
  capabilities: ["tool_use", "image_input"],
560
- defaultCompletionOptions: { contextLength: CONTEXT },
559
+ defaultCompletionOptions: { contextLength: limitsFor(s).context },
561
560
  ...headers(s),
562
561
  }
563
562
  return [
@@ -30,7 +30,7 @@
30
30
  // A model list is given, so Crush does not ask the gateway for /v1/models (403 there).
31
31
  import { existsSync } from "node:fs"
32
32
  import { isAbsolute, join, resolve } from "node:path"
33
- import { type Codec, type Connector, EDIT_HEADER, type Env, getAt, type Json, keyPath, modelName, routeUrl, shellQuote } from "../common.ts"
33
+ import { type Codec, type Connector, EDIT_HEADER, type Env, getAt, type Json, keyPath, limitsFor, modelName, routeUrl, shellQuote } from "../common.ts"
34
34
 
35
35
  /** Crush's data file, resolved as Crush does; the env overrides describe the person's real home, so they count only there. */
36
36
  export function crushDataFile(home: string, env: Env): string {
@@ -297,7 +297,7 @@ export const connectors: Connector[] = [
297
297
  base_url: `${routeUrl(s)}/v1`,
298
298
  api_key: `$(cat ${shellQuote(keyPath(home))})`,
299
299
  ...(s.contextEdit ? { extra_headers: { [EDIT_HEADER]: "on" } } : {}),
300
- models: [{ id: s.model, name: modelName(s.model), context_window: 1048576, default_max_tokens: 65536, capabilities: { vision: true } }],
300
+ models: [{ id: s.model, name: modelName(s.model), context_window: limitsFor(s).context, default_max_tokens: limitsFor(s).output, capabilities: { vision: true } }],
301
301
  },
302
302
  "models/large": pick,
303
303
  "models/small": pick,
@@ -14,6 +14,10 @@
14
14
  // stdout."), run with shell:true, cached for 5 minutes (apiKeyHelperTtlMs, FACTORY_API_KEY_HELPER_TTL_MS). So the key
15
15
  // is read from the brain's key file at run time, never copied, and a new sign-in reaches Droid without a reconnect.
16
16
  // - `extraHeaders` is sent with every request (case-insensitively unique names), so the context-edit header can go.
17
+ // - Context window and output limit: Droid infers a custom model's from its model id (the built-in model whose
18
+ // matchPatterns match it: gemini-3.8-flash gets Gemini 3.8 Flash's 1M in and 64K out), and an entry's maxContextLimit
19
+ // and maxOutputTokens take precedence (k4e and ev in the binary). 100xprompt Auto's id matches nothing, so for auto they
20
+ // are given: what every model auto may serve takes (common.ts limitsFor). Images stay on (noImageSupport unset).
17
21
  // - A model's id is `custom:<displayName with spaces as ->-<n>` unless the entry names its own `id`; this connector gives
18
22
  // its entry the id `custom:brain-gateway`, so the default stays put whatever else is in the list.
19
23
  // - The default model is `sessionDefaultSettings.model` (older top-level `model` is folded into it on read and only
@@ -27,7 +31,7 @@
27
31
  // - Checked for real (scripts/gateway-real/factory-droid.ts): `droid exec` with no -m, no Factory login, sends its
28
32
  // request to <route>/v1/messages with x-api-key from the helper, the context-edit header and the gateway's model.
29
33
  import { join } from "node:path"
30
- import { type Codec, type Connector, dirFromEnv, EDIT_HEADER, type Env, type Json, keyPath, modelName, routeUrl, shellQuote } from "../common.ts"
34
+ import { type Codec, type Connector, dirFromEnv, EDIT_HEADER, type Env, isAuto, type Json, keyPath, limitsFor, modelName, routeUrl, shellQuote } from "../common.ts"
31
35
 
32
36
  /** The id of the connector's entry in customModels, and the default model value that selects it. */
33
37
  export const DROID_MODEL_ID = "custom:brain-gateway"
@@ -295,6 +299,7 @@ export const connectors: Connector[] = [
295
299
  provider: "anthropic",
296
300
  apiKeyHelper: `cat ${shellQuote(keyPath(home))}`,
297
301
  ...(s.contextEdit ? { extraHeaders: { [EDIT_HEADER]: "on" } } : {}),
302
+ ...(isAuto(s) ? { maxContextLimit: limitsFor(s).context, maxOutputTokens: limitsFor(s).output } : {}),
298
303
  },
299
304
  "sessionDefaultSettings/model": DROID_MODEL_ID,
300
305
  },
@@ -22,12 +22,10 @@
22
22
  // The binary's strings list the same field names (`strings goose | grep api_key_env`), and the real-tool check
23
23
  // (scripts/gateway-real/goose.ts) runs this exact setup.
24
24
  import { isAbsolute, join, resolve } from "node:path"
25
- import { type Connector, EDIT_HEADER, type Env, keyPath, modelName, routeUrl, yamlTopLevel } from "../common.ts"
25
+ import { type Connector, EDIT_HEADER, type Env, keyPath, limitsFor, modelName, routeUrl, yamlTopLevel } from "../common.ts"
26
26
 
27
27
  /** The provider id (the custom provider's file name and `name`, and GOOSE_PROVIDER), as Codex and OpenCode name it. */
28
28
  export const GOOSE_PROVIDER = "brain-gateway"
29
- /** The context window Goose is told (it compacts the conversation near it): the Gemini Flash models' 1,048,576 tokens. */
30
- const CONTEXT_LIMIT = 1_048_576
31
29
 
32
30
  /** Goose's config folder, found the way Goose finds it. XDG_CONFIG_HOME describes the real home, so it counts only there. */
33
31
  export function gooseDir(home: string, env: Env): string {
@@ -56,7 +54,9 @@ export const connectors: Connector[] = [
56
54
  display_name: "100xprompt gateway",
57
55
  description: `${modelName(s.model)} through the 100xprompt gateway (set up by brain gateway connect --tool goose)`,
58
56
  base_url: `${routeUrl(s)}/v1`,
59
- models: [{ name: s.model, context_limit: CONTEXT_LIMIT }],
57
+ // The window Goose is told (it compacts the conversation near it): the Gemini Flash models' 1,048,576 tokens, or
58
+ // for 100xprompt Auto the smallest window it may serve (common.ts limitsFor).
59
+ models: [{ name: s.model, context_limit: limitsFor(s).context }],
60
60
  ...(s.contextEdit ? { headers: { [EDIT_HEADER]: "on" } } : {}),
61
61
  supports_streaming: true,
62
62
  requires_auth: true,
@@ -19,7 +19,10 @@
19
19
  // - `extra_headers` on the provider entry is sent with every request on the OpenAI wire (runtime_provider.py
20
20
  // _lift_extra_headers), so the context-edit header can go.
21
21
  // - The gateway's /v1/models answers 403; Hermes then takes the context window from its catalog ("gemini" → 1M,
22
- // agent/model_metadata.py), so nothing else needs setting.
22
+ // agent/model_metadata.py), so nothing else needs setting for a Gemini id. 100xprompt Auto's id is in no catalog (Hermes
23
+ // would assume 256,000: DEFAULT_FALLBACK_CONTEXT), so for auto the entry also gets `models: {<id>: {context_length}}`,
24
+ // the per-model override Hermes checks before any catalog (hermes_cli/config.py get_custom_provider_context_length,
25
+ // matched by the entry's base_url): what every model auto may serve takes (common.ts limitsFor).
23
26
  // - $HERMES_HOME replaces ~/.hermes when set (a Hermes profile is its own HERMES_HOME).
24
27
  // - Checked for real (scripts/gateway-real/hermes.ts): `hermes chat -q` sends its request to <route>/v1/chat/completions
25
28
  // with Authorization: Bearer <key from .env>, the context-edit header and the gateway's model, even with the person's
@@ -27,7 +30,7 @@
27
30
  // config.yaml is YAML with comments; the codec below edits `<section>/<entry>` lines in place and leaves every other line
28
31
  // as it was.
29
32
  import { join } from "node:path"
30
- import { type Codec, type Connector, dirFromEnv, EDIT_HEADER, type Env, type Json, readKey, routeUrl, yamlTopLevel } from "../common.ts"
33
+ import { type Codec, type Connector, dirFromEnv, EDIT_HEADER, type Env, isAuto, type Json, limitsFor, readKey, routeUrl, yamlTopLevel } from "../common.ts"
31
34
 
32
35
  export const HERMES_PROVIDER = "brain-gateway"
33
36
  export const HERMES_KEY_VAR = "BRAIN_GATEWAY_API_KEY"
@@ -102,7 +105,7 @@ function section(lines: string[], name: string): Section | undefined {
102
105
  const at = lines.findIndex((l) => keyLine("", name).test(l))
103
106
  if (at < 0) return undefined
104
107
  const inline = inlineValue(lines[at]!)
105
- if (inline && !/^(?:\{\s*\}|null|~)$/.test(inline)) throw new Error(`${name}: is written on one line (${inline.slice(0, 40)}), not as a section`)
108
+ if (inline && !/^(?:\{\s*\}|null|~)$/.test(inline)) throw new Error(`${name}: is written on one line, not as a section`)
106
109
  const end = blockEnd(lines, at)
107
110
  const first = lines.slice(at + 1, end).find((l) => !blank(l))
108
111
  const indent = first ? indentOf(first) : ""
@@ -209,6 +212,7 @@ export const connectors: Connector[] = [
209
212
  api_mode: "chat_completions",
210
213
  default_model: s.model,
211
214
  ...(s.contextEdit ? { extra_headers: { [EDIT_HEADER]: "on" } } : {}),
215
+ ...(isAuto(s) ? { models: { [s.model]: { context_length: limitsFor(s).context } } } : {}),
212
216
  },
213
217
  "model/provider": `custom:${HERMES_PROVIDER}`,
214
218
  "model/default": s.model,
@@ -26,7 +26,7 @@
26
26
  // back the exact bytes. Before any change the codec makes a trial edit and refuses a file where that would not land
27
27
  // exactly on brain's entries (an inline `providers = {...}`, dotted keys, sub-tables of brain's tables, ...).
28
28
  import { join } from "node:path"
29
- import { type Codec, type Connector, dirFromEnv, EDIT_HEADER, type Json, modelName, readKey, routeUrl } from "../common.ts"
29
+ import { type Codec, type Connector, dirFromEnv, EDIT_HEADER, type Json, limitsFor, modelName, readKey, routeUrl } from "../common.ts"
30
30
 
31
31
  export const kimiConfig = (home: string, env: Record<string, string | undefined>) => join(dirFromEnv(env, "KIMI_CODE_HOME", join(home, ".kimi-code")), "config.toml")
32
32
 
@@ -227,7 +227,7 @@ export const connectors: Connector[] = [
227
227
  [`models/${PROVIDER}`]: {
228
228
  provider: PROVIDER,
229
229
  model: s.model,
230
- max_context_size: 1048576,
230
+ max_context_size: limitsFor(s).context,
231
231
  capabilities: ["image_in", "tool_use"],
232
232
  display_name: modelName(s.model),
233
233
  },
@@ -33,7 +33,7 @@
33
33
  // (src/backend/dev/pi-request-headers.ts).
34
34
  import { existsSync } from "node:fs"
35
35
  import { isAbsolute, join } from "node:path"
36
- import { type Codec, type Connector, EDIT_HEADER, type Env, type GatewaySettings, type Json, keyPath, modelName, routeUrl } from "../common.ts"
36
+ import { type Codec, type Connector, EDIT_HEADER, type Env, type GatewaySettings, type Json, keyPath, limitsFor, modelName, routeUrl } from "../common.ts"
37
37
 
38
38
  export const MOD_FILE = "brain-gateway.mjs"
39
39
  const MARK = "// Written by `brain gateway connect --tool letta-code`"
@@ -136,18 +136,15 @@ export const lettaMod: Codec = {
136
136
  },
137
137
  }
138
138
 
139
- /** What 100xprompt's models take (Gemini 3.x Flash: 1M tokens in, 64k out). */
140
- const CONTEXT_WINDOW = 1_048_576
141
- const MAX_TOKENS = 65_536
142
-
143
139
  const modValues = (home: string, s: GatewaySettings): Record<string, unknown> => ({
144
140
  provider: "brain-gateway",
145
141
  name: "100xprompt gateway",
146
142
  baseUrl: `${routeUrl(s)}/v1`,
147
143
  model: s.model,
148
144
  modelName: modelName(s.model),
149
- contextWindow: CONTEXT_WINDOW,
150
- maxTokens: MAX_TOKENS,
145
+ // What the model takes (Gemini 3.x Flash: 1M tokens in, 64k out; for 100xprompt Auto, what every model it may serve takes).
146
+ contextWindow: limitsFor(s).context,
147
+ maxTokens: limitsFor(s).output,
151
148
  keyFile: keyPath(home),
152
149
  ...(s.contextEdit ? { headers: { [EDIT_HEADER]: "on" } } : {}),
153
150
  })
@@ -25,9 +25,14 @@
25
25
  // (routing metadata for the previous endpoint; chat_options.py:83-85 sends it as extra_body). All of them come back
26
26
  // on disconnect.
27
27
  // - OpenHands has no named providers or model list (no profile is used by the CLI), so the LLM entry itself is switched.
28
+ // - With max_input_tokens and max_output_tokens null, the SDK looks the model up in LiteLLM's catalog, under the id and
29
+ // under the part after the last "/" (sdk/llm/utils/model_info.py get_litellm_model_info): gemini-3.8-flash is there
30
+ // (1,048,576 in, 65,536 out, images). 100xprompt Auto's id is not, so for auto both are set to what every model auto may
31
+ // serve takes (common.ts limitsFor). Images: OpenHands decides them from that same catalog only (llm.py
32
+ // _supports_vision; there is no setting to turn them on), so with auto it sends no images.
28
33
  import { readFileSync } from "node:fs"
29
34
  import { join } from "node:path"
30
- import { type Codec, type Connector, dirFromEnv, EDIT_HEADER, type Env, type GatewaySettings, type Json, readKey, routeUrl } from "../common.ts"
35
+ import { type Codec, type Connector, dirFromEnv, EDIT_HEADER, type Env, type GatewaySettings, isAuto, type Json, limitsFor, readKey, routeUrl } from "../common.ts"
31
36
 
32
37
  // ---- JSON edited in place ------------------------------------------------------------------------------------------------
33
38
 
@@ -261,8 +266,8 @@ const llmValues = (prefix: string, home: string, s: GatewaySettings): Record<str
261
266
  [`${prefix}/api_key`]: readKey(home),
262
267
  [`${prefix}/extra_headers`]: s.contextEdit ? { [EDIT_HEADER]: "on" } : null,
263
268
  [`${prefix}/model_canonical_name`]: null,
264
- [`${prefix}/max_input_tokens`]: null,
265
- [`${prefix}/max_output_tokens`]: null,
269
+ [`${prefix}/max_input_tokens`]: isAuto(s) ? limitsFor(s).context : null,
270
+ [`${prefix}/max_output_tokens`]: isAuto(s) ? limitsFor(s).output : null,
266
271
  [`${prefix}/litellm_extra_body`]: {},
267
272
  })
268
273
 
@@ -289,7 +294,7 @@ export const connectors: Connector[] = [
289
294
  },
290
295
  notes: [
291
296
  "OpenHands keeps the key in its agent_settings.json (it has no key-file option), so the file is now readable by you only (0600); a new sign-in replaces the key there.",
292
- "Its agent and memory-condenser LLM now use the gateway's model over OpenAI Chat Completions; the model limits OpenHands had stored for your previous model are cleared so it looks them up again.",
297
+ "Its agent and memory-condenser LLM now use the gateway's model over OpenAI Chat Completions; the model limits OpenHands had stored for your previous model are cleared so it looks them up again (with 100xprompt Auto they are set instead, and OpenHands sends no images: it decides that from the model's name alone).",
293
298
  "Run without --override-with-envs (or without LLM_API_KEY, LLM_BASE_URL and LLM_MODEL set): those override this file. Changing the LLM in OpenHands' settings screen replaces these values, and disconnect then leaves your choice alone.",
294
299
  ],
295
300
  },
@@ -20,13 +20,18 @@
20
20
  // under a name of its own (BRAIN_GATEWAY_KEY, so no OPENAI_API_KEY of the person's is touched), and that file is 0600.
21
21
  // - Custom headers: generationConfig.customHeaders is sent with every request (chunks/chunk-ESPAP3UW.js buildHeaders),
22
22
  // so `x-100x-context-edit: on` goes there.
23
+ // - Context window and input kinds: Qwen Code takes them from the model id (chunks/chunk-5TODR7F6.js tokenLimit and
24
+ // defaultModalities: gemini-* gets 1,000,000 tokens and every input kind; an id it does not know gets 200,000 and text
25
+ // only), unless the provider entry's generationConfig gives contextWindowSize and modalities (chunks/chunk-KUBWE5YV.js
26
+ // ModelRegistry, MODEL_GENERATION_CONFIG_FIELDS). 100xprompt Auto's id is one it cannot know, so for auto they are given:
27
+ // what every model auto may serve takes (common.ts limitsFor), images and text.
23
28
  // What connect writes: settings.json gets a "brain-gateway" provider ("100xprompt gateway", model shown as its 100xprompt
24
29
  // name), its providerProtocol mapping, and the selection (auth type, model.name, model.baseUrl); ~/.qwen/.env gets the
25
30
  // key. The person's own providers, models and comments are left as they are; disconnect puts every key back.
26
31
  // Needs Qwen Code 0.23 or later (custom provider ids through providerProtocol); older ones skip the provider.
27
32
  import { existsSync } from "node:fs"
28
33
  import { join } from "node:path"
29
- import { type Codec, type Connector, EDIT_HEADER, type Env, type GatewaySettings, type Json, modelName, readKey, routeUrl } from "../common.ts"
34
+ import { type Codec, type Connector, EDIT_HEADER, type Env, type GatewaySettings, isAuto, type Json, limitsFor, modelName, readKey, routeUrl } from "../common.ts"
30
35
 
31
36
  // ---- JSON with comments, edited in place ----------------------------------------------------------------------------
32
37
  //
@@ -372,6 +377,15 @@ export function qwenDir(home: string, env: Env): string {
372
377
 
373
378
  const base = (s: GatewaySettings) => `${routeUrl(s)}/v1`
374
379
 
380
+ /** The provider entry's generationConfig: the context-edit header, and for auto the window and input kinds (see above). */
381
+ function generationConfig(s: GatewaySettings): Json | undefined {
382
+ const g: Json = {
383
+ ...(s.contextEdit ? { customHeaders: { [EDIT_HEADER]: "on" } } : {}),
384
+ ...(isAuto(s) ? { contextWindowSize: limitsFor(s).context, modalities: { image: true } } : {}),
385
+ }
386
+ return Object.keys(g).length ? g : undefined
387
+ }
388
+
375
389
  export const connectors: Connector[] = [
376
390
  {
377
391
  id: "qwen-code",
@@ -392,7 +406,7 @@ export const connectors: Connector[] = [
392
406
  description: "100xprompt gateway",
393
407
  baseUrl: base(s),
394
408
  envKey: QWEN_KEY_VAR,
395
- ...(s.contextEdit ? { generationConfig: { customHeaders: { [EDIT_HEADER]: "on" } } } : {}),
409
+ ...(generationConfig(s) ? { generationConfig: generationConfig(s) } : {}),
396
410
  },
397
411
  ],
398
412
  [`providerProtocol/${PROVIDER}`]: "openai",
@@ -27,13 +27,10 @@
27
27
  // Only the default VS Code profile and VS Code Stable ("Code") are handled; Insiders, VSCodium and other profiles keep
28
28
  // their own User folders.
29
29
  import { join, resolve } from "node:path"
30
- import { type Codec, type Connector, EDIT_HEADER, type Env, type Json, modelName, readKey, routeUrl } from "../common.ts"
30
+ import { type Codec, type Connector, EDIT_HEADER, type Env, type Json, limitsFor, modelName, readKey, routeUrl } from "../common.ts"
31
31
 
32
32
  const GROUP = "100xprompt gateway"
33
33
  const VENDOR = "customendpoint"
34
- /** Gemini 3.x Flash, which the gateway's models are: a 1M-token window, 64K out. */
35
- const MAX_OUTPUT = 65_536
36
- const MAX_INPUT = 1_048_576 - MAX_OUTPUT
37
34
 
38
35
  /** VS Code's user folder: ~/Library/Application Support/Code/User on macOS, $XDG_CONFIG_HOME/Code/User or ~/.config/Code/User elsewhere. */
39
36
  export function vscodeUserDir(home: string, env: Env, platform: string = process.platform): string {
@@ -268,8 +265,9 @@ export const connectors: Connector[] = [
268
265
  apiType: "chat-completions",
269
266
  toolCalling: true,
270
267
  vision: true,
271
- maxInputTokens: MAX_INPUT,
272
- maxOutputTokens: MAX_OUTPUT,
268
+ // The window less the output (Gemini 3.x Flash: 1M in, 64K out; 100xprompt Auto: what all it may serve take).
269
+ maxInputTokens: limitsFor(s).context - limitsFor(s).output,
270
+ maxOutputTokens: limitsFor(s).output,
273
271
  requestHeaders: { Authorization: `Bearer ${readKey(home)}`, ...(s.contextEdit ? { [EDIT_HEADER]: "on" } : {}) },
274
272
  },
275
273
  ],
@@ -118,8 +118,38 @@ export interface SignInDeps {
118
118
 
119
119
  export type SignIn = { ok: true; key: string; email?: string; via: "browser" | "code" } | { ok: false; why: string }
120
120
 
121
+ type Took = { ok: true; key: string; email?: string } | { ok: false; why: string }
122
+
123
+ /**
124
+ * Which browser round trip to run: the sign-in (`brain gateway login`: /auth/cli, a gateway key comes back) or pairing
125
+ * (`brain pair`: /auth/brain, the computer's own token comes back). Everything else (the loopback server, the state,
126
+ * the paste fallback) is the same.
127
+ */
128
+ export interface BrowserFlow {
129
+ /** The command the person ran, named in the refusal page. */
130
+ command: string
131
+ /** The site's page, and what else it is told. */
132
+ path: string
133
+ params?: Record<string, string>
134
+ /** What a callback's token or a pasted text must be to be kept. */
135
+ accept(token: string): Took
136
+ /** Words: what the browser is opened for, the page shown when it worked, and what to paste. */
137
+ opening: string
138
+ done: (email?: string) => { title: string; body: string }
139
+ paste: string
140
+ }
141
+
142
+ export const SIGN_IN_FLOW: BrowserFlow = {
143
+ command: "brain gateway login",
144
+ path: "/auth/cli",
145
+ accept: (token) => callbackKey(token),
146
+ opening: "sign in to 100xprompt",
147
+ done: (email) => ({ title: "Signed in", body: `brain is now signed in to the 100xprompt gateway${email ? ` as ${email}` : ""}. You can close this tab and go back to your terminal.` }),
148
+ paste: "Paste the key from the page's Copy key button, or the address the browser could not open",
149
+ }
150
+
121
151
  /** Signs in through the browser (or a pasted code). Does not store anything: the caller does. */
122
- export async function browserSignIn(d: SignInDeps): Promise<SignIn> {
152
+ export async function browserSignIn(d: SignInDeps, flow: BrowserFlow = SIGN_IN_FLOW): Promise<SignIn> {
123
153
  const base = signInBase(d.authUrl)
124
154
  if (!base) return { ok: false, why: "HUNDREDXPROMPT_AUTH_URL must be an http:// or https:// address" }
125
155
  const { challenge } = await pkce()
@@ -145,35 +175,37 @@ export async function browserSignIn(d: SignInDeps): Promise<SignIn> {
145
175
  const got = url.searchParams.get("state") ?? ""
146
176
  const error = url.searchParams.get("error")
147
177
  // Only the page this command opened knows the state: anything else is refused and the wait goes on.
148
- if (!/^[0-9a-f]{64}$/.test(got) || !sameState(got, state)) return page("Sign-in refused", "This link was not started by this brain gateway login. Start again from your terminal.", 400)
178
+ if (!/^[0-9a-f]{64}$/.test(got) || !sameState(got, state)) return page("Sign-in refused", `This link was not started by this ${flow.command}. Start again from your terminal.`, 400)
149
179
  if (error) {
150
180
  settle({ ok: false, why: `the sign-in page said: ${error.slice(0, 200)}` })
151
181
  return page("Sign-in failed", `${error.slice(0, 200)}. Go back to your terminal.`, 400)
152
182
  }
153
- const c = callbackKey(url.searchParams.get("token") ?? "")
183
+ const c = flow.accept(url.searchParams.get("token") ?? "")
154
184
  if (!c.ok) {
155
185
  settle({ ok: false, why: c.why })
156
186
  return page("Sign-in failed", "No usable key came back. Go back to your terminal.", 400)
157
187
  }
158
188
  const email = url.searchParams.get("email") || c.email || undefined
159
189
  settle({ ok: true, key: c.key, ...(email ? { email } : {}), via: "browser" })
160
- return page("Signed in", `brain is now signed in to the 100xprompt gateway${email ? ` as ${email}` : ""}. You can close this tab and go back to your terminal.`)
190
+ const done = flow.done(email)
191
+ return page(done.title, done.body)
161
192
  },
162
193
  })
163
- const url = `${base}/auth/cli?port=${server.port}&state=${state}&challenge=${challenge}`
194
+ const extra = Object.entries(flow.params ?? {}).map(([k, v]) => `&${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("")
195
+ const url = `${base}${flow.path}?port=${server.port}&state=${state}&challenge=${challenge}${extra}`
164
196
  const stopAsking = new AbortController()
165
197
  const timer = setTimeout(() => settle({ ok: false, why: `no sign-in came back within ${Math.round((d.timeoutMs ?? TIMEOUT_MS) / 60_000) || 1} minutes` }), d.timeoutMs ?? TIMEOUT_MS)
166
198
  try {
167
- if (d.browser === false) d.say(`Open this address in a browser to sign in to 100xprompt:\n ${url}`)
199
+ if (d.browser === false) d.say(`Open this address in a browser to ${flow.opening}:\n ${url}`)
168
200
  else {
169
- d.say(`Opening your browser to sign in to 100xprompt:\n ${url}`)
201
+ d.say(`Opening your browser to ${flow.opening}:\n ${url}`)
170
202
  try {
171
203
  ;(d.open ?? openInBrowser)(url)
172
204
  } catch {}
173
205
  }
174
206
  if (d.ask) {
175
207
  d.say(
176
- "Waiting for the browser. Signing in on another machine (SSH)? Paste the key from the page's Copy key button, or the address the browser could not open, then press Enter (Escape cancels).",
208
+ `Waiting for the browser. Signing in on another machine (SSH)? ${flow.paste}, then press Enter (Escape cancels).`,
177
209
  )
178
210
  // The paste runs alongside the browser: whichever comes first wins. A bad paste asks again; Escape ends the sign-in
179
211
  // (an abandoned hidden prompt would leave the terminal echoing whatever is pasted next, and the shell reading it).
@@ -183,7 +215,7 @@ export async function browserSignIn(d: SignInDeps): Promise<SignIn> {
183
215
  if (settled) return
184
216
  if (pasted === undefined) return settle({ ok: false, why: "sign-in cancelled" })
185
217
  if (!pasted.trim()) continue
186
- const c = fromPaste(pasted.trim(), state)
218
+ const c = fromPaste(pasted.trim(), state, flow)
187
219
  if (c.ok) return settle({ ok: true, key: c.key, ...(c.email ? { email: c.email } : {}), via: "code" })
188
220
  if ("fatal" in c) return settle({ ok: false, why: c.why })
189
221
  d.say(` ${c.why}`)
@@ -207,7 +239,7 @@ export async function browserSignIn(d: SignInDeps): Promise<SignIn> {
207
239
  * 127.0.0.1 on the wrong machine, and the address it could not open holds the key). An address must carry this sign-in's
208
240
  * state, so one from another sign-in, or one made up, is refused.
209
241
  */
210
- export function fromPaste(pasted: string, state: string): { ok: true; key: string; email?: string } | { ok: false; why: string } | { ok: false; why: string; fatal: true } {
242
+ export function fromPaste(pasted: string, state: string, flow: BrowserFlow = SIGN_IN_FLOW): { ok: true; key: string; email?: string } | { ok: false; why: string } | { ok: false; why: string; fatal: true } {
211
243
  if (/^https?:\/\//i.test(pasted)) {
212
244
  let u: URL
213
245
  try {
@@ -220,12 +252,12 @@ export function fromPaste(pasted: string, state: string): { ok: true; key: strin
220
252
  if (!/^[0-9a-f]{64}$/.test(got) || !sameState(got, state)) return { ok: false, why: "that address is from another sign-in: paste the one from this sign-in" }
221
253
  const error = u.searchParams.get("error")
222
254
  if (error) return { ok: false, why: `the sign-in page said: ${error.slice(0, 200)}`, fatal: true }
223
- const c = callbackKey(u.searchParams.get("token") ?? "")
255
+ const c = flow.accept(u.searchParams.get("token") ?? "")
224
256
  if (!c.ok) return c
225
257
  const email = u.searchParams.get("email") || c.email
226
258
  return { ok: true, key: c.key, ...(email ? { email } : {}) }
227
259
  }
228
- return checkCredential(pasted)
260
+ return flow === SIGN_IN_FLOW ? checkCredential(pasted) : flow.accept(pasted)
229
261
  }
230
262
 
231
263
  /**
package/src/hooks.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import { CLI_PATH, selfArgs, stableBun } from "./self.ts"
2
- import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs"
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, statSync, unlinkSync } from "node:fs"
3
3
  import { dirname, join } from "node:path"
4
4
  import { toolLabel } from "./brief.ts"
5
- import { gitInfo, isDir } from "./fsutil.ts"
5
+ import { gitInfo, isDir, writeAtomic } from "./fsutil.ts"
6
6
  import { findHandoff, type Handoff, markConsumed } from "./handoff.ts"
7
7
  import { syncInBackground } from "./memory/sync.ts"
8
+ import { withBrainLock } from "./lock.ts"
8
9
 
9
10
  // Session-start hooks: how a pending handoff (handoff.ts) reaches the model in the target tool without the
10
11
  // person pasting anything. Each tool runs `<bun> brain/src/cli.ts session hook session-start --tool <tool>`
@@ -193,19 +194,15 @@ function save(path: string, config: Json, indent: string) {
193
194
  const real = existsSync(path) ? realpathSync(path) : path
194
195
  mkdirSync(dirname(real), { recursive: true })
195
196
  if (existsSync(real) && !existsSync(backupOf(path))) copyFileSync(real, backupOf(path))
196
- const tmp = `${real}.brain-tmp`
197
197
  let mode: number | undefined
198
198
  try {
199
199
  mode = statSync(real).mode & 0o777
200
200
  } catch {
201
201
  // A new file keeps the default mode.
202
202
  }
203
- // Create the copy with the file's own mode: Claude Code's settings are 0600 and can hold API keys, so they must
204
- // not sit readable by everyone, even for a moment. A copy left by an interrupted run would keep its old mode.
205
- rmSync(tmp, { force: true })
206
- writeFileSync(tmp, `${JSON.stringify(config, null, indent)}\n`, mode === undefined ? {} : { mode })
207
- if (mode !== undefined) chmodSync(tmp, mode)
208
- renameSync(tmp, real)
203
+ // The copy is created with the file's own mode: Claude Code's settings are 0600 and can hold API keys, so they must
204
+ // not sit readable by everyone, even for a moment.
205
+ writeAtomic(real, `${JSON.stringify(config, null, indent)}\n`, mode)
209
206
  }
210
207
 
211
208
  interface Pos {
@@ -256,6 +253,12 @@ export function hookInstalled(tool: string, home: string): boolean {
256
253
  return Array.isArray(list) && positions(list, spec, id).length > 0 && !spec.off?.(loaded.config)
257
254
  }
258
255
 
256
+ /** Whether `tool` has a session-start hook brain can install, and has run here (its folder exists). */
257
+ export function hookToolHere(tool: string, home: string): boolean {
258
+ const spec = SPECS[toolId(tool)]
259
+ return !!spec && isDir(join(home, spec.dir))
260
+ }
261
+
259
262
  export interface HookChange {
260
263
  tool: string
261
264
  /** The config file changed (or that would be). */
@@ -287,6 +290,10 @@ function prepare(tool: string, home: string): { id: string; spec: Spec; path: st
287
290
  * the hook command (`<bun> <cli.ts>`); the rest stays, since that is how uninstall finds the entry.
288
291
  */
289
292
  export async function installHooks(tools: string[], home: string, opts: { dryRun?: boolean; command?: string } = {}): Promise<HookChange[]> {
293
+ return opts.dryRun ? installLocked(tools, home, opts) : withBrainLock(home, () => installLocked(tools, home, opts), { what: "installing session hooks" })
294
+ }
295
+
296
+ async function installLocked(tools: string[], home: string, opts: { dryRun?: boolean; command?: string }): Promise<HookChange[]> {
290
297
  const out: HookChange[] = []
291
298
  for (const tool of [...new Set(tools)]) {
292
299
  const p = prepare(tool, home)
@@ -333,6 +340,10 @@ export async function installHooks(tools: string[], home: string, opts: { dryRun
333
340
 
334
341
  /** Removes exactly what installHooks added. */
335
342
  export async function uninstallHooks(tools: string[], home: string, opts: { dryRun?: boolean } = {}): Promise<HookChange[]> {
343
+ return opts.dryRun ? uninstallLocked(tools, home, opts) : withBrainLock(home, () => uninstallLocked(tools, home, opts), { what: "removing session hooks" })
344
+ }
345
+
346
+ async function uninstallLocked(tools: string[], home: string, opts: { dryRun?: boolean }): Promise<HookChange[]> {
336
347
  const out: HookChange[] = []
337
348
  for (const tool of [...new Set(tools)]) {
338
349
  const p = prepare(tool, home)