@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,213 @@
1
+ // Sending the report (report.ts) to the person's 100xprompt account: POST <gateway>/api/brain/report with the brain's
2
+ // gateway key, best effort, 10 s at most. docs/dashboard-contract.md has the rules:
3
+ // - only when signed in (`brain gateway login`), and not turned off (`brain dashboard off`, or BRAIN_NO_REPORT=1);
4
+ // - in the background after a command that changes what the report says, never slowing that command: the command starts
5
+ // a detached `brain dashboard push --quiet` and returns. At most one background push every 30 s: a command within 30 s
6
+ // of the last one schedules the next for 30 s after it, and a push already waiting covers later commands too, because a
7
+ // push reads the state when it runs;
8
+ // - the first time a report is about to go out, a notice says once what is sent and how to stop it.
9
+ // This file stays cheap to import (every command that changes something loads it); report.ts is loaded only to send.
10
+ import { CLI_PATH } from "../self.ts"
11
+ import { DEFAULT_URL, type Env, readKey, statePath } from "../gateway/common.ts"
12
+ import { readFileSync } from "node:fs"
13
+ import { readCache, readSettings, updateCache, updateSettings } from "./state.ts"
14
+ import type { BrainReport } from "./report.ts"
15
+
16
+ /** Where the person sees what was sent. */
17
+ export const DASHBOARD_URL = "https://100xprompt.com/dashboard?view=brain"
18
+ export const REPORT_PATH = "/api/brain/report"
19
+ export const SEND_TIMEOUT_MS = 10_000
20
+ /** The least time between two background pushes. */
21
+ export const REPORT_EVERY_MS = 30_000
22
+ /** A waiting push that has not cleared its mark this long after its time has died: the mark is ignored. */
23
+ const PENDING_STALE_MS = 60_000
24
+
25
+ /** BRAIN_REPORT_EVERY_MS shortens or lengthens the 30 s between background pushes (tests use a short one). */
26
+ export function reportEvery(env: Env = process.env): number {
27
+ const n = Number(env.BRAIN_REPORT_EVERY_MS)
28
+ return env.BRAIN_REPORT_EVERY_MS !== undefined && env.BRAIN_REPORT_EVERY_MS !== "" && Number.isFinite(n) && n >= 0 ? n : REPORT_EVERY_MS
29
+ }
30
+
31
+ /** The gateway address: the one `brain gateway connect --url` keeps in gateway.json, else https://proxy.100xprompt.com. */
32
+ export function gatewayUrl(home: string): string {
33
+ try {
34
+ const u = JSON.parse(readFileSync(statePath(home), "utf8"))?.settings?.url
35
+ if (typeof u === "string" && u.trim()) return u.trim().replace(/\/+$/, "")
36
+ } catch {}
37
+ return DEFAULT_URL
38
+ }
39
+
40
+ export const reportUrl = (home: string) => `${gatewayUrl(home)}${REPORT_PATH}`
41
+
42
+ /** The key goes over https only (plain http only to a gateway on this machine), as `brain gateway connect` allows. */
43
+ export function safeUrl(url: string): boolean {
44
+ try {
45
+ const u = new URL(url)
46
+ if (u.username || u.password) return false
47
+ if (u.protocol === "https:") return true
48
+ return u.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(u.hostname)
49
+ } catch {
50
+ return false
51
+ }
52
+ }
53
+
54
+ export const signedIn = (home: string) => readKey(home).length > 0
55
+
56
+ /** Why nothing is sent right now, or undefined when reports go out (signed in or not is checked separately). */
57
+ export function sendingOff(home: string, env: Env = process.env): string | undefined {
58
+ const v = env.BRAIN_NO_REPORT
59
+ if (v !== undefined && v !== "" && v !== "0" && v.toLowerCase() !== "false") return "BRAIN_NO_REPORT is set"
60
+ if (readSettings(home).off) return "turned off with `brain dashboard off`"
61
+ return undefined
62
+ }
63
+
64
+ /** What the first-time notice says. */
65
+ export const NOTICE = [
66
+ "brain: this machine now sends a report to your 100xprompt account, for the Brain view of the console",
67
+ ` (${DASHBOARD_URL}). It holds: this computer's name, OS and brain version; the coding tools found`,
68
+ " here, their session counts, and which are connected to the gateway or have brain's MCP tools or hooks; your gateway",
69
+ " settings and memory counts; and each chat brain moved between tools: when, from and to which tool, its title and its",
70
+ " project folder's name. Never chat text, file contents, full paths or keys.",
71
+ " Stop it: brain dashboard off",
72
+ ]
73
+
74
+ /** Prints the notice the first time a report is about to go out, and remembers that it did. */
75
+ export function noticeOnce(home: string, print: (line: string) => void): boolean {
76
+ if (readSettings(home).noticeShownAt) return false
77
+ NOTICE.forEach((l) => print(l))
78
+ try {
79
+ updateSettings(home, (s) => {
80
+ s.noticeShownAt = new Date().toISOString()
81
+ })
82
+ } catch {}
83
+ return true
84
+ }
85
+
86
+ export type SendResult = { ok: true; machines?: number } | { ok: false; why: string }
87
+
88
+ /** POSTs the report. Never throws and never says the key; records when it went out (or why not) for `brain dashboard status`. */
89
+ export async function sendReport(home: string, report: BrainReport, o: { fetchImpl?: typeof fetch; timeoutMs?: number } = {}): Promise<SendResult> {
90
+ const r = await post(home, report, o)
91
+ updateCache(home, (c) => {
92
+ const at = new Date().toISOString()
93
+ c.push = { ...c.push, lastAttemptAt: at }
94
+ if (r.ok) {
95
+ c.push.lastSentAt = at
96
+ delete c.push.lastError
97
+ } else c.push.lastError = r.why
98
+ })
99
+ return r
100
+ }
101
+
102
+ async function post(home: string, report: BrainReport, o: { fetchImpl?: typeof fetch; timeoutMs?: number }): Promise<SendResult> {
103
+ const key = readKey(home)
104
+ if (!key) return { ok: false, why: "not signed in (brain gateway login)" }
105
+ const url = reportUrl(home)
106
+ if (!safeUrl(url)) return { ok: false, why: `the gateway address ${gatewayUrl(home)} is not https; the key is never sent over it` }
107
+ const { reportBody } = await import("./report.ts")
108
+ const timeoutMs = o.timeoutMs ?? SEND_TIMEOUT_MS
109
+ try {
110
+ const res = await (o.fetchImpl ?? fetch)(url, {
111
+ method: "POST",
112
+ headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
113
+ body: reportBody(report),
114
+ // A redirect is not followed: it could carry the key to another host.
115
+ redirect: "manual",
116
+ signal: AbortSignal.timeout(timeoutMs),
117
+ })
118
+ if (res.ok) {
119
+ const j = (await res.json().catch(() => undefined)) as { machines?: unknown } | undefined
120
+ return { ok: true, ...(typeof j?.machines === "number" ? { machines: j.machines } : {}) }
121
+ }
122
+ const said = await res
123
+ .json()
124
+ .then((j: any) => (typeof j?.error === "string" ? j.error : typeof j?.error?.message === "string" ? j.error.message : typeof j?.reason === "string" ? j.reason : ""))
125
+ .catch(() => "")
126
+ const why =
127
+ res.status === 401 || res.status === 403 ? "the gateway refused the key (sign in again: brain gateway login)"
128
+ : res.status === 404 ? "this gateway does not take brain reports yet"
129
+ : res.status === 413 ? "the report was too big for the gateway"
130
+ : res.status === 429 ? "too many reports for now; the next one carries everything"
131
+ : res.status >= 300 && res.status < 400 ? `the gateway answered with a redirect (HTTP ${res.status}); not followed`
132
+ : `the gateway answered HTTP ${res.status}`
133
+ return { ok: false, why: said ? `${why}: ${String(said).slice(0, 200)}` : why }
134
+ } catch (e) {
135
+ const name = (e as Error)?.name
136
+ return { ok: false, why: name === "TimeoutError" || name === "AbortError" ? `the gateway did not answer within ${Math.round(timeoutMs / 1000)} s` : `could not reach ${gatewayUrl(home)}` }
137
+ }
138
+ }
139
+
140
+ /**
141
+ * When the next background push may run, given the last one: now, or 30 s after the last one started. undefined when one
142
+ * is already waiting (it reads the state when it runs, so it carries this change too).
143
+ */
144
+ export function planPush(p: { lastStartMs?: number; pendingUntilMs?: number }, now: number, every = REPORT_EVERY_MS): { at: number } | undefined {
145
+ if (typeof p.pendingUntilMs === "number" && now < p.pendingUntilMs + PENDING_STALE_MS) return undefined
146
+ const last = typeof p.lastStartMs === "number" && p.lastStartMs <= now ? p.lastStartMs : -Infinity
147
+ return { at: Math.max(now, last + every) }
148
+ }
149
+
150
+ /**
151
+ * After a command that changed what the report says: starts `brain dashboard push --quiet` in a detached process and
152
+ * returns at once. Nothing at all when not signed in or turned off. Never throws.
153
+ */
154
+ export function reportInBackground(home: string, o: { env?: Env; cli?: string; say?: (line: string) => void; now?: number } = {}): boolean {
155
+ const env = o.env ?? process.env
156
+ try {
157
+ if (!signedIn(home) || sendingOff(home, env)) return false
158
+ noticeOnce(home, o.say ?? ((l) => process.stderr.write(`${l}\n`)))
159
+ const plan = planPush(readCache(home).push ?? {}, o.now ?? Date.now(), reportEvery(env))
160
+ if (!plan) return false
161
+ updateCache(home, (c) => {
162
+ c.push = { ...c.push, pendingUntilMs: plan.at }
163
+ })
164
+ try {
165
+ const child = Bun.spawn([process.execPath, o.cli ?? CLI_PATH, "dashboard", "push", "--quiet", "--at", String(plan.at)], {
166
+ cwd: home,
167
+ detached: true,
168
+ stdio: ["ignore", "ignore", "ignore"],
169
+ env: { ...env, BRAIN_HOME: home },
170
+ })
171
+ child.unref()
172
+ } catch {
173
+ // Nothing started: the mark must not hold up the next command's push.
174
+ updateCache(home, (c) => {
175
+ if (c.push?.pendingUntilMs === plan.at) delete c.push.pendingUntilMs
176
+ })
177
+ return false
178
+ }
179
+ return true
180
+ } catch {
181
+ return false
182
+ }
183
+ }
184
+
185
+ /** The start of a background push: waits for its time, then clears its mark, so a later command schedules the next. */
186
+ export async function startScheduled(home: string, at: number, env: Env = process.env): Promise<void> {
187
+ const wait = Math.min(at - Date.now(), reportEvery(env) + 5_000)
188
+ if (wait > 0) await Bun.sleep(wait)
189
+ const now = Date.now()
190
+ updateCache(home, (c) => {
191
+ c.push = { ...c.push, lastStartMs: now }
192
+ if (typeof c.push.pendingUntilMs === "number" && c.push.pendingUntilMs <= Math.max(at, now)) delete c.push.pendingUntilMs
193
+ })
194
+ }
195
+
196
+ /**
197
+ * The last report before `brain gateway logout` forgets the key: the same snapshot, marked signed out, so the console does
198
+ * not go on showing this machine as signed in. Sent while the key is still there (after logout nothing can be sent),
199
+ * waited for briefly, best effort; nothing when reports are off or the person is not signed in.
200
+ */
201
+ export async function reportSignedOut(home: string, o: { env?: Env; fetchImpl?: typeof fetch; timeoutMs?: number } = {}): Promise<SendResult | undefined> {
202
+ const env = o.env ?? process.env
203
+ if (sendingOff(home, env) || !readKey(home)) return undefined
204
+ try {
205
+ const { buildReport } = await import("./report.ts")
206
+ const report = await buildReport(home, { env })
207
+ report.gateway = { ...report.gateway, signedIn: false }
208
+ delete (report.gateway as { email?: string }).email
209
+ return await sendReport(home, report, { timeoutMs: o.timeoutMs ?? 3_000, ...(o.fetchImpl ? { fetchImpl: o.fetchImpl } : {}) })
210
+ } catch {
211
+ return undefined
212
+ }
213
+ }
@@ -0,0 +1,95 @@
1
+ // The small files the dashboard report keeps under <home>/.brain (all 0600, in a 0700 folder):
2
+ // machine.json { id, createdAt }: this machine's random id, made once (the console tells machines apart by it)
3
+ // dashboard.json the person's choice: { off?, noticeShownAt? }. Written only by the person's own commands, never
4
+ // by a background push, so a push can never undo `brain dashboard off`.
5
+ // dashboard-cache.json what brain works out or remembers by itself: the last session counts, and the push bookkeeping
6
+ // (when the last report went out, a push that is waiting to run).
7
+ // Every write re-reads the file first and changes only its own fields, then replaces the file atomically.
8
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"
9
+ import { dirname, join } from "node:path"
10
+
11
+ export const brainDir = (home: string) => join(home, ".brain")
12
+ export const machinePath = (home: string) => join(brainDir(home), "machine.json")
13
+ export const settingsPath = (home: string) => join(brainDir(home), "dashboard.json")
14
+ export const cachePath = (home: string) => join(brainDir(home), "dashboard-cache.json")
15
+
16
+ export interface DashboardSettings {
17
+ /** `brain dashboard off`: nothing is sent. */
18
+ off?: boolean
19
+ /** When the first-time notice (what is sent, how to stop it) was shown. */
20
+ noticeShownAt?: string
21
+ }
22
+
23
+ export interface ToolCount {
24
+ sessions: number
25
+ lastSessionAt: string | null
26
+ }
27
+
28
+ export interface DashboardCache {
29
+ /** Sessions per tool id, from the last `brain dashboard` (counting is expensive: background reports reuse these). */
30
+ counts?: { at: string; tools: Record<string, ToolCount> }
31
+ push?: {
32
+ /** When the last background push started (epoch ms): the next one waits until 30 s after it. */
33
+ lastStartMs?: number
34
+ /** A background push is waiting to run at this time (epoch ms); it reads the state when it runs. */
35
+ pendingUntilMs?: number
36
+ lastAttemptAt?: string
37
+ lastSentAt?: string
38
+ /** Why the last attempt failed (cleared by a success). */
39
+ lastError?: string
40
+ }
41
+ }
42
+
43
+ function readJson<T extends object>(path: string): T {
44
+ try {
45
+ const v = JSON.parse(readFileSync(path, "utf8"))
46
+ return v && typeof v === "object" && !Array.isArray(v) ? (v as T) : ({} as T)
47
+ } catch {
48
+ return {} as T
49
+ }
50
+ }
51
+
52
+ /** Atomic write, 0600, in a 0700 <home>/.brain. */
53
+ export function writePrivateJson(path: string, value: unknown) {
54
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
55
+ const tmp = `${path}.${process.pid}.brain-tmp`
56
+ try {
57
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 })
58
+ chmodSync(tmp, 0o600)
59
+ renameSync(tmp, path)
60
+ } finally {
61
+ rmSync(tmp, { force: true })
62
+ }
63
+ }
64
+
65
+ export const readSettings = (home: string): DashboardSettings => readJson<DashboardSettings>(settingsPath(home))
66
+ export function updateSettings(home: string, change: (s: DashboardSettings) => void): DashboardSettings {
67
+ const s = readSettings(home)
68
+ change(s)
69
+ writePrivateJson(settingsPath(home), s)
70
+ return s
71
+ }
72
+
73
+ export const readCache = (home: string): DashboardCache => readJson<DashboardCache>(cachePath(home))
74
+ /** Best effort: bookkeeping that cannot be written is not a reason to fail. */
75
+ export function updateCache(home: string, change: (c: DashboardCache) => void): DashboardCache {
76
+ const c = readCache(home)
77
+ change(c)
78
+ try {
79
+ writePrivateJson(cachePath(home), c)
80
+ } catch {}
81
+ return c
82
+ }
83
+
84
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
85
+
86
+ /** This machine's id: made once, kept in <home>/.brain/machine.json. A missing or damaged file gets a new one. */
87
+ export function machineId(home: string): string {
88
+ const m = readJson<{ id?: unknown; createdAt?: unknown }>(machinePath(home))
89
+ if (typeof m.id === "string" && UUID.test(m.id)) return m.id
90
+ const id = crypto.randomUUID()
91
+ try {
92
+ writePrivateJson(machinePath(home), { id, createdAt: new Date().toISOString() })
93
+ } catch {}
94
+ return id
95
+ }
package/src/fsutil.ts ADDED
@@ -0,0 +1,133 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"
2
+ import { join, resolve } from "node:path"
3
+ import type { Project } from "./session/types.ts"
4
+
5
+ /** The home folder readers and writers work in: $BRAIN_HOME in tests, else the real one. */
6
+ export function homeDir(): string {
7
+ return process.env.BRAIN_HOME ?? process.env.HOME ?? ""
8
+ }
9
+
10
+ /** macOS application-support folder for a home. */
11
+ export const appSupport = (home: string) => join(home, "Library", "Application Support")
12
+
13
+ export const exists = (p: string) => existsSync(p)
14
+
15
+ export function isDir(p: string): boolean {
16
+ try {
17
+ return statSync(p).isDirectory()
18
+ } catch {
19
+ return false
20
+ }
21
+ }
22
+
23
+ export function mtime(p: string): string | undefined {
24
+ try {
25
+ return statSync(p).mtime.toISOString()
26
+ } catch {
27
+ return undefined
28
+ }
29
+ }
30
+
31
+ /** Directory entries, or none when the directory is missing or unreadable. */
32
+ export function ls(dir: string): string[] {
33
+ try {
34
+ return readdirSync(dir)
35
+ } catch {
36
+ return []
37
+ }
38
+ }
39
+
40
+ /** Files under `dir` whose names match, depth-first, at most `depth` levels down. */
41
+ export function findFiles(dir: string, match: (name: string) => boolean, depth = 4): string[] {
42
+ const out: string[] = []
43
+ const walk = (d: string, left: number) => {
44
+ for (const name of ls(d)) {
45
+ const p = join(d, name)
46
+ if (isDir(p)) {
47
+ if (left > 0) walk(p, left - 1)
48
+ } else if (match(name)) out.push(p)
49
+ }
50
+ }
51
+ walk(dir, depth)
52
+ return out
53
+ }
54
+
55
+ /** JSON from a file, or undefined when it is missing or not JSON. */
56
+ export function readJson<T = unknown>(p: string): T | undefined {
57
+ try {
58
+ return JSON.parse(readFileSync(p, "utf8")) as T
59
+ } catch {
60
+ return undefined
61
+ }
62
+ }
63
+
64
+ /** Each line of a JSONL file parsed, skipping lines that are not JSON (a half-written last line, say); `bad` counts them. */
65
+ export function readJsonl(p: string): { rows: Array<Record<string, any>>; bad: number } {
66
+ let text = ""
67
+ try {
68
+ text = readFileSync(p, "utf8")
69
+ } catch {
70
+ return { rows: [], bad: 0 }
71
+ }
72
+ const rows: Array<Record<string, any>> = []
73
+ let bad = 0
74
+ for (const line of text.split("\n")) {
75
+ if (!line.trim()) continue
76
+ try {
77
+ rows.push(JSON.parse(line))
78
+ } catch {
79
+ bad++
80
+ }
81
+ }
82
+ return { rows, bad }
83
+ }
84
+
85
+ /**
86
+ * Git remote, branch and commit for a folder, read from .git without running git; empty when it is not a
87
+ * repository. The walk up stops below the home folder: a dotfiles repository at ~/.git would otherwise make
88
+ * every project under home look like one project.
89
+ */
90
+ export function gitInfo(cwd: string | undefined, home = homeDir()): Omit<Project, "cwd"> {
91
+ if (!cwd) return {}
92
+ const stop = home.replace(/\/+$/, "")
93
+ let dir = cwd
94
+ for (let i = 0; i < 30 && dir && dir !== "/" && dir !== stop; i++) {
95
+ const git = join(dir, ".git")
96
+ if (exists(git)) {
97
+ try {
98
+ // A worktree's .git is a file naming its git dir, absolute or relative to the worktree.
99
+ const gitDir = isDir(git) ? git : resolve(dir, readFileSync(git, "utf8").replace(/^gitdir:\s*/, "").trim())
100
+ const head = readFileSync(join(gitDir, "HEAD"), "utf8").trim()
101
+ const branch = head.startsWith("ref: refs/heads/") ? head.slice(16) : undefined
102
+ let commit: string | undefined = head.startsWith("ref:") ? undefined : head
103
+ if (branch) {
104
+ const ref = join(gitDir, "refs", "heads", branch)
105
+ commit = exists(ref) ? readFileSync(ref, "utf8").trim() : undefined
106
+ }
107
+ const config = exists(join(gitDir, "config")) ? readFileSync(join(gitDir, "config"), "utf8") : ""
108
+ const remote = config.match(/\[remote "origin"\][^[]*?url\s*=\s*(\S+)/)?.[1]
109
+ return { ...(remote ? { gitRemote: remote } : {}), ...(branch ? { gitBranch: branch } : {}), ...(commit ? { gitCommit: commit } : {}) }
110
+ } catch {
111
+ return {}
112
+ }
113
+ }
114
+ dir = dir.slice(0, dir.lastIndexOf("/"))
115
+ }
116
+ return {}
117
+ }
118
+
119
+ /** Whether a process with this exact name is running (a desktop app whose store must not be written while open). */
120
+ export function isRunning(processName: string): boolean {
121
+ const p = Bun.spawnSync(["pgrep", "-x", processName], { stdout: "pipe", stderr: "pipe" })
122
+ return p.exitCode === 0
123
+ }
124
+
125
+ /** The first line of `<bin> --version`, or undefined when the binary is missing. */
126
+ export function versionOf(bin: string): string | undefined {
127
+ try {
128
+ const p = Bun.spawnSync([bin, "--version"], { env: process.env, stdout: "pipe", stderr: "pipe", timeout: 10_000 })
129
+ return p.exitCode === 0 ? p.stdout.toString().trim().split("\n")[0] : undefined
130
+ } catch {
131
+ return undefined
132
+ }
133
+ }
@@ -0,0 +1,232 @@
1
+ // What every gateway connector shares: the settings, the shape of a connector, where the key lives, and the helpers a
2
+ // connector needs to describe its edits. connect.ts does the reading, writing, recording and undoing; a connector only
3
+ // says which files it changes and to what (src/gateway/connectors/<tool>.ts).
4
+ import { existsSync, readFileSync } from "node:fs"
5
+ import { join } from "node:path"
6
+
7
+ export type Json = Record<string, any>
8
+ export type Env = Record<string, string | undefined>
9
+
10
+ export const DEFAULT_URL = "https://proxy.100xprompt.com"
11
+ export const DEFAULT_ROUTE = "flash"
12
+ export const DEFAULT_MODEL = "gemini-3.8-flash"
13
+ export const EDIT_HEADER = "x-100x-context-edit"
14
+
15
+ /**
16
+ * What 100xprompt calls the models brain may connect a tool to, as the 100xprompt CLI shows them: tools are given the
17
+ * gateway's model id (that is what the gateway serves), and everything brain prints uses the product name.
18
+ */
19
+ export const MODEL_NAMES: Record<string, string> = { "gemini-3.8-flash": "100xprompt Pro", "gemini-3.7-flash": "100xprompt Flash", "gemini-3.6-flash": "100xprompt Flash" }
20
+ /** `--model pro` / `--model flash`: the product names, turned into the gateway's model ids. */
21
+ export const MODEL_ALIASES: Record<string, string> = { pro: "gemini-3.8-flash", flash: "gemini-3.7-flash" }
22
+ export const modelName = (id: string) => MODEL_NAMES[id] ?? id
23
+ export const modelId = (given: string) => MODEL_ALIASES[given.trim().toLowerCase()] ?? given.trim()
24
+
25
+ export interface GatewaySettings {
26
+ url: string
27
+ route: string
28
+ /** The gateway's model id (gemini-3.8-flash for 100xprompt Pro). Show it to people with modelName(). */
29
+ model: string
30
+ /** Send `x-100x-context-edit: on` (the gateway clears old tool results when that saves money). */
31
+ contextEdit: boolean
32
+ }
33
+
34
+ export const brainDir = (home: string) => join(home, ".brain")
35
+ export const statePath = (home: string) => join(brainDir(home), "gateway.json")
36
+ /** The gateway key, 0600, one line. A tool that can read a key from a file, a command or an env file should read it here. */
37
+ export const keyPath = (home: string) => join(brainDir(home), "gateway.key")
38
+
39
+ /** The stored key ("" when there is none: a dry run shows what would change without one). Never print it. */
40
+ export function readKey(home: string): string {
41
+ try {
42
+ return readFileSync(keyPath(home), "utf8").trim()
43
+ } catch {
44
+ return ""
45
+ }
46
+ }
47
+
48
+ /**
49
+ * The gateway route's base address, e.g. https://proxy.100xprompt.com/flash. Under it the gateway serves, for every model:
50
+ * /v1/chat/completions OpenAI Chat Completions (Authorization: Bearer <key>)
51
+ * /v1/responses OpenAI Responses (Authorization: Bearer <key>)
52
+ * /v1/messages Anthropic Messages (x-api-key: <key>, or Authorization: Bearer)
53
+ * /v1beta/models/<id>:generateContent and :streamGenerateContent Gemini (x-goog-api-key: <key>)
54
+ * /v1/models answers 403 ("not served"), so a tool that insists on listing models first needs the model named in its config.
55
+ */
56
+ export const routeUrl = (s: GatewaySettings) => `${s.url.replace(/\/+$/, "")}/${s.route}`
57
+
58
+ /** Single-quoted for a POSIX shell. */
59
+ export const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
60
+
61
+ /** An env-overridable folder that only counts when it exists (CLAUDE_CONFIG_DIR, CODEX_HOME and the like). */
62
+ export const dirFromEnv = (env: Env, name: string, fallback: string) => (env[name] && existsSync(env[name]!) ? env[name]! : fallback)
63
+
64
+ /**
65
+ * A file format the core does not read itself (YAML, JSON with comments, a tool's own format): the connector reads and
66
+ * writes it. Keys are whatever the codec understands (for yamlTopLevel: a top-level key).
67
+ */
68
+ export interface Codec {
69
+ /** undefined when the file can be changed safely; otherwise why not (the file is then left alone and nothing changes). */
70
+ check?(text: string): string | undefined
71
+ /** The value at `key` (undefined when absent), compared with what was written to tell whether it is still brain's. */
72
+ get(text: string, key: string): unknown
73
+ /** The whole text with `key` set to `value`, or removed when `value` is undefined. Everything else stays as it was. */
74
+ set(text: string, key: string, value: unknown): string
75
+ }
76
+
77
+ /**
78
+ * json plain JSON object; keys are paths with "/" (env/ANTHROPIC_BASE_URL). A file with comments is left alone.
79
+ * toml Codex's config.toml handling (top-level keys and the [model_providers.brain-gateway] table only).
80
+ * dotenv KEY=value lines.
81
+ * custom the edit's `codec` reads and writes it.
82
+ */
83
+ export type Format = "json" | "toml" | "dotenv" | "custom"
84
+
85
+ /** One file a connector changes, and the values it sets there. */
86
+ export interface FileEdit {
87
+ path: string
88
+ format: Format
89
+ /** Required with format "custom". */
90
+ codec?: Codec
91
+ values: Record<string, unknown>
92
+ /** The file holds the key: it is made private (0600). */
93
+ holdsKey?: boolean
94
+ /** Keys among `values` whose value contains the key: never shown or recorded, and replaced when the person signs in again. */
95
+ secretKeys?: string[]
96
+ }
97
+
98
+ export interface Connector {
99
+ /** The id people pass to --tool, the same id brain uses for the tool's sessions where it has one. */
100
+ id: string
101
+ label: string
102
+ /** The main file it edits (shown in status), absolute. */
103
+ file(home: string, env: Env): string
104
+ /** Folders or files (relative to home) whose presence shows the tool has run here, besides the main file or its folder. */
105
+ probe: string[]
106
+ /** Every file it changes, with the values to set. Called often (status, dry runs, disconnect): no side effects. */
107
+ edits(home: string, s: GatewaySettings, env: Env): FileEdit[]
108
+ /** How this tool sends the key, for the free key check before connecting. */
109
+ authHeader: "x-api-key" | "authorization" | "x-goog-api-key"
110
+ /** What the person should know (shown on connect). */
111
+ notes: string[]
112
+ }
113
+
114
+ // ---- JSON paths --------------------------------------------------------------------------------------------------------
115
+
116
+ const split = (k: string) => k.split("/")
117
+ export function getAt(o: Json, key: string): unknown {
118
+ let cur: any = o
119
+ for (const p of split(key)) {
120
+ if (!cur || typeof cur !== "object" || !(p in cur)) return undefined
121
+ cur = cur[p]
122
+ }
123
+ return cur
124
+ }
125
+ export function setAt(o: Json, key: string, v: unknown) {
126
+ const ps = split(key)
127
+ let cur: any = o
128
+ for (const p of ps.slice(0, -1)) {
129
+ if (!cur[p] || typeof cur[p] !== "object" || Array.isArray(cur[p])) cur[p] = {}
130
+ cur = cur[p]
131
+ }
132
+ cur[ps[ps.length - 1]!] = v
133
+ }
134
+ export function deleteAt(o: Json, key: string) {
135
+ const ps = split(key)
136
+ const parents: any[] = [o]
137
+ let cur: any = o
138
+ for (const p of ps.slice(0, -1)) {
139
+ if (!cur || typeof cur !== "object") return
140
+ cur = cur[p]
141
+ parents.push(cur)
142
+ }
143
+ if (cur && typeof cur === "object") delete cur[ps[ps.length - 1]!]
144
+ // Drop objects this left empty (env: {} after the last key went), never the root.
145
+ for (let i = ps.length - 1; i >= 1; i--) {
146
+ const parent = parents[i - 1]
147
+ const child = parent?.[ps[i - 1]!]
148
+ if (child && typeof child === "object" && !Array.isArray(child) && !Object.keys(child).length) delete parent[ps[i - 1]!]
149
+ }
150
+ }
151
+
152
+ // ---- YAML, top-level keys only ------------------------------------------------------------------------------------------
153
+
154
+ /**
155
+ * A YAML scalar or flow collection. A plain string is written as it is when YAML reads it back as the same string
156
+ * (`local-model`, `https://host/v1`); anything else is JSON (valid YAML), which keeps "on", "no" and "1.0" strings.
157
+ */
158
+ // Words and numbers YAML 1.1 readers (PyYAML: Aider, Hermes) take for booleans, null or numbers, though Bun's YAML 1.2
159
+ // reads them as strings: they are always quoted.
160
+ const YAML11_SPECIAL = /^(?:y|n|yes|no|on|off|true|false|null|~|[-+]?\.?(?:inf|nan)|[-+]?(?:0x[0-9a-f_]+|0o?[0-7_]+|0b[01_]+|[0-9][0-9_:]*(?:\.[0-9_]*)?(?:e[-+]?[0-9]+)?))$/i
161
+
162
+ function yamlValue(v: unknown): string {
163
+ if (typeof v === "string" && /^[A-Za-z0-9_./@-][A-Za-z0-9_./:@+=-]*$/.test(v) && !YAML11_SPECIAL.test(v)) {
164
+ try {
165
+ if ((Bun.YAML.parse(`k: ${v}`) as Json).k === v) return v
166
+ } catch {}
167
+ }
168
+ return JSON.stringify(v)
169
+ }
170
+
171
+ /** The ` # comment` at the end of a one-line YAML entry (a # after a space, outside quotes), with the spaces before it. */
172
+ function yamlComment(line: string): string {
173
+ let quote: string | undefined
174
+ for (let i = line.indexOf(":") + 1; i > 0 && i < line.length; i++) {
175
+ const c = line[i]!
176
+ if (quote) {
177
+ if (c === "\\" && quote === '"') i++
178
+ else if (c === quote) quote = undefined
179
+ } else if (c === '"' || c === "'") quote = c
180
+ else if (c === "#" && /[ \t]/.test(line[i - 1]!)) {
181
+ let j = i
182
+ while (j > 0 && /[ \t]/.test(line[j - 1]!)) j--
183
+ return line.slice(j).trimEnd()
184
+ }
185
+ }
186
+ return ""
187
+ }
188
+
189
+ /**
190
+ * Edits top-level `key: value` entries of a YAML file and leaves every other line (comments included) exactly as it was.
191
+ * A value is written on one line (strings quoted; objects and lists in flow style, which YAML reads like block style). An
192
+ * existing entry is replaced together with its indented block, if it had one; an entry that is not there is added at the
193
+ * end. Keys are top-level names only (no paths).
194
+ */
195
+ export const yamlTopLevel: Codec = {
196
+ check(text) {
197
+ if (!text.trim()) return undefined
198
+ if (/^---\s*$/m.test(text.replace(/^---\s*\n/, ""))) return "it holds more than one YAML document; left as it is"
199
+ try {
200
+ const v = Bun.YAML.parse(text)
201
+ if (v !== null && (typeof v !== "object" || Array.isArray(v))) return "it is not a YAML mapping; left as it is"
202
+ } catch {
203
+ return "it is not valid YAML; left as it is"
204
+ }
205
+ return undefined
206
+ },
207
+ get(text, key) {
208
+ if (!text.trim()) return undefined
209
+ const v = Bun.YAML.parse(text) as Json | null
210
+ return v && typeof v === "object" ? v[key] : undefined
211
+ },
212
+ set(text, key, value) {
213
+ const lines = text ? text.replace(/\n$/, "").split("\n") : []
214
+ const esc = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
215
+ const head = new RegExp(`^(?:${esc}|"${esc}"|'${esc}')\\s*:(?:\\s|$)`)
216
+ const at = lines.findIndex((l) => head.test(l))
217
+ let end = at + 1
218
+ // The entry's block: the indented (or blank, or comment) lines that follow it, up to the next top-level line.
219
+ if (at >= 0) {
220
+ while (end < lines.length && (/^[ \t]/.test(lines[end]!) || lines[end]!.trim() === "" || /^[ \t]*-\s/.test(lines[end]!))) end++
221
+ // Blank lines and comments at the end of that run belong to whatever comes next, not to this entry.
222
+ while (end > at + 1 && (lines[end - 1]!.trim() === "" || /^\s*#/.test(lines[end - 1]!))) end--
223
+ }
224
+ // A one-line entry keeps its trailing comment, so a later restore gives the line back as it was.
225
+ const line = `${key}: ${yamlValue(value)}${at >= 0 && end === at + 1 ? yamlComment(lines[at]!) : ""}`
226
+ if (value === undefined) {
227
+ if (at >= 0) lines.splice(at, end - at)
228
+ } else if (at >= 0) lines.splice(at, end - at, line)
229
+ else lines.push(line)
230
+ return lines.length ? `${lines.join("\n")}\n` : ""
231
+ },
232
+ }