@100xprompt/brain 0.1.0 → 0.1.2
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 +275 -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,434 @@
|
|
|
1
|
+
// Gateway connector for Kilo CLI (`kilo`, Kilo-Org/kilocode, an OpenCode fork). Checked against Kilo CLI 7.8.28 (the copy
|
|
2
|
+
// in ~/.cache/brain-wire/kilo-code, `kilo --version`), its source at Kilo-Org/kilocode main (package version 7.8.28) and
|
|
3
|
+
// https://kilo.ai/docs/code-with-ai/platforms/cli. The Kilo Code VS Code extension (7.x) runs this same CLI and reads the
|
|
4
|
+
// same file: its connector (kilo-code.ts) is built from the factory below.
|
|
5
|
+
//
|
|
6
|
+
// Where Kilo reads its settings:
|
|
7
|
+
// ~/.config/kilo/ (`kilo debug paths`: config = $XDG_CONFIG_HOME/kilo, else ~/.config/kilo; macOS too). Every one of
|
|
8
|
+
// config.json, kilo.json, kilo.jsonc, opencode.json, opencode.jsonc found there is read and merged, a later one winning
|
|
9
|
+
// (the binary's GLOBAL_CONFIG_FILES list; checked by running `kilo debug config` with a different "model" in each:
|
|
10
|
+
// opencode.jsonc wins, then opencode.json, kilo.jsonc, kilo.json). Each is JSON with comments and trailing commas
|
|
11
|
+
// (jsonc-parser, allowTrailingComma). Kilo writes ~/.config/kilo/kilo.jsonc on its first run ({"$schema": ...}), and its
|
|
12
|
+
// own settings writer (Config.updateGlobal) edits the first of kilo.jsonc, kilo.json, opencode.jsonc, opencode.json,
|
|
13
|
+
// config.json that exists. A project's kilo.json(c), opencode.json(c) or .kilo/ folder outranks all of these.
|
|
14
|
+
// Docs: "Global: ~/.config/kilo/kilo.json[c] or legacy opencode.json[c]"; "Project-level configuration takes precedence".
|
|
15
|
+
//
|
|
16
|
+
// What is written, in the global file with the highest rank that exists (so the model set there is the one Kilo uses; a
|
|
17
|
+
// new kilo.jsonc when there is none), or in the one that already holds brain's provider (so disconnect finds it again):
|
|
18
|
+
// provider.brain-gateway {"npm": "@ai-sdk/openai-compatible", "name": "100xprompt gateway",
|
|
19
|
+
// "options": {"baseURL": "<route>/v1", "apiKey": "{file:<keyPath>}", "headers": {...}},
|
|
20
|
+
// "models": {"<model id>": {"name": "100xprompt Pro"}}}
|
|
21
|
+
// the shape the Kilo Code extension itself saves for a custom provider (kilo-vscode
|
|
22
|
+
// src/provider-actions.ts saveCustomProvider writes provider.<id> to the global config).
|
|
23
|
+
// OpenAI Chat Completions with Authorization: Bearer; the openai-compatible SDK is bundled in
|
|
24
|
+
// the binary (no download). A model list is given, so /v1/models (403 at the gateway) is not
|
|
25
|
+
// needed. options.headers is sent with every model request (x-100x-context-edit: on).
|
|
26
|
+
// model "brain-gateway/<model id>": the default model of `kilo run`, the TUI and new Kilo Code
|
|
27
|
+
// sessions (kilo-vscode provider-actions.ts computeDefaultSelection: config.model first, the
|
|
28
|
+
// VS Code setting kilo-code.new.model.* only when the config names none).
|
|
29
|
+
// The key: {file:<path>} in a global (trusted) config is replaced with that file's text, trimmed, when Kilo loads it
|
|
30
|
+
// (docs: "{env:VAR} (and {file:...}) references are resolved only in trusted config"; the binary's config substitution). So
|
|
31
|
+
// the key is read from brain's key file at each start and never copied. If that file is missing (after `brain gateway
|
|
32
|
+
// logout`), Kilo refuses the whole config ("Configuration is invalid ... bad file reference") until the next sign-in or a
|
|
33
|
+
// disconnect: checked with the binary.
|
|
34
|
+
//
|
|
35
|
+
// Checked end to end with the 7.8.28 binary against the local stand-in gateway (scripts/gateway-real/kilo-cli.ts): the
|
|
36
|
+
// request came in on /flash/v1/chat/completions with "Authorization: Bearer <key>", x-100x-context-edit: on and the
|
|
37
|
+
// gateway's model id.
|
|
38
|
+
//
|
|
39
|
+
// The core's JSON handling refuses comments, and Kilo's files have them, so this file has its own codec (jsoncInPlace):
|
|
40
|
+
// it reads JSON with comments and trailing commas, and changes only the members brain owns, in place; every other byte
|
|
41
|
+
// stays, and a member brain added is removed with exactly the text it brought, so disconnect gives back the exact bytes.
|
|
42
|
+
// Before any change it makes a trial edit and refuses a file where that would not land exactly on brain's members.
|
|
43
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
44
|
+
import { isAbsolute, join, resolve } from "node:path"
|
|
45
|
+
import { type Codec, type Connector, EDIT_HEADER, type Env, getAt, type Json, keyPath, modelName, routeUrl } from "../common.ts"
|
|
46
|
+
|
|
47
|
+
// ---- JSON with comments, edited in place --------------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
interface Member {
|
|
50
|
+
key: string
|
|
51
|
+
keyStart: number
|
|
52
|
+
valueStart: number
|
|
53
|
+
valueEnd: number
|
|
54
|
+
/** The comma after the value (a separator or a trailing comma), or -1. */
|
|
55
|
+
comma: number
|
|
56
|
+
value: Node
|
|
57
|
+
}
|
|
58
|
+
type ObjectNode = { kind: "object"; start: number; end: number; members: Member[] }
|
|
59
|
+
type Node = ObjectNode | { kind: "array" | "value"; start: number; end: number }
|
|
60
|
+
|
|
61
|
+
const WS = /[ \t\n\r\v\f\u00a0\ufeff\u2028\u2029]/
|
|
62
|
+
|
|
63
|
+
/** Positions and value of a JSON-with-comments text (comments, trailing commas; throws on anything else). */
|
|
64
|
+
function scan(text: string): { node: Node; value: unknown } {
|
|
65
|
+
let i = 0
|
|
66
|
+
const fail = (what: string): never => {
|
|
67
|
+
throw new Error(`${what} at offset ${i}`)
|
|
68
|
+
}
|
|
69
|
+
const skip = () => {
|
|
70
|
+
for (;;) {
|
|
71
|
+
if (i < text.length && WS.test(text[i]!)) i++
|
|
72
|
+
else if (text.startsWith("//", i)) {
|
|
73
|
+
const n = text.indexOf("\n", i)
|
|
74
|
+
i = n < 0 ? text.length : n
|
|
75
|
+
} else if (text.startsWith("/*", i)) {
|
|
76
|
+
const n = text.indexOf("*/", i + 2)
|
|
77
|
+
if (n < 0) fail("unclosed comment")
|
|
78
|
+
i = n + 2
|
|
79
|
+
} else return
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const string = (): string => {
|
|
83
|
+
const start = i
|
|
84
|
+
i++
|
|
85
|
+
while (i < text.length && text[i] !== '"') i += text[i] === "\\" ? 2 : 1
|
|
86
|
+
if (i >= text.length) fail("unclosed string")
|
|
87
|
+
i++
|
|
88
|
+
try {
|
|
89
|
+
return JSON.parse(text.slice(start, i)) as string
|
|
90
|
+
} catch {
|
|
91
|
+
return fail("bad string")
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const value = (): { node: Node; value: unknown } => {
|
|
95
|
+
skip()
|
|
96
|
+
const start = i
|
|
97
|
+
const c = text[i]
|
|
98
|
+
if (c === "{") {
|
|
99
|
+
i++
|
|
100
|
+
const members: Member[] = []
|
|
101
|
+
const obj: Json = {}
|
|
102
|
+
for (;;) {
|
|
103
|
+
skip()
|
|
104
|
+
if (text[i] === "}") break
|
|
105
|
+
if (text[i] !== '"') fail("expected a key")
|
|
106
|
+
const keyStart = i
|
|
107
|
+
const key = string()
|
|
108
|
+
skip()
|
|
109
|
+
if (text[i] !== ":") fail("expected ':'")
|
|
110
|
+
i++
|
|
111
|
+
skip()
|
|
112
|
+
const valueStart = i
|
|
113
|
+
const v = value()
|
|
114
|
+
const m: Member = { key, keyStart, valueStart, valueEnd: i, comma: -1, value: v.node }
|
|
115
|
+
members.push(m)
|
|
116
|
+
obj[key] = v.value
|
|
117
|
+
skip()
|
|
118
|
+
if (text[i] === ",") {
|
|
119
|
+
m.comma = i
|
|
120
|
+
i++
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
if (text[i] !== "}") fail("expected ',' or '}'")
|
|
124
|
+
}
|
|
125
|
+
i++
|
|
126
|
+
return { node: { kind: "object", start, end: i, members }, value: obj }
|
|
127
|
+
}
|
|
128
|
+
if (c === "[") {
|
|
129
|
+
i++
|
|
130
|
+
const arr: unknown[] = []
|
|
131
|
+
for (;;) {
|
|
132
|
+
skip()
|
|
133
|
+
if (text[i] === "]") break
|
|
134
|
+
arr.push(value().value)
|
|
135
|
+
skip()
|
|
136
|
+
if (text[i] === ",") {
|
|
137
|
+
i++
|
|
138
|
+
continue
|
|
139
|
+
}
|
|
140
|
+
if (text[i] !== "]") fail("expected ',' or ']'")
|
|
141
|
+
}
|
|
142
|
+
i++
|
|
143
|
+
return { node: { kind: "array", start, end: i }, value: arr }
|
|
144
|
+
}
|
|
145
|
+
if (c === '"') {
|
|
146
|
+
const s = string()
|
|
147
|
+
return { node: { kind: "value", start, end: i }, value: s }
|
|
148
|
+
}
|
|
149
|
+
const lit = /^(?:true|false|null|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)/.exec(text.slice(i, i + 400))
|
|
150
|
+
if (!lit) fail("unexpected character")
|
|
151
|
+
i += lit![0].length
|
|
152
|
+
return { node: { kind: "value", start, end: i }, value: JSON.parse(lit![0]) }
|
|
153
|
+
}
|
|
154
|
+
const out = value()
|
|
155
|
+
skip()
|
|
156
|
+
if (i < text.length) fail("text after the value")
|
|
157
|
+
return out
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const lineStart = (text: string, pos: number) => text.lastIndexOf("\n", pos - 1) + 1
|
|
161
|
+
const indentAt = (text: string, pos: number) => /^[ \t]*/.exec(text.slice(lineStart(text, pos)))![0]
|
|
162
|
+
const startsLine = (text: string, pos: number) => /^[ \t]*$/.test(text.slice(lineStart(text, pos), pos))
|
|
163
|
+
const unitOf = (text: string) => /\n([ \t]+)["/]/.exec(text)?.[1] ?? " "
|
|
164
|
+
const eolOf = (text: string) => (text.includes("\r\n") ? "\r\n" : "\n")
|
|
165
|
+
/** `value` as JSON: on one line inside a one-line object, else spread over lines under `indent`. */
|
|
166
|
+
const render = (text: string, v: unknown, indent: string, oneLine: boolean) =>
|
|
167
|
+
oneLine ? JSON.stringify(v) : JSON.stringify(v, null, unitOf(text)).replace(/\n/g, eolOf(text) + indent)
|
|
168
|
+
/** Where a key appears twice, JSON readers keep the last: that is the one brain reads and changes. */
|
|
169
|
+
const lastIndex = (o: ObjectNode, key: string) => {
|
|
170
|
+
for (let j = o.members.length - 1; j >= 0; j--) if (o.members[j]!.key === key) return j
|
|
171
|
+
return -1
|
|
172
|
+
}
|
|
173
|
+
const lastMember = (o: ObjectNode, key: string) => o.members[lastIndex(o, key)]
|
|
174
|
+
const oneLineObject = (text: string, o: ObjectNode) => !text.slice(o.start, o.end).includes("\n")
|
|
175
|
+
|
|
176
|
+
function insert(text: string, o: ObjectNode, key: string, v: unknown): string {
|
|
177
|
+
const eol = eolOf(text)
|
|
178
|
+
const last = o.members[o.members.length - 1]
|
|
179
|
+
if (!last) {
|
|
180
|
+
const outer = indentAt(text, o.start)
|
|
181
|
+
const inner = outer + unitOf(text)
|
|
182
|
+
const interior = text.slice(o.start + 1, o.end - 1)
|
|
183
|
+
// A file written on one line stays on one line.
|
|
184
|
+
if (!text.trim().includes("\n")) return `${text.slice(0, o.start + 1)}${JSON.stringify(key)}: ${JSON.stringify(v)}${text.slice(o.start + 1)}`
|
|
185
|
+
const member = `${JSON.stringify(key)}: ${render(text, v, inner, false)}`
|
|
186
|
+
// An empty object: `{}` becomes `{\n "key": value\n}`; one holding only comments keeps them after the new member.
|
|
187
|
+
if (/^\s*$/.test(interior)) return `${text.slice(0, o.start + 1)}${eol}${inner}${member}${eol}${outer}${text.slice(o.end - 1)}`
|
|
188
|
+
return `${text.slice(0, o.start + 1)}${eol}${inner}${member}${text.slice(o.start + 1)}`
|
|
189
|
+
}
|
|
190
|
+
const flat = !startsLine(text, last.keyStart) || oneLineObject(text, o)
|
|
191
|
+
const indent = indentAt(text, last.keyStart)
|
|
192
|
+
const member = `${JSON.stringify(key)}: ${render(text, v, indent, flat)}`
|
|
193
|
+
// After a trailing comma the new member keeps that style (`"b": 2,`); otherwise it gets the comma before it. Either
|
|
194
|
+
// way the text added is exactly what remove() takes out again.
|
|
195
|
+
if (last.comma >= 0) return `${text.slice(0, last.comma + 1)}${flat ? " " : eol + indent}${member},${text.slice(last.comma + 1)}`
|
|
196
|
+
return `${text.slice(0, last.valueEnd)},${flat ? " " : eol + indent}${member}${text.slice(last.valueEnd)}`
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function remove(text: string, o: ObjectNode, at: number): string {
|
|
200
|
+
const m = o.members[at]!
|
|
201
|
+
const prev = o.members[at - 1]
|
|
202
|
+
const next = o.members[at + 1]
|
|
203
|
+
const end = m.comma >= 0 ? m.comma + 1 : m.valueEnd
|
|
204
|
+
if (prev) {
|
|
205
|
+
// The last member with a trailing comma: the comma before it stays as the trailing comma of the one before.
|
|
206
|
+
if (!next && m.comma >= 0) return text.slice(0, prev.comma + 1) + text.slice(end)
|
|
207
|
+
return text.slice(0, prev.comma) + text.slice(m.valueEnd)
|
|
208
|
+
}
|
|
209
|
+
if (next) {
|
|
210
|
+
let j = end
|
|
211
|
+
while (j < next.keyStart && WS.test(text[j]!)) j++
|
|
212
|
+
return text.slice(0, m.keyStart) + text.slice(j)
|
|
213
|
+
}
|
|
214
|
+
const before = text.slice(o.start + 1, m.keyStart)
|
|
215
|
+
const after = text.slice(end, o.end - 1)
|
|
216
|
+
if (/^\s*$/.test(before) && /^\s*$/.test(after)) return text.slice(0, o.start + 1) + text.slice(o.end - 1)
|
|
217
|
+
if (/^\s*$/.test(before)) return text.slice(0, o.start + 1) + text.slice(end)
|
|
218
|
+
return text.slice(0, m.keyStart) + text.slice(end)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The object at `path` (each step the last member of that name), or undefined. */
|
|
222
|
+
function objectAt(root: ObjectNode, path: string[]): ObjectNode | undefined {
|
|
223
|
+
let o: ObjectNode = root
|
|
224
|
+
for (const p of path) {
|
|
225
|
+
const m = lastMember(o, p)
|
|
226
|
+
if (!m || m.value.kind !== "object") return undefined
|
|
227
|
+
o = m.value
|
|
228
|
+
}
|
|
229
|
+
return o
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function setPath(text: string, path: string[], v: unknown): string {
|
|
233
|
+
const root = scan(text).node as ObjectNode
|
|
234
|
+
if (v === undefined) {
|
|
235
|
+
const parent = objectAt(root, path.slice(0, -1))
|
|
236
|
+
const at = parent ? lastIndex(parent, path[path.length - 1]!) : -1
|
|
237
|
+
if (!parent || at < 0) return text
|
|
238
|
+
let out = remove(text, parent, at)
|
|
239
|
+
// Objects this left empty (nothing in them, not even a comment) go too, as JSON connectors drop them; never the root.
|
|
240
|
+
for (let d = path.length - 1; d >= 1; d--) {
|
|
241
|
+
const r = scan(out).node as ObjectNode
|
|
242
|
+
const o = objectAt(r, path.slice(0, d))
|
|
243
|
+
const holder = objectAt(r, path.slice(0, d - 1))
|
|
244
|
+
if (!o || !holder || o.members.length || !/^\s*$/.test(out.slice(o.start + 1, o.end - 1))) break
|
|
245
|
+
out = remove(out, holder, lastIndex(holder, path[d - 1]!))
|
|
246
|
+
}
|
|
247
|
+
return out
|
|
248
|
+
}
|
|
249
|
+
let o = root
|
|
250
|
+
for (let d = 0; d < path.length; d++) {
|
|
251
|
+
const m = lastMember(o, path[d]!)
|
|
252
|
+
// What is still missing below this point is added as one nested value.
|
|
253
|
+
const rest = path.slice(d + 1).reduceRight<unknown>((acc, k) => ({ [k]: acc }), v)
|
|
254
|
+
if (d === path.length - 1 || !m || m.value.kind !== "object") {
|
|
255
|
+
if (!m) return insert(text, o, path[d]!, rest)
|
|
256
|
+
const flat = oneLineObject(text, o) || !startsLine(text, m.keyStart)
|
|
257
|
+
return text.slice(0, m.valueStart) + render(text, rest, indentAt(text, m.keyStart), flat) + text.slice(m.valueEnd)
|
|
258
|
+
}
|
|
259
|
+
o = m.value
|
|
260
|
+
}
|
|
261
|
+
return text
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const parse = (text: string): unknown => (text.trim() ? scan(text).value : {})
|
|
265
|
+
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
|
|
266
|
+
/**
|
|
267
|
+
* What `parse` gives after `key` is set to `v`; undefined removes it, and with `dropEmpty` also the objects that leaves
|
|
268
|
+
* empty (setPath keeps one that still holds a comment).
|
|
269
|
+
*/
|
|
270
|
+
function expected(obj: Json, path: string[], v: unknown, dropEmpty = true): Json {
|
|
271
|
+
const o = structuredClone(obj)
|
|
272
|
+
let cur: any = o
|
|
273
|
+
if (v === undefined) {
|
|
274
|
+
const chain: any[] = [o]
|
|
275
|
+
for (const p of path.slice(0, -1)) {
|
|
276
|
+
cur = cur?.[p]
|
|
277
|
+
chain.push(cur)
|
|
278
|
+
}
|
|
279
|
+
if (cur && typeof cur === "object") delete cur[path[path.length - 1]!]
|
|
280
|
+
for (let d = path.length - 1; dropEmpty && d >= 1; d--) {
|
|
281
|
+
const c = chain[d]
|
|
282
|
+
if (c && typeof c === "object" && !Array.isArray(c) && !Object.keys(c).length) delete chain[d - 1][path[d - 1]!]
|
|
283
|
+
else break
|
|
284
|
+
}
|
|
285
|
+
return o
|
|
286
|
+
}
|
|
287
|
+
for (const p of path.slice(0, -1)) {
|
|
288
|
+
if (!cur[p] || typeof cur[p] !== "object" || Array.isArray(cur[p])) cur[p] = {}
|
|
289
|
+
cur = cur[p]
|
|
290
|
+
}
|
|
291
|
+
cur[path[path.length - 1]!] = v
|
|
292
|
+
return o
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* A codec for a JSON file that may hold comments and trailing commas, edited in place: keys are paths with "/"
|
|
297
|
+
* (provider/brain-gateway). `trial` lists keys with a value like the one that will be written: check() refuses a file
|
|
298
|
+
* where setting or removing them would not give exactly the expected result.
|
|
299
|
+
*/
|
|
300
|
+
export function jsoncInPlace(trial: Record<string, unknown> = {}, extraCheck?: (obj: Json) => string | undefined): Codec {
|
|
301
|
+
const codec: Codec = {
|
|
302
|
+
check(text) {
|
|
303
|
+
if (!text.trim()) return undefined
|
|
304
|
+
let obj: unknown
|
|
305
|
+
try {
|
|
306
|
+
obj = scan(text).value
|
|
307
|
+
} catch (e) {
|
|
308
|
+
return `it is not valid JSON with comments (${(e as Error).message}); left as it is`
|
|
309
|
+
}
|
|
310
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return "it is not a JSON object; left as it is"
|
|
311
|
+
const more = extraCheck?.(obj as Json)
|
|
312
|
+
if (more) return more
|
|
313
|
+
// A trial edit of every key brain owns, and its undoing, must land exactly where expected.
|
|
314
|
+
for (const [key, v] of Object.entries(trial)) {
|
|
315
|
+
const path = key.split("/")
|
|
316
|
+
const shown = key.replace(/\//g, ".")
|
|
317
|
+
try {
|
|
318
|
+
const set = setPath(text, path, v)
|
|
319
|
+
const after = parse(set) as Json
|
|
320
|
+
if (!same(after, expected(obj as Json, path, v))) return `brain could not place ${shown} in it safely; left as it is`
|
|
321
|
+
const was = getAt(obj as Json, key)
|
|
322
|
+
const back = parse(setPath(set, path, was))
|
|
323
|
+
const ok = was === undefined ? same(back, expected(after, path, undefined)) || same(back, expected(after, path, undefined, false)) : same(back, obj)
|
|
324
|
+
if (!ok) return `brain could not undo a change to ${shown} in it safely; left as it is`
|
|
325
|
+
} catch {
|
|
326
|
+
return `brain could not edit ${shown} in it safely; left as it is`
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return undefined
|
|
330
|
+
},
|
|
331
|
+
get(text, key) {
|
|
332
|
+
return getAt(parse(text) as Json, key)
|
|
333
|
+
},
|
|
334
|
+
set(text, key, value) {
|
|
335
|
+
const bom = text.startsWith("\ufeff") ? "\ufeff" : ""
|
|
336
|
+
const body = bom ? text.slice(1) : text
|
|
337
|
+
if (!body.trim()) {
|
|
338
|
+
if (value === undefined) return text
|
|
339
|
+
return `${bom}${setPath("{\n}", key.split("/"), value)}\n`
|
|
340
|
+
}
|
|
341
|
+
return bom + setPath(body, key.split("/"), value)
|
|
342
|
+
},
|
|
343
|
+
}
|
|
344
|
+
return codec
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ---- Kilo -------------------------------------------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
/** The global config files, lowest rank first: a later one wins where two set the same key. */
|
|
350
|
+
export const KILO_CONFIG_FILES = ["config.json", "kilo.json", "kilo.jsonc", "opencode.json", "opencode.jsonc"]
|
|
351
|
+
const PROVIDER = "brain-gateway"
|
|
352
|
+
|
|
353
|
+
/** Kilo's global config folder, as Kilo resolves it; $XDG_CONFIG_HOME describes the person's real home, so it counts only there. */
|
|
354
|
+
export function kiloConfigDir(home: string, env: Env): string {
|
|
355
|
+
const own = !!env.HOME && resolve(env.HOME) === resolve(home)
|
|
356
|
+
const x = env.XDG_CONFIG_HOME
|
|
357
|
+
return own && x && isAbsolute(x) ? join(x, "kilo") : join(home, ".config", "kilo")
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function holdsProvider(path: string): boolean {
|
|
361
|
+
try {
|
|
362
|
+
return getAt(parse(readFileSync(path, "utf8")) as Json, `provider/${PROVIDER}`) !== undefined
|
|
363
|
+
} catch {
|
|
364
|
+
return false
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* The file brain edits: the one that already holds brain's provider (connected earlier), else the global file with the
|
|
370
|
+
* highest rank that exists (its "model" is the one Kilo uses), else a new kilo.jsonc (the file Kilo itself creates).
|
|
371
|
+
*/
|
|
372
|
+
export function kiloConfigFile(home: string, env: Env): string {
|
|
373
|
+
const dir = kiloConfigDir(home, env)
|
|
374
|
+
const present = KILO_CONFIG_FILES.map((n) => join(dir, n)).filter((p) => existsSync(p))
|
|
375
|
+
return present.find(holdsProvider) ?? present[present.length - 1] ?? join(dir, "kilo.jsonc")
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* A config that would keep brain's provider from loading: disabled by name, or left out of an allow-list. Asked only
|
|
380
|
+
* before brain's provider is there (a disconnect must still be able to take it out).
|
|
381
|
+
*/
|
|
382
|
+
function providerAllowed(obj: Json): string | undefined {
|
|
383
|
+
if (getAt(obj, `provider/${PROVIDER}`) !== undefined) return undefined
|
|
384
|
+
const off = obj.disabled_providers
|
|
385
|
+
if (Array.isArray(off) && off.includes(PROVIDER)) return `its disabled_providers turns off "${PROVIDER}"; left as it is`
|
|
386
|
+
const on = obj.enabled_providers
|
|
387
|
+
if (Array.isArray(on) && !on.includes(PROVIDER)) return `its enabled_providers allows only the providers it names (not "${PROVIDER}"); add "${PROVIDER}" there to connect`
|
|
388
|
+
return undefined
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const kiloCodec = jsoncInPlace(
|
|
392
|
+
{
|
|
393
|
+
[`provider/${PROVIDER}`]: { npm: "@ai-sdk/openai-compatible", name: "100xprompt gateway", options: { baseURL: "https://x/v1", apiKey: "{file:/x}", headers: { [EDIT_HEADER]: "on" } }, models: { m: { name: "m" } } },
|
|
394
|
+
model: `${PROVIDER}/m`,
|
|
395
|
+
},
|
|
396
|
+
providerAllowed,
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
/** One Kilo connector: Kilo CLI and the Kilo Code extension read the same file, so both are built here. */
|
|
400
|
+
export function kiloConnector(id: string, label: string, notes: string[]): Connector {
|
|
401
|
+
return {
|
|
402
|
+
id,
|
|
403
|
+
label,
|
|
404
|
+
file: kiloConfigFile,
|
|
405
|
+
// Kilo's data folder (sessions, auth.json): made by the CLI and by the extension's bundled CLI on their first run.
|
|
406
|
+
probe: [".local/share/kilo"],
|
|
407
|
+
authHeader: "authorization",
|
|
408
|
+
edits: (home, s, env) => [
|
|
409
|
+
{
|
|
410
|
+
path: kiloConfigFile(home, env),
|
|
411
|
+
format: "custom",
|
|
412
|
+
codec: kiloCodec,
|
|
413
|
+
values: {
|
|
414
|
+
[`provider/${PROVIDER}`]: {
|
|
415
|
+
npm: "@ai-sdk/openai-compatible",
|
|
416
|
+
name: "100xprompt gateway",
|
|
417
|
+
options: { baseURL: `${routeUrl(s)}/v1`, apiKey: `{file:${keyPath(home)}}`, ...(s.contextEdit ? { headers: { [EDIT_HEADER]: "on" } } : {}) },
|
|
418
|
+
models: { [s.model]: { name: modelName(s.model) } },
|
|
419
|
+
},
|
|
420
|
+
model: `${PROVIDER}/${s.model}`,
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
],
|
|
424
|
+
notes,
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export const KILO_SHARED_NOTE =
|
|
429
|
+
"Kilo CLI and the Kilo Code extension (7.x, which runs the same CLI) read this one file: connecting either points both at the gateway, and disconnect puts back what was there before."
|
|
430
|
+
export const KILO_KEY_NOTE =
|
|
431
|
+
"Kilo reads the key from the brain's key file each time it starts ({file:...}); nothing is copied. Without that file (after brain gateway logout) Kilo will not start until you sign in again or disconnect."
|
|
432
|
+
const KILO_PROJECT_NOTE = "A project's own kilo.json(c), opencode.json(c) or .kilo/ config outranks this file there; sessions already open keep the model they started with."
|
|
433
|
+
|
|
434
|
+
export const connectors: Connector[] = [kiloConnector("kilo-cli", "Kilo CLI", [KILO_KEY_NOTE, KILO_SHARED_NOTE, KILO_PROJECT_NOTE])]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Gateway connector for Kilo Code, the VS Code extension (kilocode.kilo-code; also installable in Cursor and Windsurf).
|
|
2
|
+
// Checked against Kilo Code 7.8.28: its source at Kilo-Org/kilocode main (packages/kilo-vscode/package.json version
|
|
3
|
+
// 7.8.28) and the Kilo CLI binary of the same version that the extension bundles (~/.cache/brain-wire/kilo-code, the
|
|
4
|
+
// copy the wire harness drives as the extension's <extension>/bin/kilo).
|
|
5
|
+
//
|
|
6
|
+
// Since 7.x the extension is a client of the Kilo CLI: it starts its bundled `bin/kilo serve` and the agent runs there
|
|
7
|
+
// (https://kilo.ai/docs/contributing/architecture/vscode-extension: "Agent runtime behavior belongs in CLI config so TUI,
|
|
8
|
+
// Console, VS Code, and JetBrains can share it"). Providers, models and keys therefore live in the Kilo CLI's global
|
|
9
|
+
// config, ~/.config/kilo/kilo.jsonc and its siblings; the extension's own "Custom provider" dialog writes provider.<id>
|
|
10
|
+
// there too (kilo-vscode src/provider-actions.ts saveCustomProvider -> client.global.config.update). Its VS Code settings
|
|
11
|
+
// are only kilo-code.new.* (UI, autocomplete, and a fallback default model); 7.8.28 has no autoImportSettingsPath. A new
|
|
12
|
+
// session starts on the config's "model" (provider-actions.ts computeDefaultSelection: config.model, then the VS Code
|
|
13
|
+
// setting kilo-code.new.model.providerID/modelID, then Kilo's own auto model; KiloProvider.ts fetchAndSendProviders).
|
|
14
|
+
//
|
|
15
|
+
// So this connector edits exactly what the Kilo CLI connector edits (kilo-cli.ts: provider.brain-gateway reading the key
|
|
16
|
+
// from brain's key file with {file:...}, and model), in the same file: nothing goes into VS Code's settings, state or
|
|
17
|
+
// keychain, and disconnect gives back the file's exact bytes. Connecting one of the two also points the other at the
|
|
18
|
+
// gateway; connecting both is fine (each records what it found and puts that back).
|
|
19
|
+
//
|
|
20
|
+
// Older Kilo Code releases (the Roo Code-based extension, before 7) keep providers in VS Code's own storage and keys in its
|
|
21
|
+
// keychain, like Roo Code (see roo.ts for why brain does not write those); they do not create ~/.config/kilo, so connect
|
|
22
|
+
// refuses there ("has not been run on this machine").
|
|
23
|
+
import type { Connector } from "../common.ts"
|
|
24
|
+
import { KILO_KEY_NOTE, KILO_SHARED_NOTE, kiloConnector } from "./kilo-cli.ts"
|
|
25
|
+
|
|
26
|
+
export const connectors: Connector[] = [
|
|
27
|
+
kiloConnector("kilo-code", "Kilo Code", [
|
|
28
|
+
"Kilo Code 7 runs the Kilo CLI it bundles, which reads this file: nothing goes into VS Code's settings or keychain. New chats start on the gateway's model (this file's model comes before the extension's default-model setting); open chats keep theirs.",
|
|
29
|
+
KILO_KEY_NOTE,
|
|
30
|
+
KILO_SHARED_NOTE,
|
|
31
|
+
]),
|
|
32
|
+
]
|