@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,579 @@
|
|
|
1
|
+
// Gateway connector for Continue (continuedev/continue): its VS Code and JetBrains extensions and its `cn` CLI, which share
|
|
2
|
+
// one settings folder. Checked against the `cn` CLI 1.5.47 (~/.cache/brain-wire/continue, @continuedev/cli, its src/ is
|
|
3
|
+
// shipped in the package) and the VS Code extension 2.0.0 (continue.continue-2.0.0-darwin-arm64, out/extension.js).
|
|
4
|
+
//
|
|
5
|
+
// Where Continue reads its settings:
|
|
6
|
+
// <dir> = $CONTINUE_GLOBAL_DIR, else ~/.continue cli src/env.ts:10-11; extension getContinueGlobalPath
|
|
7
|
+
// <dir>/config.yaml the CLI's local config: used whenever it exists, else the CLI loads Continue's hosted default
|
|
8
|
+
// config (cli src/configLoader.ts:94-99). The extension uses it too, and creates it with
|
|
9
|
+
// `name: Main Config / version: 1.0.0 / schema: v1 / models: []` when neither file exists
|
|
10
|
+
// (extension.js getConfigYamlPath ~56261, defaultConfig ~54871).
|
|
11
|
+
// <dir>/config.json the older format. The extension still loads it when there is no config.yaml (extension.js
|
|
12
|
+
// ~799466-799496: config.yaml if it exists, else loadContinueConfigFromJson); the CLI ignores it.
|
|
13
|
+
// A model is an entry of the `models:` list (config-yaml schema, extension.js ~222352): name, provider, model, apiBase,
|
|
14
|
+
// apiKey, roles, capabilities, defaultCompletionOptions.contextLength, requestOptions.headers (~222227: a string record,
|
|
15
|
+
// sent with every request). provider `openai` speaks Chat Completions at <apiBase>/chat/completions.
|
|
16
|
+
//
|
|
17
|
+
// The key. Continue cannot read a key from a file or a command: apiKey is a string, and `${{ secrets.NAME }}` in it is
|
|
18
|
+
// filled at load time from, in order, <dir>/.env, <workspace>/.continue/.env, <workspace>/.env and the process
|
|
19
|
+
// environment (extension: LocalPlatformClient ~798790-798870, getContinueDotEnv ~56467; CLI: src/CLIPlatformClient.ts:57-87,
|
|
20
|
+
// process environment first; template filling: fillTemplateVariables/renderTemplateData ~223583-223700). config.json does
|
|
21
|
+
// the same with its top-level `"env": ["NAME"]` list, which replaces every "NAME" string in the file with the value from
|
|
22
|
+
// <dir>/.env or the environment (extension.js resolveSerializedConfig ~797703). So brain copies the key to <dir>/.env as
|
|
23
|
+
// BRAIN_GATEWAY_KEY (that file is made 0600, and refreshed on a new sign-in), and the config only names it: config.yaml
|
|
24
|
+
// stays free of the key, as Continue's docs recommend for secrets.
|
|
25
|
+
//
|
|
26
|
+
// What is written:
|
|
27
|
+
// <dir>/.env BRAIN_GATEWAY_KEY=<key>
|
|
28
|
+
// config.yaml one entry first in `models:`, found again by its apiKey naming BRAIN_GATEWAY_KEY:
|
|
29
|
+
// name: 100xprompt Pro, provider: openai, model: <gateway id>, apiBase: <route>/v1,
|
|
30
|
+
// apiKey: ${{ secrets.BRAIN_GATEWAY_KEY }}, roles chat/edit/apply/summarize,
|
|
31
|
+
// capabilities tool_use and image_input (Continue only guesses tool support for provider openai from
|
|
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.
|
|
34
|
+
// First in the list makes it the default: the extension and the CLI take the first chat model unless
|
|
35
|
+
// one was picked before (extension rectifySelectedModelsFromGlobalContext ~798703; CLI
|
|
36
|
+
// services/ModelService.ts:53-120 and util/modelPersistence.ts, `cliSelectedModel`).
|
|
37
|
+
// A config.yaml brain creates also gets name/version/schema, which Continue requires; they are
|
|
38
|
+
// removed again only when nothing else is left in it.
|
|
39
|
+
// config.json (only when it is the file Continue uses) the same entry with `title`, its apiKey "BRAIN_GATEWAY_KEY",
|
|
40
|
+
// and "BRAIN_GATEWAY_KEY" added to the top-level "env" list.
|
|
41
|
+
// The two codecs below edit only that list entry (and the env list item) in place, as text: comments and every other byte
|
|
42
|
+
// stay, and removing the entry gives back the exact bytes, including an empty `models: []` or a list that was not there.
|
|
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, type GatewaySettings, type Json, modelName, readKey, routeUrl, yamlTopLevel } from "../common.ts"
|
|
46
|
+
|
|
47
|
+
/** The name the key is kept under in the tool's .env, and that its config refers to. */
|
|
48
|
+
export const SECRET = "BRAIN_GATEWAY_KEY"
|
|
49
|
+
/** The `name` of a config.yaml that brain created (Continue requires one). */
|
|
50
|
+
const OWN_CONFIG = "100xprompt gateway"
|
|
51
|
+
/** 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
|
+
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
|
+
|
|
56
|
+
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
|
|
57
|
+
const isPlainObject = (v: unknown): v is Json => !!v && typeof v === "object" && !Array.isArray(v)
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The list keys a codec understands: `<list>/<label>` is the one item of top-level list <list> for which `match` holds.
|
|
61
|
+
* get returns that item; when there is none, undefined if the list is there and null if it is not (so that removing the
|
|
62
|
+
* item later, with that value, also removes a list brain added). The YAML codec also returns [] for an empty `models: []`.
|
|
63
|
+
*/
|
|
64
|
+
export interface ListKey {
|
|
65
|
+
list: string
|
|
66
|
+
match(item: unknown): boolean
|
|
67
|
+
/** JSON only: write the list on one line (a list of short strings). */
|
|
68
|
+
inline?: boolean
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---- YAML: block lists ------------------------------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
const YAML11_WORDS = /^(y|yes|n|no|on|off|true|false|null|~)$/i
|
|
74
|
+
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
75
|
+
|
|
76
|
+
/** A string as YAML reads it back unchanged: plain when that is safe, else JSON (valid YAML). */
|
|
77
|
+
function yScalar(v: unknown, flow = false): string {
|
|
78
|
+
if (typeof v === "string") {
|
|
79
|
+
if (/^[A-Za-z0-9_./@-][A-Za-z0-9_./:@+=-]*$/.test(v) && !YAML11_WORDS.test(v) && !(flow && /[,[\]{}]/.test(v))) {
|
|
80
|
+
try {
|
|
81
|
+
if ((Bun.YAML.parse(`k: ${v}`) as Json).k === v) return v
|
|
82
|
+
} catch {}
|
|
83
|
+
}
|
|
84
|
+
return JSON.stringify(v)
|
|
85
|
+
}
|
|
86
|
+
if (typeof v === "number" || typeof v === "boolean") return String(v)
|
|
87
|
+
return JSON.stringify(v ?? null)
|
|
88
|
+
}
|
|
89
|
+
const yKey = (k: string) => (/^[A-Za-z_][A-Za-z0-9_.-]*$/.test(k) && !YAML11_WORDS.test(k) ? k : JSON.stringify(k))
|
|
90
|
+
|
|
91
|
+
/** An object as block-style YAML lines at `indent` (lists on one line, objects nested). */
|
|
92
|
+
function yBlock(o: Json, indent: string): string[] {
|
|
93
|
+
const out: string[] = []
|
|
94
|
+
for (const [k, v] of Object.entries(o)) {
|
|
95
|
+
if (v === undefined) continue
|
|
96
|
+
if (Array.isArray(v)) out.push(`${indent}${yKey(k)}: [${v.map((x) => yScalar(x, true)).join(", ")}]`)
|
|
97
|
+
else if (isPlainObject(v)) {
|
|
98
|
+
if (!Object.keys(v).length) out.push(`${indent}${yKey(k)}: {}`)
|
|
99
|
+
else out.push(`${indent}${yKey(k)}:`, ...yBlock(v, `${indent} `))
|
|
100
|
+
} else out.push(`${indent}${yKey(k)}: ${yScalar(v)}`)
|
|
101
|
+
}
|
|
102
|
+
return out
|
|
103
|
+
}
|
|
104
|
+
/** A list item: `<indent>- first: ...` and the rest under it. */
|
|
105
|
+
function yItem(o: Json, indent: string): string[] {
|
|
106
|
+
const lines = yBlock(o, `${indent} `)
|
|
107
|
+
if (!lines.length) return [`${indent}- {}`]
|
|
108
|
+
lines[0] = `${indent}- ${lines[0]!.slice(indent.length + 2)}`
|
|
109
|
+
return lines
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Where the ` # comment` at the end of a `key: value` line starts (with the spaces before it, outside quotes), or -1. */
|
|
113
|
+
function yCommentAt(line: string): number {
|
|
114
|
+
let quote: string | undefined
|
|
115
|
+
for (let i = line.indexOf(":") + 1; i > 0 && i < line.length; i++) {
|
|
116
|
+
const c = line[i]!
|
|
117
|
+
if (quote) {
|
|
118
|
+
if (c === "\\" && quote === '"') i++
|
|
119
|
+
else if (c === quote) quote = undefined
|
|
120
|
+
} else if (c === '"' || c === "'") quote = c
|
|
121
|
+
else if (c === "#" && /[ \t]/.test(line[i - 1]!)) {
|
|
122
|
+
let j = i
|
|
123
|
+
while (j > 0 && /[ \t]/.test(line[j - 1]!)) j--
|
|
124
|
+
return j
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return -1
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface YList {
|
|
131
|
+
/** The line of `<list>:`, or -1. */
|
|
132
|
+
at: number
|
|
133
|
+
/** The key as written (`models`, `"models"`). */
|
|
134
|
+
key: string
|
|
135
|
+
/** block: items (or nothing) below the key; empty: `models: []`; other: anything else on the key line. */
|
|
136
|
+
form: "block" | "empty" | "other"
|
|
137
|
+
comment: string
|
|
138
|
+
/** Each item's lines [start, end): from its `-` line to its last content line (trailing blank and comment lines excluded). */
|
|
139
|
+
items: Array<{ start: number; end: number; indent: string }>
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const content = (l: string) => l.trim() !== "" && !/^\s*#/.test(l)
|
|
143
|
+
|
|
144
|
+
function yLocate(lines: string[], list: string): YList {
|
|
145
|
+
const head = new RegExp(`^(?:${esc(list)}|"${esc(list)}"|'${esc(list)}')[ \\t]*:(?=[ \\t]|$)`)
|
|
146
|
+
const at = lines.findIndex((l) => head.test(l))
|
|
147
|
+
if (at < 0) return { at, key: list, form: "block", comment: "", items: [] }
|
|
148
|
+
const line = lines[at]!
|
|
149
|
+
const colon = line.indexOf(":", line.startsWith('"') || line.startsWith("'") ? list.length + 2 : 0)
|
|
150
|
+
const key = line.slice(0, colon).trimEnd()
|
|
151
|
+
const j = yCommentAt(line)
|
|
152
|
+
const comment = j < 0 ? "" : line.slice(j).trimEnd()
|
|
153
|
+
const value = line.slice(colon + 1, j < 0 ? undefined : j).trim()
|
|
154
|
+
if (value === "[]") return { at, key, form: "empty", comment, items: [] }
|
|
155
|
+
if (value !== "") return { at, key, form: "other", comment, items: [] }
|
|
156
|
+
// The block: indented lines, `-` lines (a list may sit at the key's own indentation), blank lines and comments.
|
|
157
|
+
let end = at + 1
|
|
158
|
+
while (end < lines.length && (/^[ \t]/.test(lines[end]!) || lines[end]!.trim() === "" || /^-(\s|$)/.test(lines[end]!) || /^#/.test(lines[end]!))) end++
|
|
159
|
+
while (end > at + 1 && !content(lines[end - 1]!)) end--
|
|
160
|
+
const first = lines.slice(at + 1, end).find(content)
|
|
161
|
+
const m = first === undefined ? null : /^([ \t]*)-(?:[ \t]|$)/.exec(first)
|
|
162
|
+
if (first !== undefined && !m) return { at, key, form: "other", comment, items: [] }
|
|
163
|
+
const indent = m?.[1] ?? " "
|
|
164
|
+
const starts: number[] = []
|
|
165
|
+
for (let i = at + 1; i < end; i++) if (new RegExp(`^${esc(indent)}-(?:[ \\t]|$)`).test(lines[i]!)) starts.push(i)
|
|
166
|
+
const items = starts.map((s, k) => {
|
|
167
|
+
let e = k + 1 < starts.length ? starts[k + 1]! : end
|
|
168
|
+
while (e > s + 1 && !content(lines[e - 1]!)) e--
|
|
169
|
+
return { start: s, end: e, indent }
|
|
170
|
+
})
|
|
171
|
+
return { at, key, form: "block", comment, items }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const yParse = (text: string): Json | null => {
|
|
175
|
+
if (!text.trim()) return null
|
|
176
|
+
const v = Bun.YAML.parse(text)
|
|
177
|
+
return isPlainObject(v) ? v : null
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Continue's config.yaml: `<list>/<label>` keys (see ListKey) edit one item of a block list, every other key is a top-level
|
|
182
|
+
* key (yamlTopLevel). Items are added first in the list, at its indentation; nothing else in the file changes.
|
|
183
|
+
*/
|
|
184
|
+
export function yamlListCodec(keys: Record<string, ListKey>): Codec {
|
|
185
|
+
return {
|
|
186
|
+
check(text) {
|
|
187
|
+
const base = yamlTopLevel.check!(text)
|
|
188
|
+
if (base) return base
|
|
189
|
+
const doc = yParse(text)
|
|
190
|
+
if (!doc) return undefined
|
|
191
|
+
const lines = text.replace(/\n$/, "").split("\n")
|
|
192
|
+
for (const list of new Set(Object.values(keys).map((k) => k.list))) {
|
|
193
|
+
if (!(list in doc)) continue
|
|
194
|
+
const v = doc[list]
|
|
195
|
+
if (v !== null && !Array.isArray(v)) return `its ${list} is not a list; left as it is`
|
|
196
|
+
const loc = yLocate(lines, list)
|
|
197
|
+
if (loc.at < 0 || loc.form === "other") return `its ${list} list is written in a form brain does not edit (only a block list of "- " entries, or []); left as it is`
|
|
198
|
+
if (loc.form === "block" && loc.items.length !== (v?.length ?? 0)) return `its ${list} list is laid out in a way brain does not edit; left as it is`
|
|
199
|
+
}
|
|
200
|
+
return undefined
|
|
201
|
+
},
|
|
202
|
+
get(text, key) {
|
|
203
|
+
const k = keys[key]
|
|
204
|
+
if (!k) return yamlTopLevel.get(text, key)
|
|
205
|
+
const doc = yParse(text)
|
|
206
|
+
if (!doc || !(k.list in doc)) return null
|
|
207
|
+
const v = doc[k.list]
|
|
208
|
+
if (!Array.isArray(v)) return undefined
|
|
209
|
+
const found = v.find((x) => k.match(x))
|
|
210
|
+
return found !== undefined ? found : v.length ? undefined : []
|
|
211
|
+
},
|
|
212
|
+
set(text, key, value) {
|
|
213
|
+
const k = keys[key]
|
|
214
|
+
if (!k) return same(yamlTopLevel.get(text, key), value) ? text : yamlTopLevel.set(text, key, value)
|
|
215
|
+
const lines = text ? text.replace(/\n$/, "").split("\n") : []
|
|
216
|
+
const doc = yParse(text)
|
|
217
|
+
const v = doc?.[k.list]
|
|
218
|
+
const idx = Array.isArray(v) ? v.findIndex((x) => k.match(x)) : -1
|
|
219
|
+
const loc = yLocate(lines, k.list)
|
|
220
|
+
if (isPlainObject(value)) {
|
|
221
|
+
if (idx >= 0) {
|
|
222
|
+
const it = loc.items[idx]!
|
|
223
|
+
lines.splice(it.start, it.end - it.start, ...yItem(value, it.indent))
|
|
224
|
+
} else if (loc.at < 0) lines.push(`${k.list}:`, ...yItem(value, " "))
|
|
225
|
+
else if (loc.form === "empty") lines.splice(loc.at, 1, `${loc.key}:${loc.comment}`, ...yItem(value, " "))
|
|
226
|
+
else if (loc.items.length) lines.splice(loc.items[0]!.start, 0, ...yItem(value, loc.items[0]!.indent))
|
|
227
|
+
else lines.splice(loc.at + 1, 0, ...yItem(value, " "))
|
|
228
|
+
} else {
|
|
229
|
+
// undefined: the item goes, the list stays; [] the list goes back to `[]`; null: a list left empty goes too.
|
|
230
|
+
if (idx >= 0) {
|
|
231
|
+
const it = loc.items[idx]!
|
|
232
|
+
lines.splice(it.start, it.end - it.start)
|
|
233
|
+
}
|
|
234
|
+
const after = yLocate(lines, k.list)
|
|
235
|
+
if (after.at >= 0 && after.form === "block" && !after.items.length) {
|
|
236
|
+
if (value === null) lines.splice(after.at, 1)
|
|
237
|
+
else if (Array.isArray(value) && !value.length) lines[after.at] = `${after.key}: []${after.comment}`
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return lines.length ? `${lines.join("\n")}\n` : ""
|
|
241
|
+
},
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---- JSON with comments: lists edited as text ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
type JNode =
|
|
248
|
+
| { kind: "obj"; start: number; end: number; members: Array<{ key: string; start: number; value: JNode; comma: number }> }
|
|
249
|
+
| { kind: "arr"; start: number; end: number; elems: Array<{ value: JNode; comma: number }> }
|
|
250
|
+
| { kind: "lit"; start: number; end: number; value: unknown }
|
|
251
|
+
|
|
252
|
+
/** JSON with // and /* comments and trailing commas, with the position of every value (start, end exclusive) and comma. */
|
|
253
|
+
export function jsoncTree(text: string): JNode {
|
|
254
|
+
let i = 0
|
|
255
|
+
const fail = (): never => {
|
|
256
|
+
throw new Error(`not JSON (at character ${i})`)
|
|
257
|
+
}
|
|
258
|
+
const ws = () => {
|
|
259
|
+
for (;;) {
|
|
260
|
+
while (i < text.length && /\s/.test(text[i]!)) i++
|
|
261
|
+
if (text.startsWith("//", i)) {
|
|
262
|
+
const n = text.indexOf("\n", i)
|
|
263
|
+
i = n < 0 ? text.length : n + 1
|
|
264
|
+
} else if (text.startsWith("/*", i)) {
|
|
265
|
+
const n = text.indexOf("*/", i + 2)
|
|
266
|
+
if (n < 0) fail()
|
|
267
|
+
i = n + 2
|
|
268
|
+
} else return
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const str = (): string => {
|
|
272
|
+
const s = i++
|
|
273
|
+
while (i < text.length && text[i] !== '"') {
|
|
274
|
+
if (text[i] === "\\") i++
|
|
275
|
+
else if (text[i] === "\n") fail()
|
|
276
|
+
i++
|
|
277
|
+
}
|
|
278
|
+
if (i >= text.length) fail()
|
|
279
|
+
i++
|
|
280
|
+
return JSON.parse(text.slice(s, i))
|
|
281
|
+
}
|
|
282
|
+
const val = (): JNode => {
|
|
283
|
+
ws()
|
|
284
|
+
const s = i
|
|
285
|
+
const c = text[i]
|
|
286
|
+
if (c === "{") {
|
|
287
|
+
i++
|
|
288
|
+
const members: Array<{ key: string; start: number; value: JNode; comma: number }> = []
|
|
289
|
+
for (;;) {
|
|
290
|
+
ws()
|
|
291
|
+
if (text[i] === "}") break
|
|
292
|
+
if (text[i] !== '"') fail()
|
|
293
|
+
const ks = i
|
|
294
|
+
const key = str()
|
|
295
|
+
ws()
|
|
296
|
+
if (text[i] !== ":") fail()
|
|
297
|
+
i++
|
|
298
|
+
const value = val()
|
|
299
|
+
ws()
|
|
300
|
+
if (text[i] === ",") {
|
|
301
|
+
members.push({ key, start: ks, value, comma: i++ })
|
|
302
|
+
continue
|
|
303
|
+
}
|
|
304
|
+
members.push({ key, start: ks, value, comma: -1 })
|
|
305
|
+
if (text[i] !== "}") fail()
|
|
306
|
+
break
|
|
307
|
+
}
|
|
308
|
+
i++
|
|
309
|
+
return { kind: "obj", start: s, end: i, members }
|
|
310
|
+
}
|
|
311
|
+
if (c === "[") {
|
|
312
|
+
i++
|
|
313
|
+
const elems: Array<{ value: JNode; comma: number }> = []
|
|
314
|
+
for (;;) {
|
|
315
|
+
ws()
|
|
316
|
+
if (text[i] === "]") break
|
|
317
|
+
const value = val()
|
|
318
|
+
ws()
|
|
319
|
+
if (text[i] === ",") {
|
|
320
|
+
elems.push({ value, comma: i++ })
|
|
321
|
+
continue
|
|
322
|
+
}
|
|
323
|
+
elems.push({ value, comma: -1 })
|
|
324
|
+
if (text[i] !== "]") fail()
|
|
325
|
+
break
|
|
326
|
+
}
|
|
327
|
+
i++
|
|
328
|
+
return { kind: "arr", start: s, end: i, elems }
|
|
329
|
+
}
|
|
330
|
+
if (c === '"') {
|
|
331
|
+
const value = str()
|
|
332
|
+
return { kind: "lit", start: s, end: i, value }
|
|
333
|
+
}
|
|
334
|
+
const re = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null/y
|
|
335
|
+
re.lastIndex = i
|
|
336
|
+
const m = re.exec(text)
|
|
337
|
+
if (!m) fail()
|
|
338
|
+
i += m![0].length
|
|
339
|
+
return { kind: "lit", start: s, end: i, value: JSON.parse(m![0]) }
|
|
340
|
+
}
|
|
341
|
+
const root = val()
|
|
342
|
+
ws()
|
|
343
|
+
if (i !== text.length) fail()
|
|
344
|
+
return root
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function jsonValue(n: JNode): unknown {
|
|
348
|
+
if (n.kind === "lit") return n.value
|
|
349
|
+
if (n.kind === "arr") return n.elems.map((e) => jsonValue(e.value))
|
|
350
|
+
return Object.fromEntries(n.members.map((m) => [m.key, jsonValue(m.value)]))
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
type JList = Extract<JNode, { kind: "obj" | "arr" }>
|
|
354
|
+
const spans = (n: JList) =>
|
|
355
|
+
n.kind === "arr" ? n.elems.map((e) => ({ start: e.value.start, end: e.value.end, comma: e.comma })) : n.members.map((m) => ({ start: m.start, end: m.value.end, comma: m.comma }))
|
|
356
|
+
const lineIndent = (text: string, pos: number) => /^[ \t]*/.exec(text.slice(text.lastIndexOf("\n", pos - 1) + 1))![0]
|
|
357
|
+
|
|
358
|
+
/** `item(indent)` added as the first item of an array or object; removing it again (jRemove) gives back the same bytes. */
|
|
359
|
+
function jInsertFront(text: string, n: JList, item: (indent: string) => string, unit: string, inline = false): string {
|
|
360
|
+
const items = spans(n)
|
|
361
|
+
const open = n.start
|
|
362
|
+
const close = n.end - 1
|
|
363
|
+
const base = lineIndent(text, open)
|
|
364
|
+
if (!items.length) {
|
|
365
|
+
if (/^\s*$/.test(text.slice(open + 1, close))) {
|
|
366
|
+
if (inline) return text.slice(0, open + 1) + item("") + text.slice(close)
|
|
367
|
+
return `${text.slice(0, open + 1)}\n${base}${unit}${item(base + unit)}\n${base}${text.slice(close)}`
|
|
368
|
+
}
|
|
369
|
+
// Only comments inside: the item goes right after the bracket, before them.
|
|
370
|
+
return `${text.slice(0, open + 1)}\n${base}${unit}${item(base + unit)}${text.slice(open + 1)}`
|
|
371
|
+
}
|
|
372
|
+
const first = items[0]!.start
|
|
373
|
+
const multi = text.slice(open + 1, first).includes("\n")
|
|
374
|
+
const ind = multi ? lineIndent(text, first) : base + unit
|
|
375
|
+
return `${text.slice(0, first)}${item(ind)},${multi ? `\n${ind}` : " "}${text.slice(first)}`
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** The text without item `idx` of an array or object (and the separator that went with it). */
|
|
379
|
+
function jRemove(text: string, n: JList, idx: number): string {
|
|
380
|
+
const items = spans(n)
|
|
381
|
+
const it = items[idx]!
|
|
382
|
+
const open = n.start
|
|
383
|
+
const close = n.end - 1
|
|
384
|
+
if (items.length === 1) {
|
|
385
|
+
if (/^\s*$/.test(text.slice(open + 1, it.start)) && /^\s*,?\s*$/.test(text.slice(it.end, close))) return text.slice(0, open + 1) + text.slice(close)
|
|
386
|
+
return text.slice(0, open + 1) + text.slice(it.end)
|
|
387
|
+
}
|
|
388
|
+
if (idx === 0) return text.slice(0, it.start) + text.slice(items[1]!.start)
|
|
389
|
+
return text.slice(0, items[idx - 1]!.comma) + text.slice(it.end)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const jUnit = (text: string) => /\n([ \t]+)\S/.exec(text)?.[1] ?? " "
|
|
393
|
+
const lastMember = (root: Extract<JNode, { kind: "obj" }>, key: string) => {
|
|
394
|
+
for (let i = root.members.length - 1; i >= 0; i--) if (root.members[i]!.key === key) return i
|
|
395
|
+
return -1
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* A JSON config that may hold comments (Continue's config.json, PearAI's), edited as text: `<list>/<label>` keys (see
|
|
400
|
+
* ListKey) add, replace or remove one item of a top-level list. Comments and formatting everywhere else stay.
|
|
401
|
+
*/
|
|
402
|
+
export function jsonListCodec(keys: Record<string, ListKey>): Codec {
|
|
403
|
+
const root = (text: string) => {
|
|
404
|
+
const t = jsoncTree(text.trim() ? text : "{}")
|
|
405
|
+
if (t.kind !== "obj") throw new Error("not a JSON object")
|
|
406
|
+
return t
|
|
407
|
+
}
|
|
408
|
+
return {
|
|
409
|
+
check(text) {
|
|
410
|
+
let r: Extract<JNode, { kind: "obj" }>
|
|
411
|
+
try {
|
|
412
|
+
r = root(text)
|
|
413
|
+
} catch {
|
|
414
|
+
return "it is not a JSON object (with or without comments); left as it is"
|
|
415
|
+
}
|
|
416
|
+
for (const list of new Set(Object.values(keys).map((k) => k.list))) {
|
|
417
|
+
const i = lastMember(r, list)
|
|
418
|
+
if (i >= 0 && r.members[i]!.value.kind !== "arr") return `its "${list}" is not a list; left as it is`
|
|
419
|
+
}
|
|
420
|
+
return undefined
|
|
421
|
+
},
|
|
422
|
+
get(text, key) {
|
|
423
|
+
const k = keys[key]
|
|
424
|
+
if (!k) return undefined
|
|
425
|
+
const o = jsonValue(root(text)) as Json
|
|
426
|
+
if (!(k.list in o)) return null
|
|
427
|
+
const v = o[k.list]
|
|
428
|
+
if (!Array.isArray(v)) return undefined
|
|
429
|
+
const found = v.find((x) => k.match(x))
|
|
430
|
+
return found !== undefined ? found : undefined
|
|
431
|
+
},
|
|
432
|
+
set(text, key, value) {
|
|
433
|
+
const k = keys[key]
|
|
434
|
+
if (!k) return text
|
|
435
|
+
const base = text.trim() ? text : "{}\n"
|
|
436
|
+
const r = root(base)
|
|
437
|
+
const unit = jUnit(base)
|
|
438
|
+
const mi = lastMember(r, k.list)
|
|
439
|
+
const arr = mi >= 0 && r.members[mi]!.value.kind === "arr" ? (r.members[mi]!.value as Extract<JNode, { kind: "arr" }>) : undefined
|
|
440
|
+
const ei = arr ? arr.elems.findIndex((e) => k.match(jsonValue(e.value))) : -1
|
|
441
|
+
const render = (ind: string) => JSON.stringify(value, null, unit).replace(/\n/g, `\n${ind}`)
|
|
442
|
+
if (value === undefined || value === null) {
|
|
443
|
+
// undefined: the item goes, the list stays; null (there was no list): a list left empty goes too.
|
|
444
|
+
if (!arr) return text
|
|
445
|
+
let t = ei >= 0 ? jRemove(base, arr, ei) : base
|
|
446
|
+
if (value === null) {
|
|
447
|
+
const r2 = root(t)
|
|
448
|
+
const m2 = lastMember(r2, k.list)
|
|
449
|
+
const a2 = m2 >= 0 ? r2.members[m2]!.value : undefined
|
|
450
|
+
if (a2?.kind === "arr" && !a2.elems.length && /^\s*$/.test(t.slice(a2.start + 1, a2.end - 1))) t = jRemove(t, r2, m2)
|
|
451
|
+
}
|
|
452
|
+
return t
|
|
453
|
+
}
|
|
454
|
+
if (mi < 0) {
|
|
455
|
+
const list = (ind: string) => `${JSON.stringify(k.list)}: ${k.inline ? `[${render("")}]` : `[\n${ind}${unit}${render(ind + unit)}\n${ind}]`}`
|
|
456
|
+
return jInsertFront(base, r, list, unit)
|
|
457
|
+
}
|
|
458
|
+
if (!arr) return text
|
|
459
|
+
if (ei >= 0) {
|
|
460
|
+
const el = arr.elems[ei]!.value
|
|
461
|
+
return base.slice(0, el.start) + render(lineIndent(base, el.start)) + base.slice(el.end)
|
|
462
|
+
}
|
|
463
|
+
return jInsertFront(base, arr, render, unit, k.inline)
|
|
464
|
+
},
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ---- the connector ------------------------------------------------------------------------------------------------------
|
|
469
|
+
|
|
470
|
+
/** Continue's settings folder; $CONTINUE_GLOBAL_DIR describes the person's real home, so it counts only there. */
|
|
471
|
+
export function continueDir(home: string, env: Env): string {
|
|
472
|
+
const own = !!env.HOME && resolve(env.HOME) === resolve(home)
|
|
473
|
+
const d = env.CONTINUE_GLOBAL_DIR
|
|
474
|
+
return own && d && isAbsolute(d) && existsSync(d) ? d : join(home, ".continue")
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** The config Continue loads: config.yaml, or the older config.json when that is the only one. */
|
|
478
|
+
export function continueConfig(dir: string): { path: string; kind: "yaml" | "json" } {
|
|
479
|
+
const yaml = join(dir, "config.yaml")
|
|
480
|
+
const json = join(dir, "config.json")
|
|
481
|
+
return !existsSync(yaml) && existsSync(json) ? { path: json, kind: "json" } : { path: yaml, kind: "yaml" }
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const mentionsSecret = (x: unknown) => isPlainObject(x) && typeof x.apiKey === "string" && x.apiKey.includes(SECRET)
|
|
485
|
+
const yamlList = yamlListCodec({ [ENTRY_KEY]: { list: "models", match: mentionsSecret } })
|
|
486
|
+
/**
|
|
487
|
+
* The codec key for the name/version/schema a config.yaml needs (Continue refuses one without them), set only in a
|
|
488
|
+
* config.yaml brain creates. Written at the top; removed only when nothing else is left in the file, so a model the person
|
|
489
|
+
* added to that file meanwhile keeps a config Continue can load.
|
|
490
|
+
*/
|
|
491
|
+
export const HEADER_KEY = "name+version+schema"
|
|
492
|
+
const HEADER = ["name", "version", "schema"]
|
|
493
|
+
const headerOf = (text: string) => {
|
|
494
|
+
const doc = yParse(text)
|
|
495
|
+
const present = doc ? HEADER.filter((f) => f in doc) : []
|
|
496
|
+
return present.length ? Object.fromEntries(present.map((f) => [f, doc![f]])) : undefined
|
|
497
|
+
}
|
|
498
|
+
const withoutHeader = (text: string) => HEADER.reduce((t, f) => yamlTopLevel.set(t, f, undefined), text)
|
|
499
|
+
/** Continue's config.yaml: brain's models entry (found by its apiKey) and, in a config brain created, the header. */
|
|
500
|
+
export const yamlCodec: Codec = {
|
|
501
|
+
check: yamlList.check!,
|
|
502
|
+
get: (text, key) => (key === HEADER_KEY ? headerOf(text) : yamlList.get(text, key)),
|
|
503
|
+
set(text, key, value) {
|
|
504
|
+
if (key !== HEADER_KEY) return yamlList.set(text, key, value)
|
|
505
|
+
if (isPlainObject(value)) {
|
|
506
|
+
if (same(headerOf(text), value)) return text
|
|
507
|
+
const rest = withoutHeader(text)
|
|
508
|
+
return `${[...Object.entries(value).map(([k, v]) => `${k}: ${yScalar(v)}`), ...(rest ? [rest.replace(/\n$/, "")] : [])].join("\n")}\n`
|
|
509
|
+
}
|
|
510
|
+
const doc = yParse(text)
|
|
511
|
+
return doc && Object.keys(doc).every((k) => HEADER.includes(k)) ? withoutHeader(text) : text
|
|
512
|
+
},
|
|
513
|
+
}
|
|
514
|
+
/** Continue's and PearAI's config.json: the entry (its apiKey is the env name) and the env list item. */
|
|
515
|
+
export const jsonCodec = jsonListCodec({
|
|
516
|
+
[ENTRY_KEY]: { list: "models", match: (x) => isPlainObject(x) && x.apiKey === SECRET },
|
|
517
|
+
[`env/${SECRET}`]: { list: "env", match: (x) => x === SECRET, inline: true },
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
const headers = (s: GatewaySettings) => (s.contextEdit ? { requestOptions: { headers: { [EDIT_HEADER]: "on" } } } : {})
|
|
521
|
+
|
|
522
|
+
/** The models entry in config.json form (older Continue, PearAI): `title`, the key by env name. */
|
|
523
|
+
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) }
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** The .env edit both Continue and PearAI read their secret from. */
|
|
528
|
+
export const envEdit = (dir: string, home: string) => ({ path: join(dir, ".env"), format: "dotenv" as const, holdsKey: true, secretKeys: [SECRET], values: { [SECRET]: readKey(home) } })
|
|
529
|
+
|
|
530
|
+
/** Whether config.yaml is missing, empty, or one brain created (it then needs, and keeps, name/version/schema). */
|
|
531
|
+
function ownYaml(path: string): boolean {
|
|
532
|
+
try {
|
|
533
|
+
const text = readFileSync(path, "utf8")
|
|
534
|
+
return !text.trim() || yParse(text)?.name === OWN_CONFIG
|
|
535
|
+
} catch {
|
|
536
|
+
return true
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
export const connectors: Connector[] = [
|
|
541
|
+
{
|
|
542
|
+
id: "continue",
|
|
543
|
+
label: "Continue",
|
|
544
|
+
file: (home, env) => continueConfig(continueDir(home, env)).path,
|
|
545
|
+
probe: [".continue"],
|
|
546
|
+
authHeader: "authorization",
|
|
547
|
+
edits: (home, s, env) => {
|
|
548
|
+
const dir = continueDir(home, env)
|
|
549
|
+
const cfg = continueConfig(dir)
|
|
550
|
+
if (cfg.kind === "json")
|
|
551
|
+
return [envEdit(dir, home), { path: cfg.path, format: "custom", codec: jsonCodec, values: { [ENTRY_KEY]: jsonEntry(s, { capabilities: { tools: true, uploadImage: true } }), [`env/${SECRET}`]: SECRET } }]
|
|
552
|
+
const entry = {
|
|
553
|
+
name: modelName(s.model),
|
|
554
|
+
provider: "openai",
|
|
555
|
+
model: s.model,
|
|
556
|
+
apiBase: `${routeUrl(s)}/v1`,
|
|
557
|
+
apiKey: `\${{ secrets.${SECRET} }}`,
|
|
558
|
+
roles: ["chat", "edit", "apply", "summarize"],
|
|
559
|
+
capabilities: ["tool_use", "image_input"],
|
|
560
|
+
defaultCompletionOptions: { contextLength: CONTEXT },
|
|
561
|
+
...headers(s),
|
|
562
|
+
}
|
|
563
|
+
return [
|
|
564
|
+
envEdit(dir, home),
|
|
565
|
+
{
|
|
566
|
+
path: cfg.path,
|
|
567
|
+
format: "custom",
|
|
568
|
+
codec: yamlCodec,
|
|
569
|
+
values: { [ENTRY_KEY]: entry, ...(ownYaml(cfg.path) ? { [HEADER_KEY]: { name: OWN_CONFIG, version: "1.0.0", schema: "v1" } } : {}) },
|
|
570
|
+
},
|
|
571
|
+
]
|
|
572
|
+
},
|
|
573
|
+
notes: [
|
|
574
|
+
"Continue (its extensions and the cn CLI) now lists the gateway's model first in its config; it is the default unless you picked another model in Continue before: then pick it once in Continue's model menu.",
|
|
575
|
+
`Continue cannot read a key from another file, so the key is kept in ~/.continue/.env as ${SECRET} (readable by you only), and the config refers to it.`,
|
|
576
|
+
"With only the older config.json (no config.yaml) the extensions use the gateway; the cn CLI reads config.yaml only.",
|
|
577
|
+
],
|
|
578
|
+
},
|
|
579
|
+
]
|