@100xprompt/brain 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -3
- package/package.json +1 -1
- package/src/cli.ts +28 -3
- package/src/dashboard/cli.ts +148 -0
- package/src/dashboard/conversions.ts +170 -0
- package/src/dashboard/report.ts +261 -0
- package/src/dashboard/send.ts +213 -0
- package/src/dashboard/state.ts +95 -0
- package/src/gateway/common.ts +232 -0
- package/src/gateway/connect.ts +164 -162
- package/src/gateway/connectors/aider.ts +356 -0
- package/src/gateway/connectors/amazon-q-cli.ts +6 -0
- package/src/gateway/connectors/amazon-q-ide.ts +6 -0
- package/src/gateway/connectors/antigravity.ts +6 -0
- package/src/gateway/connectors/auggie.ts +6 -0
- package/src/gateway/connectors/cline-sdk.ts +85 -0
- package/src/gateway/connectors/cline.ts +34 -0
- package/src/gateway/connectors/codebuff.ts +6 -0
- package/src/gateway/connectors/continue.ts +579 -0
- package/src/gateway/connectors/copilot-cli.ts +138 -0
- package/src/gateway/connectors/crush.ts +313 -0
- package/src/gateway/connectors/cursor-cli.ts +6 -0
- package/src/gateway/connectors/cursor.ts +6 -0
- package/src/gateway/connectors/devin.ts +6 -0
- package/src/gateway/connectors/factory-droid.ts +308 -0
- package/src/gateway/connectors/goose.ts +79 -0
- package/src/gateway/connectors/grok-cli.ts +82 -0
- package/src/gateway/connectors/hermes.ts +230 -0
- package/src/gateway/connectors/iflow-cli.ts +50 -0
- package/src/gateway/connectors/index.ts +86 -0
- package/src/gateway/connectors/kilo-cli.ts +434 -0
- package/src/gateway/connectors/kilo-code.ts +32 -0
- package/src/gateway/connectors/kimi-code.ts +241 -0
- package/src/gateway/connectors/kiro-cli.ts +6 -0
- package/src/gateway/connectors/kiro-ide.ts +6 -0
- package/src/gateway/connectors/letta-code.ts +170 -0
- package/src/gateway/connectors/mastracode.ts +91 -0
- package/src/gateway/connectors/mistral-vibe.ts +495 -0
- package/src/gateway/connectors/muse.ts +27 -0
- package/src/gateway/connectors/omp.ts +279 -0
- package/src/gateway/connectors/openhands.ts +296 -0
- package/src/gateway/connectors/pearai.ts +52 -0
- package/src/gateway/connectors/pi.ts +357 -0
- package/src/gateway/connectors/qoder-cli.ts +6 -0
- package/src/gateway/connectors/qwen-code.ts +411 -0
- package/src/gateway/connectors/roo.ts +43 -0
- package/src/gateway/connectors/tabnine-cli.ts +6 -0
- package/src/gateway/connectors/void.ts +6 -0
- package/src/gateway/connectors/vscode-copilot.ts +286 -0
- package/src/gateway/connectors/windsurf.ts +6 -0
- package/src/gateway/connectors/zed.ts +29 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// Gateway connector for Kimi Code (Moonshot's TypeScript CLI, `kimi`; the successor of the archived Python Kimi CLI that
|
|
2
|
+
// used ~/.kimi). Checked against @moonshot-ai/kimi-code 2.5.2 (the copy in ~/.cache/brain-wire/kimi-code, dist/main.mjs)
|
|
3
|
+
// and https://moonshotai.github.io/kimi-code/en/configuration/config-files.html.
|
|
4
|
+
//
|
|
5
|
+
// Settings live in one TOML file, $KIMI_CODE_HOME/config.toml, else ~/.kimi-code/config.toml (main.mjs resolveKimiHome /
|
|
6
|
+
// resolveConfigPath; docs "Config file location"). Keys are snake_case and read into camelCase (providerEntryFromToml):
|
|
7
|
+
// default_model = "<alias>" the model alias used when none is given (-m)
|
|
8
|
+
// [providers.<id>] type ("openai" = Chat Completions, also "openai_responses", "anthropic", ...), base_url, api_key or
|
|
9
|
+
// api_key_env (the NAME of an exported variable), custom_headers (a string table, sent with every model
|
|
10
|
+
// request: main.mjs resolveOutboundHeaders), env (more literal values) (ProviderConfigSchema$1)
|
|
11
|
+
// [models.<alias>] provider, model (sent to the API), max_context_size (required), capabilities, display_name (what the
|
|
12
|
+
// UI shows: main.mjs `displayName ?? model ?? alias`) (ModelBaseSchema)
|
|
13
|
+
// The key: Kimi Code takes it only as api_key (in the file), api_key_env (a variable the person exports; brain cannot set
|
|
14
|
+
// one for a tool started from a shell), the env sub-table (in the file again) or its OAuth login. It cannot run a command
|
|
15
|
+
// or read a file ("The documentation does not describe supplying a key by running a command or by reading a file"; no such
|
|
16
|
+
// field in the schema). So the key is copied into config.toml: holdsKey (0600), and the provider table is a secret key
|
|
17
|
+
// (hidden, never recorded, replaced on a new sign-in).
|
|
18
|
+
//
|
|
19
|
+
// What is written: default_model = "brain-gateway", [providers.brain-gateway] (type "openai", base_url <route>/v1, api_key,
|
|
20
|
+
// custom_headers with x-100x-context-edit) and [models.brain-gateway] (the gateway's model id, shown as "100xprompt Pro").
|
|
21
|
+
// Kimi Code needs no model list from the gateway for a provider of type "openai" (only its own "kimi" provider refreshes one).
|
|
22
|
+
//
|
|
23
|
+
// The core's TOML handling knows only Codex's table, so this file has its own codec: top-level keys are edited line by
|
|
24
|
+
// line, and a table is one key ("providers/brain-gateway") replaced, added or removed as a whole block. Every other line,
|
|
25
|
+
// comments included, stays as it was; a table brain added is removed with the blank line it brought, so disconnect gives
|
|
26
|
+
// back the exact bytes. Before any change the codec makes a trial edit and refuses a file where that would not land
|
|
27
|
+
// exactly on brain's entries (an inline `providers = {...}`, dotted keys, sub-tables of brain's tables, ...).
|
|
28
|
+
import { join } from "node:path"
|
|
29
|
+
import { type Codec, type Connector, dirFromEnv, EDIT_HEADER, type Json, modelName, readKey, routeUrl } from "../common.ts"
|
|
30
|
+
|
|
31
|
+
export const kimiConfig = (home: string, env: Record<string, string | undefined>) => join(dirFromEnv(env, "KIMI_CODE_HOME", join(home, ".kimi-code")), "config.toml")
|
|
32
|
+
|
|
33
|
+
// ---- TOML: top-level keys and whole tables ------------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
36
|
+
const tomlKey = (k: string) => (/^[A-Za-z0-9_-]+$/.test(k) ? k : JSON.stringify(k))
|
|
37
|
+
const tomlValue = (v: unknown): string =>
|
|
38
|
+
typeof v === "string"
|
|
39
|
+
? JSON.stringify(v)
|
|
40
|
+
: typeof v === "number" || typeof v === "boolean"
|
|
41
|
+
? String(v)
|
|
42
|
+
: Array.isArray(v)
|
|
43
|
+
? `[${v.map(tomlValue).join(", ")}]`
|
|
44
|
+
: `{ ${Object.entries(v as Json).map(([k, x]) => `${tomlKey(k)} = ${tomlValue(x)}`).join(", ")} }`
|
|
45
|
+
|
|
46
|
+
const ANY_HEADER = /^[ \t]*\[/
|
|
47
|
+
const quiet = (l: string) => l.trim() === "" || /^\s*#/.test(l)
|
|
48
|
+
|
|
49
|
+
/** The ` # comment` after a `key = value` line (a # outside quotes), with the spaces before it. */
|
|
50
|
+
function lineComment(line: string): string {
|
|
51
|
+
let quote: string | undefined
|
|
52
|
+
for (let i = line.indexOf("=") + 1; i > 0 && i < line.length; i++) {
|
|
53
|
+
const c = line[i]!
|
|
54
|
+
if (quote) {
|
|
55
|
+
if (c === "\\" && quote === '"') i++
|
|
56
|
+
else if (c === quote) quote = undefined
|
|
57
|
+
} else if (c === '"' || c === "'") quote = c
|
|
58
|
+
else if (c === "#") {
|
|
59
|
+
let j = i
|
|
60
|
+
while (j > 0 && /[ \t]/.test(line[j - 1]!)) j--
|
|
61
|
+
return line.slice(j).trimEnd()
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return ""
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function tomlSet(text: string, key: string, value: unknown): string {
|
|
68
|
+
const nl = text === "" || text.endsWith("\n")
|
|
69
|
+
const lines = text ? text.replace(/\n$/, "").split("\n") : []
|
|
70
|
+
const slash = key.indexOf("/")
|
|
71
|
+
if (slash < 0) {
|
|
72
|
+
// A top-level key: its line before the first table, replaced in place (keeping a trailing comment), or added at the
|
|
73
|
+
// end of that part (before the comments that introduce the first table).
|
|
74
|
+
const first = lines.findIndex((l) => ANY_HEADER.test(l))
|
|
75
|
+
const preEnd = first < 0 ? lines.length : first
|
|
76
|
+
const re = new RegExp(`^[ \\t]*(?:${esc(key)}|"${esc(key)}"|'${esc(key)}')[ \\t]*=`)
|
|
77
|
+
const at = lines.slice(0, preEnd).findIndex((l) => re.test(l))
|
|
78
|
+
if (value === undefined) {
|
|
79
|
+
if (at >= 0) lines.splice(at, 1)
|
|
80
|
+
} else if (at >= 0) lines[at] = `${tomlKey(key)} = ${tomlValue(value)}${lineComment(lines[at]!)}`
|
|
81
|
+
else {
|
|
82
|
+
let ins = preEnd
|
|
83
|
+
if (first >= 0) while (ins > 0 && quiet(lines[ins - 1]!)) ins--
|
|
84
|
+
lines.splice(ins, 0, `${tomlKey(key)} = ${tomlValue(value)}`)
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
// A whole table [parent.name]: its header line and the lines up to the next header (the blank lines and comments
|
|
88
|
+
// just before that header belong to it, not to this table).
|
|
89
|
+
const parent = key.slice(0, slash)
|
|
90
|
+
const name = key.slice(slash + 1)
|
|
91
|
+
const head = new RegExp(`^[ \\t]*\\[[ \\t]*${esc(parent)}[ \\t]*\\.[ \\t]*(?:${esc(name)}|"${esc(name)}"|'${esc(name)}')[ \\t]*\\][ \\t]*(?:#.*)?$`)
|
|
92
|
+
const at = lines.findIndex((l) => head.test(l))
|
|
93
|
+
let end = at + 1
|
|
94
|
+
if (at >= 0) {
|
|
95
|
+
while (end < lines.length && !ANY_HEADER.test(lines[end]!)) end++
|
|
96
|
+
while (end > at + 1 && quiet(lines[end - 1]!)) end--
|
|
97
|
+
}
|
|
98
|
+
const block = value === undefined ? [] : [`[${tomlKey(parent)}.${tomlKey(name)}]`, ...Object.entries(value as Json).map(([k, x]) => `${tomlKey(k)} = ${tomlValue(x)}`)]
|
|
99
|
+
if (value === undefined) {
|
|
100
|
+
if (at >= 0) {
|
|
101
|
+
lines.splice(at, end - at)
|
|
102
|
+
// The blank line that was put before it when it was added.
|
|
103
|
+
if (at > 0 && lines[at - 1] === "") lines.splice(at - 1, 1)
|
|
104
|
+
}
|
|
105
|
+
} else if (at >= 0) lines.splice(at, end - at, ...block)
|
|
106
|
+
else {
|
|
107
|
+
if (lines.length) lines.push("")
|
|
108
|
+
lines.push(...block)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return lines.length ? `${lines.join("\n")}${nl ? "\n" : ""}` : ""
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const pathGet = (o: unknown, key: string): unknown => key.split("/").reduce<unknown>((cur, p) => (cur && typeof cur === "object" && !Array.isArray(cur) ? (cur as Json)[p] : undefined), o)
|
|
115
|
+
|
|
116
|
+
const canon = (v: unknown): string =>
|
|
117
|
+
v instanceof Date
|
|
118
|
+
? JSON.stringify(v.toISOString())
|
|
119
|
+
: Array.isArray(v)
|
|
120
|
+
? `[${v.map(canon).join(",")}]`
|
|
121
|
+
: v && typeof v === "object"
|
|
122
|
+
? `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canon((v as Json)[k])}`).join(",")}}`
|
|
123
|
+
: JSON.stringify(v)
|
|
124
|
+
|
|
125
|
+
/** `o` without `key`, and without the tables that leaves empty (to compare the rest of a file). */
|
|
126
|
+
function without(o: Json, key: string): Json {
|
|
127
|
+
const c = structuredClone(o)
|
|
128
|
+
const ps = key.split("/")
|
|
129
|
+
const parent = ps.length > 1 ? c[ps[0]!] : c
|
|
130
|
+
if (!parent || typeof parent !== "object") return c
|
|
131
|
+
delete (parent as Json)[ps[ps.length - 1]!]
|
|
132
|
+
if (ps.length > 1 && !Object.keys(parent).length) delete c[ps[0]!]
|
|
133
|
+
return c
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Stand-ins for what brain writes: a trial edit with them must land exactly on those entries and change nothing else. */
|
|
137
|
+
const TRIAL: Record<string, unknown> = {
|
|
138
|
+
default_model: "brain-trial",
|
|
139
|
+
"providers/brain-gateway": { type: "openai", base_url: "http://127.0.0.1/v1", custom_headers: { a: "b" } },
|
|
140
|
+
"models/brain-gateway": { provider: "brain-gateway", model: "m", max_context_size: 1, capabilities: ["tool_use"] },
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Whether `providers` or `models`, or brain's entry in them, is written in a form a `[providers.brain-gateway]` table
|
|
145
|
+
* cannot be added next to (an inline table, dotted keys, a sub-table or an array of tables). TOML forbids defining a table
|
|
146
|
+
* twice and Kimi Code's parser enforces it; Bun's parser does not, so this is checked line by line.
|
|
147
|
+
*/
|
|
148
|
+
function unusualLayout(text: string): boolean {
|
|
149
|
+
let table: string | undefined
|
|
150
|
+
for (const l of text.split("\n")) {
|
|
151
|
+
const h = /^[ \t]*(\[\[?)([^\]]*)\]/.exec(l)
|
|
152
|
+
if (h) {
|
|
153
|
+
table = h[2]!.replace(/[\s"']/g, "")
|
|
154
|
+
if (/^(providers|models)\.brain-gateway\./.test(table) || (h[1] === "[[" && /^(providers|models)(\.|$)/.test(table))) return true
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
if (table === undefined && /^[ \t]*["']?(providers|models)["']?[ \t]*[.=]/.test(l)) return true
|
|
158
|
+
if ((table === "providers" || table === "models") && /^[ \t]*["']?brain-gateway["']?[ \t]*[.=]/.test(l)) return true
|
|
159
|
+
}
|
|
160
|
+
return false
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Kimi Code's config.toml: top-level keys ("default_model") and whole tables ("providers/brain-gateway"). */
|
|
164
|
+
export const kimiToml: Codec = {
|
|
165
|
+
check(text) {
|
|
166
|
+
if (!text.trim()) return undefined
|
|
167
|
+
let before: Json
|
|
168
|
+
try {
|
|
169
|
+
before = Bun.TOML.parse(text) as Json
|
|
170
|
+
} catch {
|
|
171
|
+
return "it is not valid TOML; left as it is"
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
if (unusualLayout(text)) throw new Error("layout")
|
|
175
|
+
let t = text
|
|
176
|
+
for (const [k, v] of Object.entries(TRIAL)) t = tomlSet(t, k, v)
|
|
177
|
+
const after = Bun.TOML.parse(t) as Json
|
|
178
|
+
let rest = after
|
|
179
|
+
let was = before
|
|
180
|
+
for (const [k, v] of Object.entries(TRIAL)) {
|
|
181
|
+
if (canon(pathGet(after, k)) !== canon(v)) throw new Error(k)
|
|
182
|
+
rest = without(rest, k)
|
|
183
|
+
was = without(was, k)
|
|
184
|
+
}
|
|
185
|
+
if (canon(rest) !== canon(was)) throw new Error("other values moved")
|
|
186
|
+
// Where none of brain's entries is there yet, taking them out again must give back the very same text.
|
|
187
|
+
if (Object.keys(TRIAL).every((k) => pathGet(before, k) === undefined)) {
|
|
188
|
+
for (const k of Object.keys(TRIAL)) t = tomlSet(t, k, undefined)
|
|
189
|
+
if (t !== text) throw new Error("not reversible")
|
|
190
|
+
}
|
|
191
|
+
} catch {
|
|
192
|
+
return "brain cannot add its entries without touching other settings (an unusual layout: inline tables or dotted keys for providers or models?); left as it is"
|
|
193
|
+
}
|
|
194
|
+
return undefined
|
|
195
|
+
},
|
|
196
|
+
get(text, key) {
|
|
197
|
+
if (!text.trim()) return undefined
|
|
198
|
+
return pathGet(Bun.TOML.parse(text), key)
|
|
199
|
+
},
|
|
200
|
+
set: tomlSet,
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export const PROVIDER = "brain-gateway"
|
|
204
|
+
|
|
205
|
+
export const connectors: Connector[] = [
|
|
206
|
+
{
|
|
207
|
+
id: "kimi-code",
|
|
208
|
+
label: "Kimi Code",
|
|
209
|
+
file: kimiConfig,
|
|
210
|
+
probe: [".kimi-code"],
|
|
211
|
+
authHeader: "authorization",
|
|
212
|
+
edits: (home, s, env) => [
|
|
213
|
+
{
|
|
214
|
+
path: kimiConfig(home, env),
|
|
215
|
+
format: "custom",
|
|
216
|
+
codec: kimiToml,
|
|
217
|
+
holdsKey: true,
|
|
218
|
+
secretKeys: [`providers/${PROVIDER}`],
|
|
219
|
+
values: {
|
|
220
|
+
default_model: PROVIDER,
|
|
221
|
+
[`providers/${PROVIDER}`]: {
|
|
222
|
+
type: "openai",
|
|
223
|
+
base_url: `${routeUrl(s)}/v1`,
|
|
224
|
+
api_key: readKey(home),
|
|
225
|
+
...(s.contextEdit ? { custom_headers: { [EDIT_HEADER]: "on" } } : {}),
|
|
226
|
+
},
|
|
227
|
+
[`models/${PROVIDER}`]: {
|
|
228
|
+
provider: PROVIDER,
|
|
229
|
+
model: s.model,
|
|
230
|
+
max_context_size: 1048576,
|
|
231
|
+
capabilities: ["image_in", "tool_use"],
|
|
232
|
+
display_name: modelName(s.model),
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
],
|
|
237
|
+
notes: [
|
|
238
|
+
"Kimi Code cannot read a key from a file or a command (only from its config.toml, or from a variable you export), so the key is kept in ~/.kimi-code/config.toml, which is now readable by you only (0600).",
|
|
239
|
+
],
|
|
240
|
+
},
|
|
241
|
+
]
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Gateway connector for Kiro CLI: none. It uses only Kiro's AWS-hosted models (sign-in required); ~/.kiro/settings/cli.json
|
|
2
|
+
// picks a model by name (chat.defaultModel) and has no endpoint, provider or key (https://kiro.dev/docs/reference/settings/,
|
|
3
|
+
// updated 2026-11-06; the BYOK request is still open: https://github.com/kirodotdev/Kiro/issues/8939).
|
|
4
|
+
import type { Connector } from "../common.ts"
|
|
5
|
+
|
|
6
|
+
export const connectors: Connector[] = []
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Gateway connector for Kiro IDE: none. Its model dropdown offers only Kiro's own models, served from AWS regions; there is
|
|
2
|
+
// no custom model, endpoint or key (https://kiro.dev/docs/chat/model-selection/, updated 2026-10-24; BYOK request open since
|
|
3
|
+
// 2026-05-28: https://github.com/kirodotdev/Kiro/issues/8939).
|
|
4
|
+
import type { Connector } from "../common.ts"
|
|
5
|
+
|
|
6
|
+
export const connectors: Connector[] = []
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Gateway connector for Letta Code (`letta`, npm @letta-ai/letta-code). Checked against its source at 0.34.51
|
|
2
|
+
// (github.com/letta-ai/letta-code, main at 0aed9a6, 2026-11-10; Letta Code is not installed here: wire/drivers/b.ts says
|
|
3
|
+
// why) and docs.letta.com/letta-code/models.
|
|
4
|
+
//
|
|
5
|
+
// Letta Code has two backends, and only one of them makes its model calls from this machine:
|
|
6
|
+
// - Letta Cloud / a Letta server (the default, `letta backend cloud`): the agent runs on the server, which calls the
|
|
7
|
+
// model. Custom endpoints there are BYOK providers stored in the Letta account through the server's /v1/providers
|
|
8
|
+
// API (src/backend/api/providers.ts:111-152), and they "must be reachable from the public internet"
|
|
9
|
+
// (docs.letta.com/letta-code/models). Nothing on disk to change: those agents are left alone.
|
|
10
|
+
// - Local mode (`letta backend local`, or --backend local; src/cli/subcommands/backend.ts:27-79): agents live in
|
|
11
|
+
// ~/.letta/lc-local-backend and Letta Code calls the model itself through pi-ai. Its built-in "OpenAI-compatible"
|
|
12
|
+
// provider (/connect, stored in lc-local-backend/providers/auth.json) cannot be used: it offers only the models its
|
|
13
|
+
// GET <base>/v1/models returns (src/backend/dev/pi-openai-compatible-provider.ts:16-22; models are kept in memory
|
|
14
|
+
// only and "never fabricated from name strings": pi-models-runtime.ts:209, pi-model-factory.ts), and the gateway
|
|
15
|
+
// answers /v1/models with 403. A provider MOD can: Letta loads every .js/.mjs/.ts file in
|
|
16
|
+
// ${LETTA_CONFIG_DIR:-~/.letta}/mods (or the legacy ~/.letta/extensions) as trusted local code at start and on
|
|
17
|
+
// /reload (src/mods/paths.ts:20-53, mod-sources.ts:45-106, mod-engine.ts:1430-1490), and a mod's
|
|
18
|
+
// letta.providers.register(id, {...}) declares an OpenAI-compatible provider with a fixed model list, its own
|
|
19
|
+
// baseUrl, an apiKey and headers (src/backend/dev/pi-provider-mod-types.ts:68-82;
|
|
20
|
+
// src/skills/builtin/creating-mods/references/providers.md). Static models are never re-listed
|
|
21
|
+
// (pi-mod-provider.ts:98-122), `connect: false` makes it usable with no /connect step
|
|
22
|
+
// (registered-pi-provider-runtime.ts:62-73), `authHeader` sends `Authorization: Bearer <apiKey>` and `headers` go on
|
|
23
|
+
// every request (pi-mod-provider.ts:41-59,124-153).
|
|
24
|
+
//
|
|
25
|
+
// So brain writes one file it owns, <mods>/brain-gateway.mjs: a mod registering the provider "brain-gateway" (shown as
|
|
26
|
+
// "100xprompt gateway", its model as modelName(s.model)) on OpenAI Chat Completions at <route>/v1, with the
|
|
27
|
+
// context-edit header when on. The mod reads the key from the brain's key file each time it is activated, so the key is
|
|
28
|
+
// never copied (and a new sign-in is picked up at the next start or /reload). disconnect deletes the file.
|
|
29
|
+
//
|
|
30
|
+
// Limits, said on connect: only local agents can use it; the model is chosen per agent (each local agent record keeps
|
|
31
|
+
// its own model, src/backend/local/local-agent-record.ts; there is no global default-model setting,
|
|
32
|
+
// src/settings-manager.ts:68-112), so the person picks it with /model. Letta adds its own x-letta-request-kind header
|
|
33
|
+
// (src/backend/dev/pi-request-headers.ts).
|
|
34
|
+
import { existsSync } from "node:fs"
|
|
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"
|
|
37
|
+
|
|
38
|
+
export const MOD_FILE = "brain-gateway.mjs"
|
|
39
|
+
const MARK = "// Written by `brain gateway connect --tool letta-code`"
|
|
40
|
+
const SETTINGS_LINE = /^const gateway = (.*)$/m
|
|
41
|
+
|
|
42
|
+
/** ${LETTA_CONFIG_DIR:-~/.letta} (src/utils/settings-path.ts:10-22): the override counts when absolute and present. */
|
|
43
|
+
function configDir(home: string, env: Env): string {
|
|
44
|
+
const o = env.LETTA_CONFIG_DIR?.trim()
|
|
45
|
+
return o && isAbsolute(o) && existsSync(o) ? o : join(home, ".letta")
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Where Letta looks for global mods, as resolveDefaultGlobalModsDirectory does (src/mods/paths.ts:34-53). */
|
|
49
|
+
export function modsDir(home: string, env: Env): string {
|
|
50
|
+
const fromEnv = (env.LETTA_MODS_DIR ?? env.LETTA_EXTENSIONS_DIR)?.trim()
|
|
51
|
+
if (fromEnv && existsSync(fromEnv)) return fromEnv
|
|
52
|
+
const mods = join(configDir(home, env), "mods")
|
|
53
|
+
if (existsSync(mods)) return mods
|
|
54
|
+
const legacy = join(configDir(home, env), "extensions")
|
|
55
|
+
return existsSync(legacy) ? legacy : mods
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A JS literal for `v` (JSON, with the two line separators JSON leaves raw escaped, so it is safe on one line of code). */
|
|
59
|
+
const literal = (v: unknown) => JSON.stringify(v).replace(/[\u2028\u2029]/g, (c) => `\\u${c.charCodeAt(0).toString(16)}`)
|
|
60
|
+
|
|
61
|
+
/** The whole mod, from its settings: the only part that varies is the `const gateway = {...}` line. */
|
|
62
|
+
export function renderMod(settings: Json): string {
|
|
63
|
+
if (!Object.keys(settings).length) return ""
|
|
64
|
+
return `${MARK}: it lets Letta Code's local agents use the
|
|
65
|
+
// 100xprompt gateway ("100xprompt gateway" in /model). \`brain gateway disconnect --tool letta-code\` removes it; a
|
|
66
|
+
// reconnect writes it again. The key is read from the brain's key file each time Letta Code starts or reloads mods.
|
|
67
|
+
import { readFileSync } from "node:fs"
|
|
68
|
+
|
|
69
|
+
const gateway = ${literal(settings)}
|
|
70
|
+
|
|
71
|
+
export default function activate(letta) {
|
|
72
|
+
if (!letta?.capabilities?.providers || !gateway.baseUrl || !gateway.model) return
|
|
73
|
+
let apiKey
|
|
74
|
+
try {
|
|
75
|
+
apiKey = readFileSync(gateway.keyFile, "utf8").trim() || undefined
|
|
76
|
+
} catch {}
|
|
77
|
+
return letta.providers.register(gateway.provider, {
|
|
78
|
+
name: gateway.name,
|
|
79
|
+
description: "The 100xprompt gateway, set up by brain",
|
|
80
|
+
api: "openai-completions",
|
|
81
|
+
baseUrl: gateway.baseUrl,
|
|
82
|
+
...(apiKey ? { apiKey } : {}),
|
|
83
|
+
authHeader: true,
|
|
84
|
+
...(gateway.headers ? { headers: gateway.headers } : {}),
|
|
85
|
+
connect: false,
|
|
86
|
+
models: [
|
|
87
|
+
{
|
|
88
|
+
id: gateway.model,
|
|
89
|
+
name: gateway.modelName,
|
|
90
|
+
reasoning: false,
|
|
91
|
+
input: ["text", "image"],
|
|
92
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
93
|
+
contextWindow: gateway.contextWindow,
|
|
94
|
+
maxTokens: gateway.maxTokens,
|
|
95
|
+
compat: { supportsDeveloperRole: false, supportsReasoningEffort: false },
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The settings of a mod brain wrote (undefined for any other file). */
|
|
104
|
+
function readSettings(text: string): Json | undefined {
|
|
105
|
+
if (!text.startsWith(MARK)) return undefined
|
|
106
|
+
const m = SETTINGS_LINE.exec(text)
|
|
107
|
+
if (!m) return undefined
|
|
108
|
+
try {
|
|
109
|
+
const v = JSON.parse(m[1]!)
|
|
110
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : undefined
|
|
111
|
+
} catch {
|
|
112
|
+
return undefined
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The mod file as settings: each key is one field of its `const gateway = {...}` line, and the file is generated whole
|
|
118
|
+
* from them (no fields: no file). A file brain did not write is refused; a brain file that was edited by hand reads as
|
|
119
|
+
* changed, so disconnect leaves it (and says so).
|
|
120
|
+
*/
|
|
121
|
+
export const lettaMod: Codec = {
|
|
122
|
+
check(text) {
|
|
123
|
+
if (!text.trim() || readSettings(text)) return undefined
|
|
124
|
+
return "a mod by that name that brain did not write is there; left as it is"
|
|
125
|
+
},
|
|
126
|
+
get(text, key) {
|
|
127
|
+
const s = readSettings(text)
|
|
128
|
+
if (!s) return undefined
|
|
129
|
+
return renderMod(s) === text ? s[key] : "(changed by hand)"
|
|
130
|
+
},
|
|
131
|
+
set(text, key, value) {
|
|
132
|
+
const s = { ...(readSettings(text) ?? {}) }
|
|
133
|
+
if (value === undefined) delete s[key]
|
|
134
|
+
else s[key] = value
|
|
135
|
+
return renderMod(s)
|
|
136
|
+
},
|
|
137
|
+
}
|
|
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
|
+
const modValues = (home: string, s: GatewaySettings): Record<string, unknown> => ({
|
|
144
|
+
provider: "brain-gateway",
|
|
145
|
+
name: "100xprompt gateway",
|
|
146
|
+
baseUrl: `${routeUrl(s)}/v1`,
|
|
147
|
+
model: s.model,
|
|
148
|
+
modelName: modelName(s.model),
|
|
149
|
+
contextWindow: CONTEXT_WINDOW,
|
|
150
|
+
maxTokens: MAX_TOKENS,
|
|
151
|
+
keyFile: keyPath(home),
|
|
152
|
+
...(s.contextEdit ? { headers: { [EDIT_HEADER]: "on" } } : {}),
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
export const connectors: Connector[] = [
|
|
156
|
+
{
|
|
157
|
+
id: "letta-code",
|
|
158
|
+
label: "Letta Code",
|
|
159
|
+
file: (home, env) => join(modsDir(home, env), MOD_FILE),
|
|
160
|
+
// Its local mode has been used here (the local backend's store); a mods folder counts too (the file's own folder).
|
|
161
|
+
probe: [".letta/lc-local-backend"],
|
|
162
|
+
authHeader: "authorization",
|
|
163
|
+
edits: (home, s, env) => [{ path: join(modsDir(home, env), MOD_FILE), format: "custom", codec: lettaMod, values: modValues(home, s) }],
|
|
164
|
+
notes: [
|
|
165
|
+
"Letta Code reaches the gateway through a provider mod brain wrote in its mods folder; the mod reads the key from the brain's key file each time Letta Code starts (or on /reload), so the key is not copied.",
|
|
166
|
+
"Only local agents (letta backend local, or --backend local) use it: agents on Letta Cloud or a Letta server call their model from that server and are not changed.",
|
|
167
|
+
"Letta Code keeps a model per agent: in each local agent, choose the 100xprompt gateway model with /model. After disconnect, an agent still set to it asks you to choose another.",
|
|
168
|
+
],
|
|
169
|
+
},
|
|
170
|
+
]
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Gateway connector for Mastra Code (the `mastracode` CLI, npm `mastracode`, source mastra-ai/mastra/mastracode). Checked against
|
|
2
|
+
// the source at tag mastracode@0.49.0 (the npm latest on 2026-11-10) and, for the same facts, mastracode@0.37.1 (the version
|
|
3
|
+
// brain's session adapter was checked against). It is not installed on this machine (wire/drivers/f.ts), so there is no
|
|
4
|
+
// real-tool check for it.
|
|
5
|
+
//
|
|
6
|
+
// Everything Mastra Code keeps is in one JSON file, settings.json in its app-data folder: $MASTRA_APP_DATA_DIR, else
|
|
7
|
+
// ~/Library/Application Support/mastracode (macOS), %APPDATA%\mastracode (Windows), $XDG_DATA_HOME/mastracode or
|
|
8
|
+
// ~/.local/share/mastracode (sdk/src/utils/project.ts:187-212; settings.ts:781-783 getSettingsPath).
|
|
9
|
+
// - `customProviders: [{name, url, apiKey?, models: [id]}]` adds an OpenAI-compatible provider (settings.ts:41-46). Its id
|
|
10
|
+
// is the slugged name, "100xprompt gateway" -> 100xprompt-gateway (settings.ts:785-792), its models are
|
|
11
|
+
// `<id>/<model>`, and a request goes to `<url>/chat/completions` through @ai-sdk/openai-compatible with
|
|
12
|
+
// `Authorization: Bearer <apiKey>` (sdk/src/agents/mastracode-gateway.ts:490-511; the key is the literal apiKey,
|
|
13
|
+
// :477-479). There is no key file, env or command form and no per-provider headers, so the key is COPIED into
|
|
14
|
+
// settings.json (made 0600; Mastra Code keeps a file's mode when it rewrites it, settings.ts:1519) and the
|
|
15
|
+
// context-edit header cannot be sent.
|
|
16
|
+
// - `customModelPacks: [{name, models: {build, plan, fast}, createdAt}]` plus `models.activeModelPackId: "custom:<name>"`
|
|
17
|
+
// make every mode's default model the pack's (settings.ts:34-38, 1393-1407 resolveModelDefaults), which is how
|
|
18
|
+
// Mastra Code's own /models picker saves a custom pack. That leaves the person's own modeDefaults untouched.
|
|
19
|
+
// The file is plain JSON (settings.ts:1199 JSON.parse) that Mastra Code rewrites whole with JSON.stringify(_, null, 2)
|
|
20
|
+
// (settings.ts:1537) and that always holds both lists; brain's list entries are found by name, so they survive those
|
|
21
|
+
// rewrites, and the text editor (pi.ts) gives the exact bytes back on disconnect. A missing settings.json is refused: Mastra
|
|
22
|
+
// Code writes it when its first-run setup finishes, and that setup picks a model pack of its own.
|
|
23
|
+
import { existsSync } from "node:fs"
|
|
24
|
+
import { join, resolve, sep } from "node:path"
|
|
25
|
+
import { type Connector, type Env, type GatewaySettings, type Json, readKey, routeUrl } from "../common.ts"
|
|
26
|
+
import { jsonText, PROVIDER_NAME } from "./pi.ts"
|
|
27
|
+
|
|
28
|
+
/** Mastra Code's provider id for a custom provider: its name, slugged (settings.ts getCustomProviderId). */
|
|
29
|
+
export const providerSlug = (name: string) =>
|
|
30
|
+
name
|
|
31
|
+
.trim()
|
|
32
|
+
.toLowerCase()
|
|
33
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
34
|
+
.replace(/^-+|-+$/g, "") || "provider"
|
|
35
|
+
|
|
36
|
+
/** The pack's creation time: fixed, so a reconnect with the same settings changes nothing. */
|
|
37
|
+
const CREATED = "2026-11-10T00:00:00.000Z"
|
|
38
|
+
|
|
39
|
+
/** An env folder counts only inside this home (a throwaway home in tests never follows the real XDG_DATA_HOME). */
|
|
40
|
+
const inside = (home: string, dir: string | undefined) => !!dir && dir.startsWith("/") && existsSync(dir) && resolve(dir).startsWith(resolve(home) + sep)
|
|
41
|
+
|
|
42
|
+
export function appDir(home: string, env: Env, platform: string = process.platform): string {
|
|
43
|
+
const own = env.MASTRA_APP_DATA_DIR
|
|
44
|
+
if (own && existsSync(own)) return own
|
|
45
|
+
if (platform === "darwin") return join(home, "Library", "Application Support", "mastracode")
|
|
46
|
+
if (platform === "win32") return join(inside(home, env.APPDATA) ? env.APPDATA! : join(home, "AppData", "Roaming"), "mastracode")
|
|
47
|
+
return join(inside(home, env.XDG_DATA_HOME) ? env.XDG_DATA_HOME! : join(home, ".local", "share"), "mastracode")
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const PROVIDERS_KEY = `customProviders[name=${PROVIDER_NAME}]`
|
|
51
|
+
const PACK_KEY = `customModelPacks[name=${PROVIDER_NAME}]`
|
|
52
|
+
|
|
53
|
+
export function mastraValues(home: string, s: GatewaySettings): Json {
|
|
54
|
+
const model = `${providerSlug(PROVIDER_NAME)}/${s.model}`
|
|
55
|
+
return {
|
|
56
|
+
// Key order as Mastra Code writes a provider back (parseCustomProviders), so its rewrites compare equal.
|
|
57
|
+
[PROVIDERS_KEY]: { name: PROVIDER_NAME, url: `${routeUrl(s)}/v1`, apiKey: readKey(home), models: [s.model] },
|
|
58
|
+
[PACK_KEY]: { name: PROVIDER_NAME, models: { build: model, plan: model, fast: model }, createdAt: CREATED },
|
|
59
|
+
"models/activeModelPackId": `custom:${PROVIDER_NAME}`,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const mastracode: Connector = {
|
|
64
|
+
id: "mastracode",
|
|
65
|
+
label: "Mastra Code",
|
|
66
|
+
file: (home, env) => join(appDir(home, env), "settings.json"),
|
|
67
|
+
// Its app-data folder (the main file's folder, also where mastra.db lives) is what the first run creates.
|
|
68
|
+
probe: [],
|
|
69
|
+
authHeader: "authorization",
|
|
70
|
+
edits: (home, s, env) => [
|
|
71
|
+
{
|
|
72
|
+
path: join(appDir(home, env), "settings.json"),
|
|
73
|
+
format: "custom",
|
|
74
|
+
codec: jsonText({
|
|
75
|
+
jsonc: false,
|
|
76
|
+
mustExist: "Mastra Code has not saved its settings yet; start mastracode once and finish its setup, then connect",
|
|
77
|
+
shape: { customProviders: "array", customModelPacks: "array", models: "object" },
|
|
78
|
+
}),
|
|
79
|
+
holdsKey: true,
|
|
80
|
+
secretKeys: [PROVIDERS_KEY],
|
|
81
|
+
values: mastraValues(home, s),
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
notes: [
|
|
85
|
+
`Mastra Code now uses the "${PROVIDER_NAME}" custom provider for its build, plan and fast modes (the "${PROVIDER_NAME}" model pack). It can only take the key in its settings.json, so the file is now readable by you only (0600).`,
|
|
86
|
+
"Mastra Code cannot send extra headers to a custom provider: context editing applies only when the gateway turns it on for everyone.",
|
|
87
|
+
"Threads you already have keep their own model; observational memory and subagent models keep theirs (/om, /subagents). Picking another pack in /models switches back without disconnecting.",
|
|
88
|
+
],
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const connectors: Connector[] = [mastracode]
|