@100xprompt/brain 0.0.0-stage → 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/LICENSE +48 -0
- package/README.md +235 -2
- package/package.json +48 -4
- package/src/adapters/aider/index.ts +8 -0
- package/src/adapters/aider/reader.ts +129 -0
- package/src/adapters/aider/records.ts +220 -0
- package/src/adapters/aider/writer.ts +269 -0
- package/src/adapters/amazon-q-cli/index.ts +8 -0
- package/src/adapters/amazon-q-cli/reader.ts +84 -0
- package/src/adapters/amazon-q-cli/records.ts +191 -0
- package/src/adapters/amazon-q-cli/writer.ts +370 -0
- package/src/adapters/amazon-q-ide/index.ts +9 -0
- package/src/adapters/amazon-q-ide/reader.ts +131 -0
- package/src/adapters/amazon-q-ide/records.ts +141 -0
- package/src/adapters/amazon-q-ide/writer.ts +325 -0
- package/src/adapters/antigravity/index.ts +9 -0
- package/src/adapters/antigravity/proto.ts +194 -0
- package/src/adapters/antigravity/reader.ts +424 -0
- package/src/adapters/antigravity/records.ts +236 -0
- package/src/adapters/antigravity/writer.ts +463 -0
- package/src/adapters/auggie/index.ts +36 -0
- package/src/adapters/claude-code/index.ts +8 -0
- package/src/adapters/claude-code/reader.ts +639 -0
- package/src/adapters/claude-code/records.ts +88 -0
- package/src/adapters/claude-code/writer.ts +329 -0
- package/src/adapters/cline/index.ts +12 -0
- package/src/adapters/cline/reader.ts +432 -0
- package/src/adapters/cline/records.ts +151 -0
- package/src/adapters/cline/vscdb.ts +56 -0
- package/src/adapters/cline/writer.ts +656 -0
- package/src/adapters/cline/xml.ts +177 -0
- package/src/adapters/cline4/index.ts +9 -0
- package/src/adapters/cline4/reader.ts +179 -0
- package/src/adapters/cline4/records.ts +92 -0
- package/src/adapters/cline4/writer.ts +361 -0
- package/src/adapters/codebuff/index.ts +8 -0
- package/src/adapters/codebuff/project.ts +207 -0
- package/src/adapters/codebuff/reader.ts +215 -0
- package/src/adapters/codebuff/records.ts +173 -0
- package/src/adapters/codebuff/writer.ts +383 -0
- package/src/adapters/codex/index.ts +8 -0
- package/src/adapters/codex/native.ts +239 -0
- package/src/adapters/codex/reader.ts +747 -0
- package/src/adapters/codex/records.ts +114 -0
- package/src/adapters/codex/writer.ts +479 -0
- package/src/adapters/continue/index.ts +9 -0
- package/src/adapters/continue/reader.ts +68 -0
- package/src/adapters/continue/records.ts +418 -0
- package/src/adapters/continue/writer.ts +162 -0
- package/src/adapters/copilot-cli/index.ts +9 -0
- package/src/adapters/copilot-cli/reader.ts +186 -0
- package/src/adapters/copilot-cli/records.ts +241 -0
- package/src/adapters/copilot-cli/writer.ts +353 -0
- package/src/adapters/crush/index.ts +8 -0
- package/src/adapters/crush/reader.ts +180 -0
- package/src/adapters/crush/records.ts +169 -0
- package/src/adapters/crush/writer.ts +333 -0
- package/src/adapters/cursor-cli/index.ts +405 -0
- package/src/adapters/cursor-ide/agentkv.ts +95 -0
- package/src/adapters/cursor-ide/index.ts +501 -0
- package/src/adapters/cursor-ide/transcripts.ts +122 -0
- package/src/adapters/cursor-ide/workspaces.ts +119 -0
- package/src/adapters/cursor-ide/writer.ts +170 -0
- package/src/adapters/devin/index.ts +8 -0
- package/src/adapters/devin/reader.ts +305 -0
- package/src/adapters/devin/records.ts +277 -0
- package/src/adapters/devin/writer.ts +523 -0
- package/src/adapters/factory-droid/index.ts +12 -0
- package/src/adapters/factory-droid/reader.ts +184 -0
- package/src/adapters/factory-droid/records.ts +79 -0
- package/src/adapters/factory-droid/writer.ts +350 -0
- package/src/adapters/gemini-cli/exchanges.ts +228 -0
- package/src/adapters/gemini-cli/index.ts +396 -0
- package/src/adapters/gemini-cli/records.ts +521 -0
- package/src/adapters/gemini-cli/writer.ts +354 -0
- package/src/adapters/goose/index.ts +8 -0
- package/src/adapters/goose/reader.ts +138 -0
- package/src/adapters/goose/records.ts +199 -0
- package/src/adapters/goose/writer.ts +340 -0
- package/src/adapters/grok-cli/image.ts +44 -0
- package/src/adapters/grok-cli/index.ts +8 -0
- package/src/adapters/grok-cli/reader.ts +302 -0
- package/src/adapters/grok-cli/records.ts +172 -0
- package/src/adapters/grok-cli/writer.ts +329 -0
- package/src/adapters/hermes/index.ts +8 -0
- package/src/adapters/hermes/reader.ts +249 -0
- package/src/adapters/hermes/records.ts +285 -0
- package/src/adapters/hermes/writer.ts +452 -0
- package/src/adapters/iflow-cli/index.ts +72 -0
- package/src/adapters/iflow-cli/records.ts +215 -0
- package/src/adapters/iflow-cli/writer.ts +204 -0
- package/src/adapters/index.ts +112 -0
- package/src/adapters/kilo-cli/index.ts +21 -0
- package/src/adapters/kilo-code/index.ts +11 -0
- package/src/adapters/kilo-code/writer.ts +92 -0
- package/src/adapters/kimi/index.ts +9 -0
- package/src/adapters/kimi/reader.ts +92 -0
- package/src/adapters/kimi/records.ts +221 -0
- package/src/adapters/kimi/writer.ts +367 -0
- package/src/adapters/kiro-cli/index.ts +8 -0
- package/src/adapters/kiro-cli/reader.ts +87 -0
- package/src/adapters/kiro-cli/records.ts +260 -0
- package/src/adapters/kiro-cli/writer.ts +305 -0
- package/src/adapters/kiro-ide/index.ts +8 -0
- package/src/adapters/kiro-ide/writer.ts +52 -0
- package/src/adapters/letta/index.ts +8 -0
- package/src/adapters/letta/reader.ts +177 -0
- package/src/adapters/letta/records.ts +119 -0
- package/src/adapters/letta/writer.ts +413 -0
- package/src/adapters/mastracode/index.ts +8 -0
- package/src/adapters/mastracode/reader.ts +323 -0
- package/src/adapters/mastracode/records.ts +336 -0
- package/src/adapters/mastracode/writer.ts +385 -0
- package/src/adapters/mistral-vibe/index.ts +9 -0
- package/src/adapters/mistral-vibe/reader.ts +62 -0
- package/src/adapters/mistral-vibe/records.ts +104 -0
- package/src/adapters/mistral-vibe/writer.ts +359 -0
- package/src/adapters/muse/index.ts +10 -0
- package/src/adapters/muse/reader.ts +332 -0
- package/src/adapters/muse/records.ts +104 -0
- package/src/adapters/muse/writer.ts +387 -0
- package/src/adapters/omp/index.ts +13 -0
- package/src/adapters/omp/reader.ts +314 -0
- package/src/adapters/omp/records.ts +153 -0
- package/src/adapters/omp/writer.ts +216 -0
- package/src/adapters/opencode/ids.ts +52 -0
- package/src/adapters/opencode/index.ts +31 -0
- package/src/adapters/opencode/note.ts +8 -0
- package/src/adapters/opencode/reader.ts +452 -0
- package/src/adapters/opencode/tools.ts +174 -0
- package/src/adapters/opencode/v2.ts +367 -0
- package/src/adapters/opencode/writer.ts +379 -0
- package/src/adapters/openhands/index.ts +8 -0
- package/src/adapters/openhands/reader.ts +222 -0
- package/src/adapters/openhands/records.ts +136 -0
- package/src/adapters/openhands/writer.ts +404 -0
- package/src/adapters/pearai/index.ts +10 -0
- package/src/adapters/pi/index.ts +28 -0
- package/src/adapters/pi/reader.ts +261 -0
- package/src/adapters/pi/records.ts +99 -0
- package/src/adapters/pi/writer.ts +362 -0
- package/src/adapters/qoder-cli/index.ts +8 -0
- package/src/adapters/qoder-cli/reader.ts +83 -0
- package/src/adapters/qoder-cli/records.ts +45 -0
- package/src/adapters/qoder-cli/writer.ts +72 -0
- package/src/adapters/qwen-code/index.ts +84 -0
- package/src/adapters/qwen-code/records.ts +194 -0
- package/src/adapters/qwen-code/writer.ts +224 -0
- package/src/adapters/tabnine-cli/index.ts +8 -0
- package/src/adapters/tabnine-cli/reader.ts +106 -0
- package/src/adapters/tabnine-cli/records.ts +33 -0
- package/src/adapters/tabnine-cli/writer.ts +156 -0
- package/src/adapters/types.ts +73 -0
- package/src/adapters/void/index.ts +9 -0
- package/src/adapters/void/reader.ts +48 -0
- package/src/adapters/void/records.ts +285 -0
- package/src/adapters/void/writer.ts +93 -0
- package/src/adapters/vscode-copilot/index.ts +9 -0
- package/src/adapters/vscode-copilot/reader.ts +212 -0
- package/src/adapters/vscode-copilot/records.ts +388 -0
- package/src/adapters/vscode-copilot/shared.ts +68 -0
- package/src/adapters/vscode-copilot/writer.ts +546 -0
- package/src/adapters/zed/index.ts +8 -0
- package/src/adapters/zed/reader.ts +198 -0
- package/src/adapters/zed/records.ts +87 -0
- package/src/adapters/zed/writer.ts +341 -0
- package/src/brief-writers.ts +71 -0
- package/src/brief.ts +238 -0
- package/src/cli.ts +351 -0
- 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/fsutil.ts +133 -0
- package/src/gateway/common.ts +232 -0
- package/src/gateway/connect.ts +847 -0
- 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
- package/src/gateway/signin.ts +323 -0
- package/src/handoff.ts +104 -0
- package/src/hooks.ts +455 -0
- package/src/memory/audit.ts +514 -0
- package/src/memory/blobs.ts +94 -0
- package/src/memory/cli.ts +117 -0
- package/src/memory/distill.ts +381 -0
- package/src/memory/externalize.ts +435 -0
- package/src/memory/lookups.ts +70 -0
- package/src/memory/mcp-install.ts +401 -0
- package/src/memory/mcp-server.ts +296 -0
- package/src/memory/prepare.ts +179 -0
- package/src/memory/preview.ts +154 -0
- package/src/memory/project.ts +35 -0
- package/src/memory/redact.ts +72 -0
- package/src/memory/relevance.ts +187 -0
- package/src/memory/repomap.ts +516 -0
- package/src/memory/store.ts +270 -0
- package/src/memory/sync.ts +166 -0
- package/src/memory/tidy.ts +193 -0
- package/src/memory/types.ts +118 -0
- package/src/org/admin-ops.ts +96 -0
- package/src/org/auth.ts +112 -0
- package/src/org/backup.ts +115 -0
- package/src/org/cli.ts +438 -0
- package/src/org/client.ts +404 -0
- package/src/org/config.ts +44 -0
- package/src/org/crypto.ts +537 -0
- package/src/org/db.ts +435 -0
- package/src/org/graph.ts +318 -0
- package/src/org/oidc.ts +435 -0
- package/src/org/policy.ts +398 -0
- package/src/org/server.ts +920 -0
- package/src/org/sso.ts +316 -0
- package/src/org/tls.ts +66 -0
- package/src/org/types.ts +193 -0
- package/src/self.ts +68 -0
- package/src/session/attachments.ts +53 -0
- package/src/session/peekcache.ts +50 -0
- package/src/session/types.ts +104 -0
- package/src/session/util.ts +130 -0
- package/src/sqlite.ts +48 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// Gateway connector for Oh My Pi (can1357/oh-my-pi, `omp`, a fork of Pi). Checked against the source at tag v18.18.6 (the
|
|
2
|
+
// latest release, 2026-11-09) and its docs; brain's session adapter was checked against 18.0.5. omp is not installed on this
|
|
3
|
+
// machine (wire/drivers/f.ts), so there is no real-tool check for it.
|
|
4
|
+
//
|
|
5
|
+
// Where omp reads things: the agent dir ~/.omp/agent, or ~/.omp/profiles/<name>/agent for OMP_PROFILE (PI_PROFILE as the
|
|
6
|
+
// fallback), or $PI_CODING_AGENT_DIR for the default profile; PI_CONFIG_DIR renames .omp (packages/utils/src/dirs.ts:29-33,
|
|
7
|
+
// 84-135, 331-339, 367-374).
|
|
8
|
+
// - models.yml (else models.yaml) `providers.<id>`: baseUrl, api ("openai-completions", ...), apiKey, headers, compat,
|
|
9
|
+
// models[{id, name, input}] (docs/models.md "Config file location", "Provider-level fields";
|
|
10
|
+
// packages/coding-agent/src/config/models-config.ts:44-88 requires baseUrl and apiKey for a provider with models).
|
|
11
|
+
// Without either YAML file a legacy models.json is migrated to models.yml on the next start
|
|
12
|
+
// (src/config/config-file.ts:30-56, 173-179): brain writing models.yml first would make omp skip that migration and drop
|
|
13
|
+
// the person's providers, so that case is refused until omp has run once.
|
|
14
|
+
// - apiKey and header values starting with "!" are shell commands whose trimmed stdout is used, at request time
|
|
15
|
+
// (src/config/resolve-config-value.ts:22-24, 104-108; docs/models.md "Command-resolved secrets"): the key is read from
|
|
16
|
+
// brain's key file with `!cat '<key file>'`, never copied. Plain header values are literals (or an env var of that name).
|
|
17
|
+
// - config.yml (else config.yaml) `modelRoles.default: <provider>/<model>` is the main session model; unset smol/slow
|
|
18
|
+
// follow it (docs/models.md "Role aliases and settings", "Initial model selection priority";
|
|
19
|
+
// src/config/settings.ts:2228-2237, 2658-2670; MAIN_CONFIG_FILENAMES in utils/src/dirs.ts:32). Without either file omp
|
|
20
|
+
// first migrates a legacy settings.json (settings.ts:2885-2945), so that case is refused the same way.
|
|
21
|
+
// The request is OpenAI Chat Completions on <route>/v1 with Authorization: Bearer (the provider's apiKey), plus
|
|
22
|
+
// `x-100x-context-edit: on`. Both YAML files are edited by the codec below: one entry under one top-level mapping, every other
|
|
23
|
+
// line kept as it was, so disconnect gives the exact bytes back.
|
|
24
|
+
import { existsSync } from "node:fs"
|
|
25
|
+
import { dirname, join } from "node:path"
|
|
26
|
+
import { type Codec, type Connector, dirFromEnv, EDIT_HEADER, type Env, type GatewaySettings, type Json, modelName, routeUrl, yamlTopLevel } from "../common.ts"
|
|
27
|
+
import { CHAT_COMPAT, keyCommand, PROVIDER_ID } from "./pi.ts"
|
|
28
|
+
|
|
29
|
+
// ---- YAML: one entry of one top-level mapping, every other line as it was -----------------------------------------------
|
|
30
|
+
|
|
31
|
+
const isObj = (v: unknown): v is Json => !!v && typeof v === "object" && !Array.isArray(v)
|
|
32
|
+
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
33
|
+
const quietLine = (l: string) => !l.trim() || /^\s*#/.test(l)
|
|
34
|
+
const indentOf = (l: string) => /^ */.exec(l)![0].length
|
|
35
|
+
const keyLine = (k: string, indent: number) => new RegExp(`^ {${indent}}(?:${esc(k)}|"${esc(k)}"|'${esc(k)}')[ \\t]*:(?:[ \\t]|$)`)
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A scalar as YAML: a plain word when YAML reads it back as the same string (and an older YAML would not read it as a
|
|
39
|
+
* boolean, as it would `on`), otherwise JSON (valid YAML).
|
|
40
|
+
*/
|
|
41
|
+
function scalar(v: unknown): string {
|
|
42
|
+
if (typeof v === "string" && /^[A-Za-z0-9_./@-][A-Za-z0-9_./:@+=-]*$/.test(v) && !/^(y|n|yes|no|on|off)$/i.test(v)) {
|
|
43
|
+
try {
|
|
44
|
+
if ((Bun.YAML.parse(`k: ${v}`) as Json).k === v) return v
|
|
45
|
+
} catch {}
|
|
46
|
+
}
|
|
47
|
+
return JSON.stringify(v)
|
|
48
|
+
}
|
|
49
|
+
const leaf = (v: unknown) => (isObj(v) ? "{}" : Array.isArray(v) ? "[]" : scalar(v))
|
|
50
|
+
|
|
51
|
+
/** `key: value` in block style at `indent`, nested one `unit` deeper. */
|
|
52
|
+
function block(key: string, v: unknown, indent: string, unit: string): string[] {
|
|
53
|
+
const head = `${indent}${scalar(key)}:`
|
|
54
|
+
if (isObj(v) && Object.keys(v).length) return [head, ...Object.entries(v).flatMap(([k, x]) => block(k, x, indent + unit, unit))]
|
|
55
|
+
if (Array.isArray(v) && v.length) return [head, ...v.flatMap((x) => item(x, indent + unit, unit))]
|
|
56
|
+
return [`${head} ${leaf(v)}`]
|
|
57
|
+
}
|
|
58
|
+
function item(x: unknown, indent: string, unit: string): string[] {
|
|
59
|
+
if (isObj(x) && Object.keys(x).length) {
|
|
60
|
+
const lines = Object.entries(x).flatMap(([k, y]) => block(k, y, `${indent} `, unit))
|
|
61
|
+
return [`${indent}- ${lines[0]!.slice(indent.length + 2)}`, ...lines.slice(1)]
|
|
62
|
+
}
|
|
63
|
+
return [`${indent}- ${Array.isArray(x) && x.length ? JSON.stringify(x) : leaf(x)}`]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The ` # comment` ending a one-line entry (a # after a space, outside quotes), with the spaces before it. */
|
|
67
|
+
function trailing(line: string): string {
|
|
68
|
+
let quote: string | undefined
|
|
69
|
+
for (let i = line.indexOf(":") + 1; i > 0 && i < line.length; i++) {
|
|
70
|
+
const c = line[i]!
|
|
71
|
+
if (quote) {
|
|
72
|
+
if (c === "\\" && quote === '"') i++
|
|
73
|
+
else if (c === quote) quote = undefined
|
|
74
|
+
} else if (c === '"' || c === "'") quote = c
|
|
75
|
+
else if (c === "#" && /[ \t]/.test(line[i - 1]!)) {
|
|
76
|
+
let j = i
|
|
77
|
+
while (j > 0 && /[ \t]/.test(line[j - 1]!)) j--
|
|
78
|
+
return line.slice(j).trimEnd()
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return ""
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface Lines {
|
|
85
|
+
lines: string[]
|
|
86
|
+
/** Whether the text ended with a newline (kept as it was). */
|
|
87
|
+
nl: boolean
|
|
88
|
+
}
|
|
89
|
+
const toLines = (t: string): Lines => ({ lines: t ? t.replace(/\n$/, "").split("\n") : [], nl: !t || t.endsWith("\n") })
|
|
90
|
+
const fromLines = (d: Lines) => (d.lines.length ? `${d.lines.join("\n")}${d.nl ? "\n" : ""}` : "")
|
|
91
|
+
|
|
92
|
+
/** The top-level `parent:` line and the end (exclusive) of its last indented line, or undefined. */
|
|
93
|
+
function parentAt(lines: string[], parent: string): { at: number; end: number } | undefined {
|
|
94
|
+
const re = keyLine(parent, 0)
|
|
95
|
+
const at = lines.findIndex((l) => re.test(l))
|
|
96
|
+
if (at < 0) return undefined
|
|
97
|
+
let end = at + 1
|
|
98
|
+
for (let j = at + 1; j < lines.length; j++) {
|
|
99
|
+
const l = lines[j]!
|
|
100
|
+
if (quietLine(l)) continue
|
|
101
|
+
if (!/^[ \t]/.test(l)) break
|
|
102
|
+
end = j + 1
|
|
103
|
+
}
|
|
104
|
+
return { at, end }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** What follows `parent:` on its line, without a trailing comment. */
|
|
108
|
+
const parentValue = (line: string, parent: string) => {
|
|
109
|
+
const m = keyLine(parent, 0).exec(line)!
|
|
110
|
+
const rest = line.slice(m[0].length)
|
|
111
|
+
const c = trailing(line)
|
|
112
|
+
return (c ? rest.slice(0, rest.length - c.length) : rest).trim()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The child entries' indentation and the child `key`'s lines [at, end) inside the parent's block (at -1: not there). */
|
|
116
|
+
function childAt(lines: string[], p: { at: number; end: number }, key: string): { indent?: number; at: number; end: number } {
|
|
117
|
+
const content: number[] = []
|
|
118
|
+
for (let j = p.at + 1; j < p.end; j++) if (!quietLine(lines[j]!)) content.push(j)
|
|
119
|
+
if (!content.length) return { at: -1, end: -1 }
|
|
120
|
+
const indent = indentOf(lines[content[0]!]!)
|
|
121
|
+
const re = keyLine(key, indent)
|
|
122
|
+
const at = content.find((j) => re.test(lines[j]!)) ?? -1
|
|
123
|
+
if (at < 0) return { indent, at, end: -1 }
|
|
124
|
+
let end = at + 1
|
|
125
|
+
for (let j = at + 1; j < p.end; j++) {
|
|
126
|
+
const l = lines[j]!
|
|
127
|
+
if (quietLine(l)) continue
|
|
128
|
+
if (indentOf(l) > indent || (indentOf(l) === indent && /^ *- /.test(l))) end = j + 1
|
|
129
|
+
else break
|
|
130
|
+
}
|
|
131
|
+
return { indent, at, end }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The indentation step: the smallest indentation of any indented line, else two spaces. */
|
|
135
|
+
function unitOf(lines: string[]): number {
|
|
136
|
+
const ins = lines.filter((l) => !quietLine(l) && /^ +\S/.test(l)).map(indentOf)
|
|
137
|
+
return ins.length ? Math.min(...ins) : 2
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A Codec for one entry of one top-level mapping of a YAML file: keys are `<parent>/<child>` (providers/brain-gateway,
|
|
142
|
+
* modelRoles/default). The entry is written in block style in the file's own indentation; a parent that is not there is
|
|
143
|
+
* added at the end, and removed again when the removal leaves it empty. A parent written as a one-line {...} mapping is
|
|
144
|
+
* rewritten as JSON on that line (`{}` comes back as `{}`). `beforeFirstWrite` may refuse a missing or empty file.
|
|
145
|
+
*/
|
|
146
|
+
export function yamlEntry(parent: string, beforeFirstWrite?: () => string | undefined): Codec {
|
|
147
|
+
const split = (key: string) => {
|
|
148
|
+
const [p, ...rest] = key.split("/")
|
|
149
|
+
if (p !== parent || rest.length !== 1) throw new Error(`${key}: this file's editor only handles ${parent}/<name>`)
|
|
150
|
+
return rest[0]!
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
check(text) {
|
|
154
|
+
if (!text.trim()) return beforeFirstWrite?.()
|
|
155
|
+
const base = yamlTopLevel.check!(text)
|
|
156
|
+
if (base) return base
|
|
157
|
+
if (text.includes("\r")) return "it has Windows (CRLF) line endings; left as it is"
|
|
158
|
+
const { lines } = toLines(text)
|
|
159
|
+
const first = lines.find((l) => !quietLine(l) && !/^(---|%)/.test(l))
|
|
160
|
+
if (first && /^[{[]/.test(first.trim())) return "it is written as one {...} mapping; left as it is"
|
|
161
|
+
const re = keyLine(parent, 0)
|
|
162
|
+
const ats = lines.flatMap((l, j) => (re.test(l) ? [j] : []))
|
|
163
|
+
if (ats.length > 1) return `it names ${parent} more than once; left as it is`
|
|
164
|
+
const doc = (Bun.YAML.parse(text) ?? {}) as Json
|
|
165
|
+
if (!ats.length) return Object.hasOwn(doc, parent) ? `its ${parent} entry is written in a way brain does not edit; left as it is` : undefined
|
|
166
|
+
const v = parentValue(lines[ats[0]!]!, parent)
|
|
167
|
+
const value = doc[parent]
|
|
168
|
+
if (v === "") {
|
|
169
|
+
if (value !== null && value !== undefined && !isObj(value)) return `its ${parent} is not a mapping; left as it is`
|
|
170
|
+
const p = parentAt(lines, parent)!
|
|
171
|
+
const content = lines.slice(p.at + 1, p.end).filter((l) => !quietLine(l))
|
|
172
|
+
if (content.length && (/^ *- /.test(content[0]!) || content.some((l) => indentOf(l) < indentOf(content[0]!)))) return `its ${parent} block is indented in a way brain does not edit; left as it is`
|
|
173
|
+
return undefined
|
|
174
|
+
}
|
|
175
|
+
if (!/^\{.*\}$/.test(v) || !isObj(value)) return `its ${parent} is not a plain mapping; left as it is`
|
|
176
|
+
return undefined
|
|
177
|
+
},
|
|
178
|
+
get(text, key) {
|
|
179
|
+
const child = split(key)
|
|
180
|
+
if (!text.trim()) return undefined
|
|
181
|
+
const doc = Bun.YAML.parse(text) as Json | null
|
|
182
|
+
const p = isObj(doc) ? doc[parent] : undefined
|
|
183
|
+
return isObj(p) && Object.hasOwn(p, child) ? p[child] : undefined
|
|
184
|
+
},
|
|
185
|
+
set(text, key, value) {
|
|
186
|
+
const child = split(key)
|
|
187
|
+
const d = toLines(text)
|
|
188
|
+
const { lines } = d
|
|
189
|
+
const unit = " ".repeat(unitOf(lines))
|
|
190
|
+
const p = parentAt(lines, parent)
|
|
191
|
+
if (!p) {
|
|
192
|
+
if (value === undefined) return text
|
|
193
|
+
lines.push(`${parent}:`, ...block(child, value, unit, unit))
|
|
194
|
+
return fromLines(d)
|
|
195
|
+
}
|
|
196
|
+
const v = parentValue(lines[p.at]!, parent)
|
|
197
|
+
if (v !== "") {
|
|
198
|
+
// A one-line {...} mapping: change it as data, write it back as JSON on the same line.
|
|
199
|
+
const map = { ...((Bun.YAML.parse(`k: ${v}`) as Json).k as Json) }
|
|
200
|
+
if (value === undefined) delete map[child]
|
|
201
|
+
else map[child] = value
|
|
202
|
+
const head = keyLine(parent, 0).exec(lines[p.at]!)![0].replace(/[ \t]*$/, "")
|
|
203
|
+
lines[p.at] = `${head} ${Object.keys(map).length ? JSON.stringify(map) : "{}"}${trailing(lines[p.at]!)}`
|
|
204
|
+
return fromLines(d)
|
|
205
|
+
}
|
|
206
|
+
const c = childAt(lines, p, child)
|
|
207
|
+
const indent = " ".repeat(c.indent ?? unit.length)
|
|
208
|
+
if (c.at < 0) {
|
|
209
|
+
if (value === undefined) return text
|
|
210
|
+
lines.splice(p.end, 0, ...block(child, value, indent, unit))
|
|
211
|
+
return fromLines(d)
|
|
212
|
+
}
|
|
213
|
+
if (value !== undefined) {
|
|
214
|
+
const repl = block(child, value, indent, unit)
|
|
215
|
+
// A one-line entry keeps its trailing comment, so a later restore gives the line back as it was.
|
|
216
|
+
if (c.end === c.at + 1 && repl.length === 1) repl[0] += trailing(lines[c.at]!)
|
|
217
|
+
lines.splice(c.at, c.end - c.at, ...repl)
|
|
218
|
+
return fromLines(d)
|
|
219
|
+
}
|
|
220
|
+
lines.splice(c.at, c.end - c.at)
|
|
221
|
+
// A parent this left with nothing under it (not even a comment) goes too: connect added it.
|
|
222
|
+
const q = parentAt(lines, parent)!
|
|
223
|
+
const next = lines[q.at + 1]
|
|
224
|
+
if (q.end === q.at + 1 && !trailing(lines[q.at]!) && (next === undefined || !/^[ \t]+#/.test(next))) lines.splice(q.at, 1)
|
|
225
|
+
return fromLines(d)
|
|
226
|
+
},
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---- Oh My Pi -----------------------------------------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
function agentDir(home: string, env: Env): string {
|
|
233
|
+
const root = join(home, env.PI_CONFIG_DIR?.trim() || ".omp")
|
|
234
|
+
const profile = (env.OMP_PROFILE !== undefined ? env.OMP_PROFILE : env.PI_PROFILE)?.trim()
|
|
235
|
+
if (profile && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(profile)) return join(root, "profiles", profile, "agent")
|
|
236
|
+
return dirFromEnv(env, "PI_CODING_AGENT_DIR", join(root, "agent"))
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** <dir>/<name>.yml, or <name>.yaml when only that one exists (omp reads the .yml first). */
|
|
240
|
+
const yamlFile = (dir: string, name: string) => (!existsSync(join(dir, `${name}.yml`)) && existsSync(join(dir, `${name}.yaml`)) ? join(dir, `${name}.yaml`) : join(dir, `${name}.yml`))
|
|
241
|
+
|
|
242
|
+
/** Refuses writing a first YAML file while the legacy JSON one omp would migrate from is still there. */
|
|
243
|
+
const unmigrated = (path: string, legacy: string) => () =>
|
|
244
|
+
existsSync(join(dirname(path), legacy)) ? `Oh My Pi has not moved its ${legacy} to YAML yet (it does on its next start); run omp once, then connect` : undefined
|
|
245
|
+
|
|
246
|
+
export function ompProvider(home: string, s: GatewaySettings): Json {
|
|
247
|
+
return {
|
|
248
|
+
baseUrl: `${routeUrl(s)}/v1`,
|
|
249
|
+
api: "openai-completions",
|
|
250
|
+
apiKey: keyCommand(home),
|
|
251
|
+
...(s.contextEdit ? { headers: { [EDIT_HEADER]: "on" } } : {}),
|
|
252
|
+
compat: CHAT_COMPAT,
|
|
253
|
+
models: [{ id: s.model, name: modelName(s.model), input: ["text", "image"] }],
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const omp: Connector = {
|
|
258
|
+
id: "omp",
|
|
259
|
+
label: "Oh My Pi",
|
|
260
|
+
file: (home, env) => yamlFile(agentDir(home, env), "models"),
|
|
261
|
+
// The agent dir (the main file's folder) is what omp's first run creates.
|
|
262
|
+
probe: [],
|
|
263
|
+
authHeader: "authorization",
|
|
264
|
+
edits: (home, s, env) => {
|
|
265
|
+
const dir = agentDir(home, env)
|
|
266
|
+
const models = yamlFile(dir, "models")
|
|
267
|
+
const config = yamlFile(dir, "config")
|
|
268
|
+
return [
|
|
269
|
+
{ path: models, format: "custom", codec: yamlEntry("providers", unmigrated(models, "models.json")), values: { [`providers/${PROVIDER_ID}`]: ompProvider(home, s) } },
|
|
270
|
+
{ path: config, format: "custom", codec: yamlEntry("modelRoles", unmigrated(config, "settings.json")), values: { "modelRoles/default": `${PROVIDER_ID}/${s.model}` } },
|
|
271
|
+
]
|
|
272
|
+
},
|
|
273
|
+
notes: [
|
|
274
|
+
`Oh My Pi's default model role is now the "${PROVIDER_ID}" provider in models.yml; it reads the key from the brain's key file when it sends a request (apiKey "!cat <key file>").`,
|
|
275
|
+
"A --model flag, a project's .omp/config.yml modelRoles, or an enabledModels list that leaves this model out still take precedence; smol and slow roles you set yourself keep their own models.",
|
|
276
|
+
],
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export const connectors: Connector[] = [omp]
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// Gateway connector for the OpenHands CLI (`openhands`, PyPI package openhands 1.16.0 with openhands-sdk 1.21.0, checked
|
|
2
|
+
// in the installed copy at ~/.cache/brain-wire/openhands/venv, site-packages below):
|
|
3
|
+
//
|
|
4
|
+
// - The CLI keeps its whole agent spec, LLM included, in <persistence dir>/agent_settings.json, the persistence dir being
|
|
5
|
+
// $OPENHANDS_PERSISTENCE_DIR or ~/.openhands (openhands_cli/locations.py:4-11,34). It loads it with
|
|
6
|
+
// Agent.model_validate_json at every start (openhands_cli/stores/agent_store.py:262-284) and writes it back only when
|
|
7
|
+
// the person saves settings (agent_store.py:473-475, tui/modals/settings/utils.py:267). It is pydantic's compact JSON
|
|
8
|
+
// (no spaces, floats as 8.0), so it is edited here in place (the codec below) and comes back byte for byte.
|
|
9
|
+
// - The LLM is `llm` (and the summarising condenser's own `condenser.llm`, which OpenHands' settings screen sets to the
|
|
10
|
+
// same model: tui/modals/settings/utils.py:216-235). Its fields (openhands/sdk/llm/llm.py:167-300): `model` (a LiteLLM
|
|
11
|
+
// id: "openai/<id>" sends OpenAI Chat Completions to `base_url` + /chat/completions with `Authorization: Bearer
|
|
12
|
+
// <api_key>`; llm.py:1161-1180), `base_url`, `api_key`, and `extra_headers`, forwarded on every chat and Responses call
|
|
13
|
+
// (openhands/sdk/llm/options/chat_options.py:31-33, responses_options.py:30-32): the context-edit header goes there.
|
|
14
|
+
// "litellm_proxy/<id>" is not used: it makes the SDK ask <base_url>/v1/model/info first (sdk/llm/utils/model_info.py:
|
|
15
|
+
// 15-67), which the gateway does not serve.
|
|
16
|
+
// - The key: `api_key` is a plain string in that file (written with expose_secrets: agent_store.py:474); there is no
|
|
17
|
+
// key-file or command option (sdk/utils/pydantic_secrets.py:65-90), and the only env route (LLM_API_KEY) needs the
|
|
18
|
+
// --override-with-envs flag on every run (agent_store.py:131-237). So the key is copied in: the file is made 0600
|
|
19
|
+
// and the key is replaced on a new sign-in.
|
|
20
|
+
// - Besides model, base_url, api_key and extra_headers, the values OpenHands derives from the previous model are reset,
|
|
21
|
+
// as OpenHands' own settings screen does when the model changes (it builds a fresh LLM: tui/modals/settings/utils.py:
|
|
22
|
+
// 186-211): max_input_tokens and max_output_tokens (null: the SDK looks them up again; a previous model's 64k or 128k
|
|
23
|
+
// output cap would be sent as max_completion_tokens), model_canonical_name (it overrides capability lookups, e.g. a
|
|
24
|
+
// "gpt-5" name would switch to the Responses API: sdk/llm/utils/model_features.py:159-201) and litellm_extra_body
|
|
25
|
+
// (routing metadata for the previous endpoint; chat_options.py:83-85 sends it as extra_body). All of them come back
|
|
26
|
+
// on disconnect.
|
|
27
|
+
// - OpenHands has no named providers or model list (no profile is used by the CLI), so the LLM entry itself is switched.
|
|
28
|
+
import { readFileSync } from "node:fs"
|
|
29
|
+
import { join } from "node:path"
|
|
30
|
+
import { type Codec, type Connector, dirFromEnv, EDIT_HEADER, type Env, type GatewaySettings, type Json, readKey, routeUrl } from "../common.ts"
|
|
31
|
+
|
|
32
|
+
// ---- JSON edited in place ------------------------------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
interface Member {
|
|
35
|
+
key: string
|
|
36
|
+
keyStart: number
|
|
37
|
+
keyEnd: number
|
|
38
|
+
valueStart: number
|
|
39
|
+
valueEnd: number
|
|
40
|
+
value: Node
|
|
41
|
+
}
|
|
42
|
+
type Node = { kind: "object"; start: number; end: number; members: Member[] } | { kind: "other"; start: number; end: number }
|
|
43
|
+
|
|
44
|
+
/** The spans of a JSON text (already known to be valid JSON): every object's members with where their key and value are. */
|
|
45
|
+
function parseSpans(text: string): Node {
|
|
46
|
+
let i = 0
|
|
47
|
+
const ws = () => {
|
|
48
|
+
while (i < text.length && /[ \t\r\n]/.test(text[i]!)) i++
|
|
49
|
+
}
|
|
50
|
+
const str = () => {
|
|
51
|
+
i++ // opening quote
|
|
52
|
+
while (i < text.length && text[i] !== '"') i += text[i] === "\\" ? 2 : 1
|
|
53
|
+
i++ // closing quote
|
|
54
|
+
}
|
|
55
|
+
const value = (): Node => {
|
|
56
|
+
ws()
|
|
57
|
+
const start = i
|
|
58
|
+
const c = text[i]
|
|
59
|
+
if (c === "{") {
|
|
60
|
+
i++
|
|
61
|
+
const members: Member[] = []
|
|
62
|
+
ws()
|
|
63
|
+
if (text[i] === "}") {
|
|
64
|
+
i++
|
|
65
|
+
return { kind: "object", start, end: i, members }
|
|
66
|
+
}
|
|
67
|
+
for (;;) {
|
|
68
|
+
ws()
|
|
69
|
+
const keyStart = i
|
|
70
|
+
str()
|
|
71
|
+
const keyEnd = i
|
|
72
|
+
ws()
|
|
73
|
+
i++ // :
|
|
74
|
+
ws()
|
|
75
|
+
const valueStart = i
|
|
76
|
+
const v = value()
|
|
77
|
+
members.push({ key: JSON.parse(text.slice(keyStart, keyEnd)), keyStart, keyEnd, valueStart, valueEnd: i, value: v })
|
|
78
|
+
ws()
|
|
79
|
+
if (text[i] === ",") {
|
|
80
|
+
i++
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
i++ // }
|
|
84
|
+
return { kind: "object", start, end: i, members }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (c === "[") {
|
|
88
|
+
i++
|
|
89
|
+
ws()
|
|
90
|
+
if (text[i] === "]") {
|
|
91
|
+
i++
|
|
92
|
+
return { kind: "other", start, end: i }
|
|
93
|
+
}
|
|
94
|
+
for (;;) {
|
|
95
|
+
value()
|
|
96
|
+
ws()
|
|
97
|
+
if (text[i++] === "]") return { kind: "other", start, end: i }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (c === '"') {
|
|
101
|
+
str()
|
|
102
|
+
return { kind: "other", start, end: i }
|
|
103
|
+
}
|
|
104
|
+
while (i < text.length && !/[,}\]\s]/.test(text[i]!)) i++
|
|
105
|
+
return { kind: "other", start, end: i }
|
|
106
|
+
}
|
|
107
|
+
return value()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** How the file writes JSON, read off its first object with members: compact (pydantic, JSON.stringify) or indented. */
|
|
111
|
+
interface Style {
|
|
112
|
+
colon: string
|
|
113
|
+
comma: string
|
|
114
|
+
/** Indent unit of an indented file; undefined for a one-line file. */
|
|
115
|
+
unit?: string
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function styleOf(text: string, root: Node): Style {
|
|
119
|
+
const multiline = /\n\s*\S/.test(text.trim())
|
|
120
|
+
let colon: string | undefined
|
|
121
|
+
let comma: string | undefined
|
|
122
|
+
let unit: string | undefined
|
|
123
|
+
const walk = (n: Node, indent: string) => {
|
|
124
|
+
if (n.kind !== "object") return
|
|
125
|
+
const [a, b] = n.members
|
|
126
|
+
if (a && colon === undefined) colon = text.slice(a.keyEnd, a.valueStart)
|
|
127
|
+
if (a && b && comma === undefined && !text.slice(a.valueEnd, b.keyStart).includes("\n")) comma = text.slice(a.valueEnd, b.keyStart)
|
|
128
|
+
if (a && unit === undefined && multiline) {
|
|
129
|
+
const own = lineIndent(text, a.keyStart)
|
|
130
|
+
if (own.length > indent.length) unit = own.slice(indent.length)
|
|
131
|
+
}
|
|
132
|
+
for (const m of n.members) walk(m.value, lineIndent(text, m.keyStart))
|
|
133
|
+
}
|
|
134
|
+
walk(root, "")
|
|
135
|
+
return { colon: colon ?? (multiline ? ": " : ":"), comma: comma ?? ",", ...(multiline ? { unit: unit ?? " " } : {}) }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The spaces before the first non-space character of the line holding `at`. */
|
|
139
|
+
function lineIndent(text: string, at: number): string {
|
|
140
|
+
const start = text.lastIndexOf("\n", at - 1) + 1
|
|
141
|
+
return /^[ \t]*/.exec(text.slice(start))![0]
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** A value written in the file's own style; `indent` is the indent of the line it starts on. */
|
|
145
|
+
function serialize(v: unknown, st: Style, indent: string): string {
|
|
146
|
+
if (v === null || typeof v !== "object") return JSON.stringify(v) ?? "null"
|
|
147
|
+
const arr = Array.isArray(v)
|
|
148
|
+
const entries: Array<[string | undefined, unknown]> = arr ? (v as unknown[]).map((x) => [undefined, x]) : Object.entries(v as Json)
|
|
149
|
+
const [open, close] = arr ? ["[", "]"] : ["{", "}"]
|
|
150
|
+
if (!entries.length) return open + close
|
|
151
|
+
const item = ([k, x]: [string | undefined, unknown], at: string) => (k === undefined ? "" : JSON.stringify(k) + st.colon) + serialize(x, st, at)
|
|
152
|
+
if (st.unit === undefined) return open + entries.map((e) => item(e, indent)).join(st.comma) + close
|
|
153
|
+
const inner = indent + st.unit
|
|
154
|
+
return `${open}\n${entries.map((e) => inner + item(e, inner)).join(",\n")}\n${indent}${close}`
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** `value` under the path `parts` (objects created on the way). */
|
|
158
|
+
const nest = (parts: string[], value: unknown): unknown => parts.reduceRight((v, p) => ({ [p]: v }), value)
|
|
159
|
+
|
|
160
|
+
/** The text with member `m` of object `o` removed, together with the comma and spaces that joined it to its neighbour. */
|
|
161
|
+
function removeMember(text: string, o: Extract<Node, { kind: "object" }>, m: Member): string {
|
|
162
|
+
const i = o.members.indexOf(m)
|
|
163
|
+
if (o.members.length === 1) return text.slice(0, o.start + 1) + text.slice(o.end - 1)
|
|
164
|
+
if (i === o.members.length - 1) return text.slice(0, o.members[i - 1]!.valueEnd) + text.slice(m.valueEnd)
|
|
165
|
+
return text.slice(0, m.keyStart) + text.slice(o.members[i + 1]!.keyStart)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The text with `"key": value` added as the last member of object `o`. */
|
|
169
|
+
function addMember(text: string, o: Extract<Node, { kind: "object" }>, key: string, value: unknown, st: Style): string {
|
|
170
|
+
const last = o.members[o.members.length - 1]
|
|
171
|
+
const parentIndent = lineIndent(text, o.start)
|
|
172
|
+
const indent = last ? lineIndent(text, last.keyStart) : parentIndent + (st.unit ?? "")
|
|
173
|
+
const member = `${JSON.stringify(key)}${st.colon}${serialize(value, st, indent)}`
|
|
174
|
+
if (last) {
|
|
175
|
+
const multiline = text.slice(o.start, o.members[0]!.keyStart).includes("\n")
|
|
176
|
+
const sep = multiline ? `,\n${indent}` : st.comma
|
|
177
|
+
return text.slice(0, last.valueEnd) + sep + member + text.slice(last.valueEnd)
|
|
178
|
+
}
|
|
179
|
+
const body = st.unit === undefined ? member : `\n${indent}${member}\n${parentIndent}`
|
|
180
|
+
return text.slice(0, o.start + 1) + body + text.slice(o.end - 1)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Plain JSON (a JSON object) with keys as "/" paths, like the core's json format, but edited in place: a value is
|
|
185
|
+
* replaced where it stands, a new key is added after the last member of its object in the file's own spacing, and a
|
|
186
|
+
* removed key takes back exactly what was added. Everything else keeps its bytes (pydantic's compact JSON with its 8.0
|
|
187
|
+
* floats stays as it is). Objects a removal leaves empty are dropped, as the core does (never the root).
|
|
188
|
+
*/
|
|
189
|
+
export const jsonInPlace: Codec = {
|
|
190
|
+
check(text) {
|
|
191
|
+
if (!text.trim()) return undefined
|
|
192
|
+
try {
|
|
193
|
+
const v = JSON.parse(text)
|
|
194
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) return "it is not a JSON object; left as it is"
|
|
195
|
+
} catch {
|
|
196
|
+
return "it is not plain JSON; left as it is"
|
|
197
|
+
}
|
|
198
|
+
return undefined
|
|
199
|
+
},
|
|
200
|
+
get(text, key) {
|
|
201
|
+
if (!text.trim()) return undefined
|
|
202
|
+
let cur: unknown = JSON.parse(text)
|
|
203
|
+
for (const p of key.split("/")) {
|
|
204
|
+
if (!cur || typeof cur !== "object" || Array.isArray(cur) || !(p in (cur as Json))) return undefined
|
|
205
|
+
cur = (cur as Json)[p]
|
|
206
|
+
}
|
|
207
|
+
return cur
|
|
208
|
+
},
|
|
209
|
+
set(text, key, value) {
|
|
210
|
+
return setPath(text, key.split("/"), value)
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function setPath(text: string, parts: string[], value: unknown): string {
|
|
215
|
+
// A new (or empty) file is started as an indented object, as the core writes JSON.
|
|
216
|
+
const src = text.trim() ? text : "{}\n"
|
|
217
|
+
const root = parseSpans(src)
|
|
218
|
+
if (root.kind !== "object") throw new Error("not a JSON object")
|
|
219
|
+
const st: Style = text.trim() ? styleOf(src, root) : { colon: ": ", comma: ",", unit: " " }
|
|
220
|
+
let o = root
|
|
221
|
+
for (let d = 0; d < parts.length; d++) {
|
|
222
|
+
// The last of duplicate keys wins, as in JSON.parse.
|
|
223
|
+
const m = o.members.filter((x) => x.key === parts[d]).pop()
|
|
224
|
+
const last = d === parts.length - 1
|
|
225
|
+
if (!m) return value === undefined ? text : addMember(src, o, parts[d]!, nest(parts.slice(d + 1), value), st)
|
|
226
|
+
if (!last && m.value.kind === "object") {
|
|
227
|
+
o = m.value
|
|
228
|
+
continue
|
|
229
|
+
}
|
|
230
|
+
if (value === undefined) {
|
|
231
|
+
if (!last) return text
|
|
232
|
+
const out = removeMember(src, o, m)
|
|
233
|
+
// The object this left empty goes too (spans are read again from the new text), never the root.
|
|
234
|
+
return o.members.length === 1 && d > 0 ? setPath(out, parts.slice(0, d), undefined) : out
|
|
235
|
+
}
|
|
236
|
+
const v = last ? value : nest(parts.slice(d + 1), value)
|
|
237
|
+
return src.slice(0, m.valueStart) + serialize(v, st, lineIndent(src, m.keyStart)) + src.slice(m.valueEnd)
|
|
238
|
+
}
|
|
239
|
+
return text
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---- the connector -----------------------------------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
const persistenceDir = (home: string, env: Env) => dirFromEnv(env, "OPENHANDS_PERSISTENCE_DIR", join(home, ".openhands"))
|
|
245
|
+
const settingsFile = (home: string, env: Env) => join(persistenceDir(home, env), "agent_settings.json")
|
|
246
|
+
|
|
247
|
+
/** Whether the agent spec has a summarising condenser with its own LLM (then that LLM is pointed at the gateway too). */
|
|
248
|
+
function hasCondenserLlm(path: string): boolean {
|
|
249
|
+
try {
|
|
250
|
+
const c = JSON.parse(readFileSync(path, "utf8"))?.condenser?.llm
|
|
251
|
+
return !!c && typeof c === "object" && !Array.isArray(c)
|
|
252
|
+
} catch {
|
|
253
|
+
return false
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** The values one OpenHands LLM entry (`llm` or `condenser/llm`) gets. */
|
|
258
|
+
const llmValues = (prefix: string, home: string, s: GatewaySettings): Record<string, unknown> => ({
|
|
259
|
+
[`${prefix}/model`]: `openai/${s.model}`,
|
|
260
|
+
[`${prefix}/base_url`]: `${routeUrl(s)}/v1`,
|
|
261
|
+
[`${prefix}/api_key`]: readKey(home),
|
|
262
|
+
[`${prefix}/extra_headers`]: s.contextEdit ? { [EDIT_HEADER]: "on" } : null,
|
|
263
|
+
[`${prefix}/model_canonical_name`]: null,
|
|
264
|
+
[`${prefix}/max_input_tokens`]: null,
|
|
265
|
+
[`${prefix}/max_output_tokens`]: null,
|
|
266
|
+
[`${prefix}/litellm_extra_body`]: {},
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
export const connectors: Connector[] = [
|
|
270
|
+
{
|
|
271
|
+
id: "openhands",
|
|
272
|
+
label: "OpenHands",
|
|
273
|
+
file: settingsFile,
|
|
274
|
+
probe: [".openhands"],
|
|
275
|
+
authHeader: "authorization",
|
|
276
|
+
edits: (home, s, env) => {
|
|
277
|
+
const path = settingsFile(home, env)
|
|
278
|
+
const prefixes = ["llm", ...(hasCondenserLlm(path) ? ["condenser/llm"] : [])]
|
|
279
|
+
return [
|
|
280
|
+
{
|
|
281
|
+
path,
|
|
282
|
+
format: "custom",
|
|
283
|
+
codec: jsonInPlace,
|
|
284
|
+
holdsKey: true,
|
|
285
|
+
secretKeys: prefixes.map((p) => `${p}/api_key`),
|
|
286
|
+
values: Object.assign({}, ...prefixes.map((p) => llmValues(p, home, s))),
|
|
287
|
+
},
|
|
288
|
+
]
|
|
289
|
+
},
|
|
290
|
+
notes: [
|
|
291
|
+
"OpenHands keeps the key in its agent_settings.json (it has no key-file option), so the file is now readable by you only (0600); a new sign-in replaces the key there.",
|
|
292
|
+
"Its agent and memory-condenser LLM now use the gateway's model over OpenAI Chat Completions; the model limits OpenHands had stored for your previous model are cleared so it looks them up again.",
|
|
293
|
+
"Run without --override-with-envs (or without LLM_API_KEY, LLM_BASE_URL and LLM_MODEL set): those override this file. Changing the LLM in OpenHands' settings screen replaces these values, and disconnect then leaves your choice alone.",
|
|
294
|
+
],
|
|
295
|
+
},
|
|
296
|
+
]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Gateway connector for PearAI's chat ("PearAI Chat"), a fork of an older Continue. Checked against its source,
|
|
2
|
+
// trypear/pearai-submodule at main 51eceef (2026-06-18; extensions/vscode/package.json: "PearAI" 2.0.0). PearAI is not
|
|
3
|
+
// installed on this machine and is not in ~/.cache/brain-wire, so there is no real-tool check; the file format is the one
|
|
4
|
+
// Continue's own config.json loader reads too, which test/gateway-connectors/pearai.test.ts and continue.test.ts exercise.
|
|
5
|
+
//
|
|
6
|
+
// Where PearAI reads its settings (core/util/paths.ts):
|
|
7
|
+
// ~/.pearai CONTINUE_GLOBAL_DIR ?? ~/.pearai (paths.ts:10-12). brain does not follow CONTINUE_GLOBAL_DIR
|
|
8
|
+
// here: that variable is Continue's, and a person who sets it means Continue's folder.
|
|
9
|
+
// ~/.pearai/config.json created on first start with `"models": []` (getConfigJsonPath, config/default.ts defaultConfig),
|
|
10
|
+
// read as JSON with comments (comment-json) by core/config/load.ts resolveSerializedConfig (:71-96).
|
|
11
|
+
// ~/.pearai/.env getContinueDotEnv (paths.ts:289-295). resolveSerializedConfig replaces every "NAME" string in
|
|
12
|
+
// config.json with NAME's value from the process environment or this file, for each NAME listed
|
|
13
|
+
// in the top-level "env" array. There is no other way to give a key (no file or command).
|
|
14
|
+
// A model is an entry of "models": title, provider "openai", model, apiBase, apiKey, contextLength, requestOptions.headers.
|
|
15
|
+
// Provider openai posts Chat Completions to new URL("chat/completions", apiBase) (core/llm/llms/OpenAI.ts:337-352, :425)
|
|
16
|
+
// with Authorization: Bearer <apiKey> (:314-320); apiBase is given a trailing "/" first (core/llm/index.ts:184-186), and
|
|
17
|
+
// requestOptions.headers go with every request (core/llm/index.ts:301-304, core/util/fetchWithOptions.ts:74-80). The
|
|
18
|
+
// Responses API is used only against api.openai.com (OpenAI.ts usesResponsesApi).
|
|
19
|
+
// The model in use is the one picked before if it still exists, else the first in the list (gui/src/redux/slices/
|
|
20
|
+
// stateSlice.ts:186-192); PearAI appends its own models after the person's in memory (load.ts addDefaultModels), so the
|
|
21
|
+
// gateway's entry, written first, is the default for anyone who has not picked a model.
|
|
22
|
+
//
|
|
23
|
+
// What is written (the same as Continue's config.json case, connectors/continue.ts):
|
|
24
|
+
// ~/.pearai/.env BRAIN_GATEWAY_KEY=<key> (0600, refreshed on a new sign-in)
|
|
25
|
+
// ~/.pearai/config.json first in "models": {title: 100xprompt Pro, provider: openai, model, apiBase: <route>/v1,
|
|
26
|
+
// apiKey: "BRAIN_GATEWAY_KEY", contextLength, requestOptions.headers x-100x-context-edit},
|
|
27
|
+
// and "BRAIN_GATEWAY_KEY" in the top-level "env" list. Edited as text: comments stay, and
|
|
28
|
+
// disconnect gives back the exact bytes.
|
|
29
|
+
// Not covered: PearAI's agent ("PearAI Agent", a Roo Code fork), whose provider settings live in the editor's own storage.
|
|
30
|
+
import { join } from "node:path"
|
|
31
|
+
import type { Connector } from "../common.ts"
|
|
32
|
+
import { ENTRY_KEY, envEdit, jsonCodec, jsonEntry, SECRET } from "./continue.ts"
|
|
33
|
+
|
|
34
|
+
const dir = (home: string) => join(home, ".pearai")
|
|
35
|
+
|
|
36
|
+
export const connectors: Connector[] = [
|
|
37
|
+
{
|
|
38
|
+
id: "pearai",
|
|
39
|
+
label: "PearAI",
|
|
40
|
+
file: (home) => join(dir(home), "config.json"),
|
|
41
|
+
probe: [".pearai"],
|
|
42
|
+
authHeader: "authorization",
|
|
43
|
+
edits: (home, s) => [
|
|
44
|
+
envEdit(dir(home), home),
|
|
45
|
+
{ path: join(dir(home), "config.json"), format: "custom", codec: jsonCodec, values: { [ENTRY_KEY]: jsonEntry(s), [`env/${SECRET}`]: SECRET } },
|
|
46
|
+
],
|
|
47
|
+
notes: [
|
|
48
|
+
"PearAI's chat now lists the gateway's model first; it is the default unless you picked another model before: then pick it once in the chat's model menu. PearAI's agent is not changed.",
|
|
49
|
+
`PearAI cannot read a key from another file, so the key is kept in ~/.pearai/.env as ${SECRET} (readable by you only), and config.json refers to it.`,
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
]
|