@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.
Files changed (260) hide show
  1. package/LICENSE +48 -0
  2. package/README.md +235 -2
  3. package/package.json +48 -4
  4. package/src/adapters/aider/index.ts +8 -0
  5. package/src/adapters/aider/reader.ts +129 -0
  6. package/src/adapters/aider/records.ts +220 -0
  7. package/src/adapters/aider/writer.ts +269 -0
  8. package/src/adapters/amazon-q-cli/index.ts +8 -0
  9. package/src/adapters/amazon-q-cli/reader.ts +84 -0
  10. package/src/adapters/amazon-q-cli/records.ts +191 -0
  11. package/src/adapters/amazon-q-cli/writer.ts +370 -0
  12. package/src/adapters/amazon-q-ide/index.ts +9 -0
  13. package/src/adapters/amazon-q-ide/reader.ts +131 -0
  14. package/src/adapters/amazon-q-ide/records.ts +141 -0
  15. package/src/adapters/amazon-q-ide/writer.ts +325 -0
  16. package/src/adapters/antigravity/index.ts +9 -0
  17. package/src/adapters/antigravity/proto.ts +194 -0
  18. package/src/adapters/antigravity/reader.ts +424 -0
  19. package/src/adapters/antigravity/records.ts +236 -0
  20. package/src/adapters/antigravity/writer.ts +463 -0
  21. package/src/adapters/auggie/index.ts +36 -0
  22. package/src/adapters/claude-code/index.ts +8 -0
  23. package/src/adapters/claude-code/reader.ts +639 -0
  24. package/src/adapters/claude-code/records.ts +88 -0
  25. package/src/adapters/claude-code/writer.ts +329 -0
  26. package/src/adapters/cline/index.ts +12 -0
  27. package/src/adapters/cline/reader.ts +432 -0
  28. package/src/adapters/cline/records.ts +151 -0
  29. package/src/adapters/cline/vscdb.ts +56 -0
  30. package/src/adapters/cline/writer.ts +656 -0
  31. package/src/adapters/cline/xml.ts +177 -0
  32. package/src/adapters/cline4/index.ts +9 -0
  33. package/src/adapters/cline4/reader.ts +179 -0
  34. package/src/adapters/cline4/records.ts +92 -0
  35. package/src/adapters/cline4/writer.ts +361 -0
  36. package/src/adapters/codebuff/index.ts +8 -0
  37. package/src/adapters/codebuff/project.ts +207 -0
  38. package/src/adapters/codebuff/reader.ts +215 -0
  39. package/src/adapters/codebuff/records.ts +173 -0
  40. package/src/adapters/codebuff/writer.ts +383 -0
  41. package/src/adapters/codex/index.ts +8 -0
  42. package/src/adapters/codex/native.ts +239 -0
  43. package/src/adapters/codex/reader.ts +747 -0
  44. package/src/adapters/codex/records.ts +114 -0
  45. package/src/adapters/codex/writer.ts +479 -0
  46. package/src/adapters/continue/index.ts +9 -0
  47. package/src/adapters/continue/reader.ts +68 -0
  48. package/src/adapters/continue/records.ts +418 -0
  49. package/src/adapters/continue/writer.ts +162 -0
  50. package/src/adapters/copilot-cli/index.ts +9 -0
  51. package/src/adapters/copilot-cli/reader.ts +186 -0
  52. package/src/adapters/copilot-cli/records.ts +241 -0
  53. package/src/adapters/copilot-cli/writer.ts +353 -0
  54. package/src/adapters/crush/index.ts +8 -0
  55. package/src/adapters/crush/reader.ts +180 -0
  56. package/src/adapters/crush/records.ts +169 -0
  57. package/src/adapters/crush/writer.ts +333 -0
  58. package/src/adapters/cursor-cli/index.ts +405 -0
  59. package/src/adapters/cursor-ide/agentkv.ts +95 -0
  60. package/src/adapters/cursor-ide/index.ts +501 -0
  61. package/src/adapters/cursor-ide/transcripts.ts +122 -0
  62. package/src/adapters/cursor-ide/workspaces.ts +119 -0
  63. package/src/adapters/cursor-ide/writer.ts +170 -0
  64. package/src/adapters/devin/index.ts +8 -0
  65. package/src/adapters/devin/reader.ts +305 -0
  66. package/src/adapters/devin/records.ts +277 -0
  67. package/src/adapters/devin/writer.ts +523 -0
  68. package/src/adapters/factory-droid/index.ts +12 -0
  69. package/src/adapters/factory-droid/reader.ts +184 -0
  70. package/src/adapters/factory-droid/records.ts +79 -0
  71. package/src/adapters/factory-droid/writer.ts +350 -0
  72. package/src/adapters/gemini-cli/exchanges.ts +228 -0
  73. package/src/adapters/gemini-cli/index.ts +396 -0
  74. package/src/adapters/gemini-cli/records.ts +521 -0
  75. package/src/adapters/gemini-cli/writer.ts +354 -0
  76. package/src/adapters/goose/index.ts +8 -0
  77. package/src/adapters/goose/reader.ts +138 -0
  78. package/src/adapters/goose/records.ts +199 -0
  79. package/src/adapters/goose/writer.ts +340 -0
  80. package/src/adapters/grok-cli/image.ts +44 -0
  81. package/src/adapters/grok-cli/index.ts +8 -0
  82. package/src/adapters/grok-cli/reader.ts +302 -0
  83. package/src/adapters/grok-cli/records.ts +172 -0
  84. package/src/adapters/grok-cli/writer.ts +329 -0
  85. package/src/adapters/hermes/index.ts +8 -0
  86. package/src/adapters/hermes/reader.ts +249 -0
  87. package/src/adapters/hermes/records.ts +285 -0
  88. package/src/adapters/hermes/writer.ts +452 -0
  89. package/src/adapters/iflow-cli/index.ts +72 -0
  90. package/src/adapters/iflow-cli/records.ts +215 -0
  91. package/src/adapters/iflow-cli/writer.ts +204 -0
  92. package/src/adapters/index.ts +112 -0
  93. package/src/adapters/kilo-cli/index.ts +21 -0
  94. package/src/adapters/kilo-code/index.ts +11 -0
  95. package/src/adapters/kilo-code/writer.ts +92 -0
  96. package/src/adapters/kimi/index.ts +9 -0
  97. package/src/adapters/kimi/reader.ts +92 -0
  98. package/src/adapters/kimi/records.ts +221 -0
  99. package/src/adapters/kimi/writer.ts +367 -0
  100. package/src/adapters/kiro-cli/index.ts +8 -0
  101. package/src/adapters/kiro-cli/reader.ts +87 -0
  102. package/src/adapters/kiro-cli/records.ts +260 -0
  103. package/src/adapters/kiro-cli/writer.ts +305 -0
  104. package/src/adapters/kiro-ide/index.ts +8 -0
  105. package/src/adapters/kiro-ide/writer.ts +52 -0
  106. package/src/adapters/letta/index.ts +8 -0
  107. package/src/adapters/letta/reader.ts +177 -0
  108. package/src/adapters/letta/records.ts +119 -0
  109. package/src/adapters/letta/writer.ts +413 -0
  110. package/src/adapters/mastracode/index.ts +8 -0
  111. package/src/adapters/mastracode/reader.ts +323 -0
  112. package/src/adapters/mastracode/records.ts +336 -0
  113. package/src/adapters/mastracode/writer.ts +385 -0
  114. package/src/adapters/mistral-vibe/index.ts +9 -0
  115. package/src/adapters/mistral-vibe/reader.ts +62 -0
  116. package/src/adapters/mistral-vibe/records.ts +104 -0
  117. package/src/adapters/mistral-vibe/writer.ts +359 -0
  118. package/src/adapters/muse/index.ts +10 -0
  119. package/src/adapters/muse/reader.ts +332 -0
  120. package/src/adapters/muse/records.ts +104 -0
  121. package/src/adapters/muse/writer.ts +387 -0
  122. package/src/adapters/omp/index.ts +13 -0
  123. package/src/adapters/omp/reader.ts +314 -0
  124. package/src/adapters/omp/records.ts +153 -0
  125. package/src/adapters/omp/writer.ts +216 -0
  126. package/src/adapters/opencode/ids.ts +52 -0
  127. package/src/adapters/opencode/index.ts +31 -0
  128. package/src/adapters/opencode/note.ts +8 -0
  129. package/src/adapters/opencode/reader.ts +452 -0
  130. package/src/adapters/opencode/tools.ts +174 -0
  131. package/src/adapters/opencode/v2.ts +367 -0
  132. package/src/adapters/opencode/writer.ts +379 -0
  133. package/src/adapters/openhands/index.ts +8 -0
  134. package/src/adapters/openhands/reader.ts +222 -0
  135. package/src/adapters/openhands/records.ts +136 -0
  136. package/src/adapters/openhands/writer.ts +404 -0
  137. package/src/adapters/pearai/index.ts +10 -0
  138. package/src/adapters/pi/index.ts +28 -0
  139. package/src/adapters/pi/reader.ts +261 -0
  140. package/src/adapters/pi/records.ts +99 -0
  141. package/src/adapters/pi/writer.ts +362 -0
  142. package/src/adapters/qoder-cli/index.ts +8 -0
  143. package/src/adapters/qoder-cli/reader.ts +83 -0
  144. package/src/adapters/qoder-cli/records.ts +45 -0
  145. package/src/adapters/qoder-cli/writer.ts +72 -0
  146. package/src/adapters/qwen-code/index.ts +84 -0
  147. package/src/adapters/qwen-code/records.ts +194 -0
  148. package/src/adapters/qwen-code/writer.ts +224 -0
  149. package/src/adapters/tabnine-cli/index.ts +8 -0
  150. package/src/adapters/tabnine-cli/reader.ts +106 -0
  151. package/src/adapters/tabnine-cli/records.ts +33 -0
  152. package/src/adapters/tabnine-cli/writer.ts +156 -0
  153. package/src/adapters/types.ts +73 -0
  154. package/src/adapters/void/index.ts +9 -0
  155. package/src/adapters/void/reader.ts +48 -0
  156. package/src/adapters/void/records.ts +285 -0
  157. package/src/adapters/void/writer.ts +93 -0
  158. package/src/adapters/vscode-copilot/index.ts +9 -0
  159. package/src/adapters/vscode-copilot/reader.ts +212 -0
  160. package/src/adapters/vscode-copilot/records.ts +388 -0
  161. package/src/adapters/vscode-copilot/shared.ts +68 -0
  162. package/src/adapters/vscode-copilot/writer.ts +546 -0
  163. package/src/adapters/zed/index.ts +8 -0
  164. package/src/adapters/zed/reader.ts +198 -0
  165. package/src/adapters/zed/records.ts +87 -0
  166. package/src/adapters/zed/writer.ts +341 -0
  167. package/src/brief-writers.ts +71 -0
  168. package/src/brief.ts +238 -0
  169. package/src/cli.ts +351 -0
  170. package/src/dashboard/cli.ts +148 -0
  171. package/src/dashboard/conversions.ts +170 -0
  172. package/src/dashboard/report.ts +261 -0
  173. package/src/dashboard/send.ts +213 -0
  174. package/src/dashboard/state.ts +95 -0
  175. package/src/fsutil.ts +133 -0
  176. package/src/gateway/common.ts +232 -0
  177. package/src/gateway/connect.ts +847 -0
  178. package/src/gateway/connectors/aider.ts +356 -0
  179. package/src/gateway/connectors/amazon-q-cli.ts +6 -0
  180. package/src/gateway/connectors/amazon-q-ide.ts +6 -0
  181. package/src/gateway/connectors/antigravity.ts +6 -0
  182. package/src/gateway/connectors/auggie.ts +6 -0
  183. package/src/gateway/connectors/cline-sdk.ts +85 -0
  184. package/src/gateway/connectors/cline.ts +34 -0
  185. package/src/gateway/connectors/codebuff.ts +6 -0
  186. package/src/gateway/connectors/continue.ts +579 -0
  187. package/src/gateway/connectors/copilot-cli.ts +138 -0
  188. package/src/gateway/connectors/crush.ts +313 -0
  189. package/src/gateway/connectors/cursor-cli.ts +6 -0
  190. package/src/gateway/connectors/cursor.ts +6 -0
  191. package/src/gateway/connectors/devin.ts +6 -0
  192. package/src/gateway/connectors/factory-droid.ts +308 -0
  193. package/src/gateway/connectors/goose.ts +79 -0
  194. package/src/gateway/connectors/grok-cli.ts +82 -0
  195. package/src/gateway/connectors/hermes.ts +230 -0
  196. package/src/gateway/connectors/iflow-cli.ts +50 -0
  197. package/src/gateway/connectors/index.ts +86 -0
  198. package/src/gateway/connectors/kilo-cli.ts +434 -0
  199. package/src/gateway/connectors/kilo-code.ts +32 -0
  200. package/src/gateway/connectors/kimi-code.ts +241 -0
  201. package/src/gateway/connectors/kiro-cli.ts +6 -0
  202. package/src/gateway/connectors/kiro-ide.ts +6 -0
  203. package/src/gateway/connectors/letta-code.ts +170 -0
  204. package/src/gateway/connectors/mastracode.ts +91 -0
  205. package/src/gateway/connectors/mistral-vibe.ts +495 -0
  206. package/src/gateway/connectors/muse.ts +27 -0
  207. package/src/gateway/connectors/omp.ts +279 -0
  208. package/src/gateway/connectors/openhands.ts +296 -0
  209. package/src/gateway/connectors/pearai.ts +52 -0
  210. package/src/gateway/connectors/pi.ts +357 -0
  211. package/src/gateway/connectors/qoder-cli.ts +6 -0
  212. package/src/gateway/connectors/qwen-code.ts +411 -0
  213. package/src/gateway/connectors/roo.ts +43 -0
  214. package/src/gateway/connectors/tabnine-cli.ts +6 -0
  215. package/src/gateway/connectors/void.ts +6 -0
  216. package/src/gateway/connectors/vscode-copilot.ts +286 -0
  217. package/src/gateway/connectors/windsurf.ts +6 -0
  218. package/src/gateway/connectors/zed.ts +29 -0
  219. package/src/gateway/signin.ts +323 -0
  220. package/src/handoff.ts +104 -0
  221. package/src/hooks.ts +455 -0
  222. package/src/memory/audit.ts +514 -0
  223. package/src/memory/blobs.ts +94 -0
  224. package/src/memory/cli.ts +117 -0
  225. package/src/memory/distill.ts +381 -0
  226. package/src/memory/externalize.ts +435 -0
  227. package/src/memory/lookups.ts +70 -0
  228. package/src/memory/mcp-install.ts +401 -0
  229. package/src/memory/mcp-server.ts +296 -0
  230. package/src/memory/prepare.ts +179 -0
  231. package/src/memory/preview.ts +154 -0
  232. package/src/memory/project.ts +35 -0
  233. package/src/memory/redact.ts +72 -0
  234. package/src/memory/relevance.ts +187 -0
  235. package/src/memory/repomap.ts +516 -0
  236. package/src/memory/store.ts +270 -0
  237. package/src/memory/sync.ts +166 -0
  238. package/src/memory/tidy.ts +193 -0
  239. package/src/memory/types.ts +118 -0
  240. package/src/org/admin-ops.ts +96 -0
  241. package/src/org/auth.ts +112 -0
  242. package/src/org/backup.ts +115 -0
  243. package/src/org/cli.ts +438 -0
  244. package/src/org/client.ts +404 -0
  245. package/src/org/config.ts +44 -0
  246. package/src/org/crypto.ts +537 -0
  247. package/src/org/db.ts +435 -0
  248. package/src/org/graph.ts +318 -0
  249. package/src/org/oidc.ts +435 -0
  250. package/src/org/policy.ts +398 -0
  251. package/src/org/server.ts +920 -0
  252. package/src/org/sso.ts +316 -0
  253. package/src/org/tls.ts +66 -0
  254. package/src/org/types.ts +193 -0
  255. package/src/self.ts +68 -0
  256. package/src/session/attachments.ts +53 -0
  257. package/src/session/peekcache.ts +50 -0
  258. package/src/session/types.ts +104 -0
  259. package/src/session/util.ts +130 -0
  260. 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
+ ]