@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,404 @@
1
+ // The organisation client: a fetch-based HTTP client for the brain org server (`OrgClient`), and `openRemoteMemory`, which makes the
2
+ // server look like the local `Memory` so the rest of the brain (prepare, sync, the MCP server) needs no changes.
3
+ //
4
+ // Trust model of this file: the server is trusted with the distilled items it is sent, and NOT trusted to be well-formed (a hostile or
5
+ // old server must not crash the client: every response is validated). The client-side policy check is a courtesy that keeps denied
6
+ // items on the machine; the server re-checks everything. The bearer token is only ever put in the Authorization header: never in a
7
+ // URL, an error message, a log line or a redirect (redirects are refused, so the header cannot be forwarded to another host).
8
+ // Nothing is queued on disk when the server is down: items either reach the server or stay in the local session files they came from.
9
+ import { readFileSync } from "node:fs"
10
+ import type { BrainMemory, MemoryCounts } from "../memory/store.ts"
11
+ import type { MemoryItem, MemoryKind, MemoryStatus, ProjectKey, Recalled } from "../memory/types.ts"
12
+ import { SCOPES, scopeRank, type AuditEvent, type ForgetRequest, type IngestResponse, type OrgConfig, type OrgRecalled, type OrgUser, type Policy, type ProjectsResponse, type Role, type Scope, type WhoAmI } from "./types.ts"
13
+
14
+ export class OrgHttpError extends Error {
15
+ constructor(
16
+ readonly status: number,
17
+ readonly code: string,
18
+ message: string,
19
+ ) {
20
+ super(message)
21
+ this.name = "OrgHttpError"
22
+ }
23
+ }
24
+
25
+ const KINDS: MemoryKind[] = ["decision", "constraint", "preference", "failed", "file", "command", "summary", "state"]
26
+ const ROLES: Role[] = ["viewer", "editor", "admin"]
27
+ const MAX_RESPONSE_CHARS = 8_000_000
28
+
29
+ const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
30
+ const isStr = (v: unknown): v is string => typeof v === "string"
31
+ const isScope = (v: unknown): v is Scope => isStr(v) && (SCOPES as string[]).includes(v)
32
+ const bad = (what: string): never => {
33
+ throw new OrgHttpError(502, "bad_response", `the server sent an unexpected ${what}; is it a brain org server of the same version?`)
34
+ }
35
+
36
+ // ---- response validators --------------------------------------------------------------------------------------------
37
+
38
+ function parsePolicy(v: unknown): Policy {
39
+ if (!isObj(v) || !isScope(v.maxScope) || !isScope(v.defaultScope) || !Array.isArray(v.denyPaths) || !Array.isArray(v.denyTerms)) return bad("policy")
40
+ const p: Policy = { maxScope: v.maxScope as Scope, defaultScope: v.defaultScope as Scope, denyPaths: (v.denyPaths as unknown[]).filter(isStr), denyTerms: (v.denyTerms as unknown[]).filter(isStr) }
41
+ if (Array.isArray(v.allowedKinds)) p.allowedKinds = (v.allowedKinds as unknown[]).filter((k): k is MemoryKind => KINDS.includes(k as MemoryKind))
42
+ if (typeof v.retentionDays === "number" && Number.isFinite(v.retentionDays) && v.retentionDays > 0) p.retentionDays = v.retentionDays
43
+ return p
44
+ }
45
+
46
+ function parseUser(v: unknown): OrgUser {
47
+ if (!isObj(v) || !isStr(v.id) || !isStr(v.name) || !ROLES.includes(v.role as Role)) return bad("user")
48
+ return { id: v.id as string, name: v.name as string, role: v.role as Role, teams: Array.isArray(v.teams) ? (v.teams as unknown[]).filter(isStr) : [], ...(v.disabled === true ? { disabled: true } : {}) }
49
+ }
50
+
51
+ export function parseWhoAmI(v: unknown): WhoAmI {
52
+ if (!isObj(v) || !isObj(v.org) || !isStr(v.org.id) || !isStr(v.org.name)) return bad("whoami answer")
53
+ return { user: parseUser(v.user), org: { id: v.org.id as string, name: v.org.name as string }, policy: parsePolicy(v.policy), api: 1 }
54
+ }
55
+
56
+ function parseRecalled(v: unknown): OrgRecalled {
57
+ if (!isObj(v) || !isStr(v.text) || !isScope(v.scope) || !isStr(v.owner)) return bad("recalled item")
58
+ const out: OrgRecalled = { text: v.text as string, score: typeof v.score === "number" && Number.isFinite(v.score) ? v.score : 0, scope: v.scope as Scope, owner: v.owner as string }
59
+ if (KINDS.includes(v.kind as MemoryKind)) out.kind = v.kind as MemoryKind
60
+ if (isStr(v.name)) out.name = v.name
61
+ if (isStr(v.source)) out.source = v.source
62
+ if (isStr(v.id)) out.id = v.id
63
+ if (isStr(v.project)) out.project = v.project
64
+ if (isStr(v.team)) out.team = v.team
65
+ return out
66
+ }
67
+
68
+ const count = (v: unknown, what: string): number => (typeof v === "number" && Number.isInteger(v) && v >= 0 ? v : (bad(what) as never))
69
+
70
+ function parseIngest(v: unknown): IngestResponse {
71
+ if (!isObj(v) || !isScope(v.scope)) return bad("ingest answer")
72
+ const dropped = Array.isArray(v.dropped) ? (v.dropped as unknown[]).filter(isObj).map((d) => ({ id: String(d.id ?? ""), reason: String(d.reason ?? "") })) : []
73
+ return { stored: count(v.stored, "ingest answer"), dropped, scope: v.scope as Scope }
74
+ }
75
+
76
+ function parseProjects(v: unknown): ProjectsResponse {
77
+ if (!isObj(v) || !Array.isArray(v.projects)) return bad("projects answer")
78
+ return { projects: (v.projects as unknown[]).filter(isObj).filter((p) => isStr(p.id)).map((p) => ({ id: p.id as string, label: isStr(p.label) ? p.label : (p.id as string), items: typeof p.items === "number" ? p.items : 0 })) }
79
+ }
80
+
81
+ /** Admin answers: the server sends a bare array; the wrapped form ({users: [...]}) is tolerated too, anything else is a bad response. */
82
+ const listOf = (v: unknown, key: string): unknown[] => (Array.isArray(v) ? v : isObj(v) && Array.isArray(v[key]) ? (v[key] as unknown[]) : (bad(`${key} list`) as never))
83
+
84
+ // ---- transport check ------------------------------------------------------------------------------------------------
85
+
86
+ export function isLoopbackHost(host: string): boolean {
87
+ const h = host.replace(/^\[|\]$/g, "").toLowerCase()
88
+ return h === "localhost" || h.endsWith(".localhost") || h === "::1" || /^127\.\d+\.\d+\.\d+$/.test(h)
89
+ }
90
+
91
+ /** Parses the server URL and refuses plain http to anything but loopback (unless BRAIN_ORG_INSECURE=1). Returns the base URL, no trailing slash. */
92
+ export function checkServerUrl(server: string, env = process.env): string {
93
+ let u: URL
94
+ try {
95
+ u = new URL(server)
96
+ } catch {
97
+ throw new OrgHttpError(0, "bad_url", `"${server}" is not a URL; use e.g. https://brain.example.com`)
98
+ }
99
+ if (u.protocol !== "https:" && u.protocol !== "http:") throw new OrgHttpError(0, "bad_url", `unsupported scheme ${u.protocol}; use https://`)
100
+ if (u.username || u.password) throw new OrgHttpError(0, "bad_url", "put the token in --token, not in the URL")
101
+ if (u.protocol === "http:" && !isLoopbackHost(u.hostname) && env.BRAIN_ORG_INSECURE !== "1")
102
+ throw new OrgHttpError(0, "insecure_transport", `refusing http:// to ${u.hostname}: the token would cross the network in clear text. Use https://, or set BRAIN_ORG_INSECURE=1 if you accept that`)
103
+ return `${u.origin}${u.pathname.replace(/\/+$/, "")}`
104
+ }
105
+
106
+ /** fetch that trusts the certificate authority in the PEM file `ca` (a private or internal CA) for https servers. The https-only rule and
107
+ * the redirect refusal live in OrgClient and are unchanged; this only says whom to trust for the handshake. */
108
+ function fetchTrusting(ca: string): (input: string, init?: RequestInit) => Promise<Response> {
109
+ let pem: string
110
+ try {
111
+ pem = readFileSync(ca, "utf8")
112
+ } catch {
113
+ throw new OrgHttpError(0, "bad_ca", `cannot read the certificate authority file ${ca}`)
114
+ }
115
+ if (!pem.includes("BEGIN CERTIFICATE")) throw new OrgHttpError(0, "bad_ca", `${ca} does not hold a PEM certificate`)
116
+ return (input, init) => fetch(input, { ...init, tls: { ca: pem } } as RequestInit)
117
+ }
118
+
119
+ // ---- the client -----------------------------------------------------------------------------------------------------
120
+
121
+ export interface OrgClientOptions {
122
+ /** For tests. */
123
+ fetch?: (input: string, init?: RequestInit) => Promise<Response>
124
+ timeoutMs?: number
125
+ }
126
+
127
+ export class OrgClient {
128
+ private base: string
129
+ private token: string
130
+ private fetchImpl: (input: string, init?: RequestInit) => Promise<Response>
131
+ private timeoutMs: number
132
+
133
+ constructor(cfg: Pick<OrgConfig, "server" | "token"> & { ca?: string }, opts: OrgClientOptions = {}) {
134
+ this.base = checkServerUrl(cfg.server)
135
+ this.token = cfg.token
136
+ this.fetchImpl = opts.fetch ?? (cfg.ca ? fetchTrusting(cfg.ca) : fetch)
137
+ this.timeoutMs = opts.timeoutMs ?? 10_000
138
+ }
139
+
140
+ /** One request. `idempotent` calls are retried once on a network error (never on an HTTP answer). */
141
+ private async call(method: string, path: string, body: unknown, idempotent: boolean, auth = true): Promise<unknown> {
142
+ for (let attempt = 0; ; attempt++) {
143
+ const ctl = new AbortController()
144
+ const timer = setTimeout(() => ctl.abort(), this.timeoutMs)
145
+ let res: Response
146
+ let text: string
147
+ try {
148
+ res = await this.fetchImpl(`${this.base}${path}`, {
149
+ method,
150
+ redirect: "error",
151
+ signal: ctl.signal,
152
+ headers: { accept: "application/json", ...(body !== undefined ? { "content-type": "application/json" } : {}), ...(auth ? { authorization: `Bearer ${this.token}` } : {}) },
153
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
154
+ })
155
+ text = await res.text()
156
+ } catch (e) {
157
+ clearTimeout(timer)
158
+ if (attempt === 0 && idempotent) continue
159
+ const timedOut = ctl.signal.aborted
160
+ throw new OrgHttpError(0, timedOut ? "timeout" : "unreachable", timedOut ? `the server did not answer within ${this.timeoutMs / 1000}s` : `cannot reach the server (${String((e as Error)?.message ?? e).split("\n")[0]!.replaceAll(this.token, "***")})`)
161
+ }
162
+ clearTimeout(timer)
163
+ if (text.length > MAX_RESPONSE_CHARS) throw new OrgHttpError(502, "bad_response", "the server's answer is too large")
164
+ let json: unknown
165
+ try {
166
+ json = text ? JSON.parse(text) : undefined
167
+ } catch {
168
+ if (res.ok) return bad("answer (not JSON)")
169
+ }
170
+ if (!res.ok) {
171
+ const code = isObj(json) && isStr(json.code) ? json.code : `http_${res.status}`
172
+ const msg = isObj(json) && isStr(json.error) ? json.error : `the server answered ${res.status}`
173
+ throw new OrgHttpError(res.status, code.slice(0, 64), msg.slice(0, 500).replaceAll(this.token, "***"))
174
+ }
175
+ return json
176
+ }
177
+ }
178
+
179
+ health = async (): Promise<boolean> => isObj(await this.call("GET", "/v1/health", undefined, true, false))
180
+ whoami = async (): Promise<WhoAmI> => parseWhoAmI(await this.call("GET", "/v1/whoami", undefined, true))
181
+ async ingest(project: ProjectKey, items: MemoryItem[], opts: { scope?: Scope; team?: string } = {}): Promise<IngestResponse> {
182
+ return parseIngest(await this.call("POST", "/v1/ingest", { project, items, ...(opts.scope ? { scope: opts.scope } : {}), ...(opts.team ? { team: opts.team } : {}) }, true))
183
+ }
184
+ async recall(project: ProjectKey, query: string, opts: { limit?: number; maxTokens?: number } = {}): Promise<OrgRecalled[]> {
185
+ const r = await this.call("POST", "/v1/recall", { project, query, ...(opts.limit ? { limit: opts.limit } : {}), ...(opts.maxTokens ? { maxTokens: opts.maxTokens } : {}) }, true)
186
+ return listOf(r, "items").map(parseRecalled)
187
+ }
188
+ async promote(req: { ids?: string[]; source?: string; project?: ProjectKey; to: Scope; team?: string }): Promise<number> {
189
+ const r = await this.call("POST", "/v1/promote", { ...(req.ids ? { ids: req.ids } : {}), ...(req.source ? { source: req.source } : {}), ...(req.project ? { project: req.project } : {}), to: req.to, ...(req.team ? { team: req.team } : {}) }, true)
190
+ return count(isObj(r) ? r.changed : undefined, "promote answer")
191
+ }
192
+ async forget(req: ForgetRequest): Promise<number> {
193
+ const r = await this.call("POST", "/v1/forget", req, true)
194
+ return count(isObj(r) ? r.removed : undefined, "forget answer")
195
+ }
196
+ async projects(): Promise<ProjectsResponse["projects"]> {
197
+ return parseProjects(await this.call("GET", "/v1/projects", undefined, true)).projects
198
+ }
199
+
200
+ // ---- admin (role admin; the server enforces it) ----
201
+ async addUser(u: { id: string; name: string; role: Role; teams?: string[] }): Promise<OrgUser> {
202
+ return parseUser(await this.call("POST", "/v1/admin/users", u, false))
203
+ }
204
+ async listUsers(): Promise<OrgUser[]> {
205
+ return listOf(await this.call("GET", "/v1/admin/users", undefined, true), "users").map(parseUser)
206
+ }
207
+ async disableUser(id: string): Promise<void> {
208
+ await this.call("POST", "/v1/admin/users/disable", { id }, true)
209
+ }
210
+ /** The token is in the answer exactly once: the caller must show it and drop it. */
211
+ async createToken(user: string, expiresInDays?: number): Promise<{ id: string; token: string }> {
212
+ const r = await this.call("POST", "/v1/admin/tokens", { user, ...(expiresInDays ? { expiresInDays } : {}) }, false)
213
+ if (!isObj(r) || !isStr(r.id) || !isStr(r.token)) return bad("token answer")
214
+ return { id: r.id as string, token: r.token as string }
215
+ }
216
+ async listTokens(): Promise<Array<Record<string, unknown>>> {
217
+ // Whatever the server lists, a field named `token` is dropped here: a listing must never be able to put a secret on screen.
218
+ return listOf(await this.call("GET", "/v1/admin/tokens", undefined, true), "tokens").filter(isObj).map(({ token: _t, ...rest }) => rest)
219
+ }
220
+ async revokeToken(id: string): Promise<void> {
221
+ await this.call("POST", "/v1/admin/tokens/revoke", { id }, true)
222
+ }
223
+ async addTeam(t: { id: string; name: string; members: string[] }): Promise<void> {
224
+ await this.call("POST", "/v1/admin/teams", t, true)
225
+ }
226
+ async projectMembers(project: string, change: { add?: { users?: string[]; teams?: string[] }; remove?: { users?: string[]; teams?: string[] } }): Promise<void> {
227
+ await this.call("POST", "/v1/admin/projects/members", { project, ...change }, true)
228
+ }
229
+ async getPolicy(): Promise<Policy> {
230
+ return parsePolicy(await this.call("GET", "/v1/admin/policy", undefined, true))
231
+ }
232
+ async setPolicy(p: unknown): Promise<void> {
233
+ await this.call("PUT", "/v1/admin/policy", p, true)
234
+ }
235
+ async audit(q: { since?: string; limit?: number } = {}): Promise<AuditEvent[]> {
236
+ const qs = new URLSearchParams()
237
+ if (q.since) qs.set("since", q.since)
238
+ if (q.limit) qs.set("limit", String(q.limit))
239
+ const r = listOf(await this.call("GET", `/v1/admin/audit${qs.size ? `?${qs}` : ""}`, undefined, true), "events")
240
+ return r.filter(isObj).map((e) => ({ at: String(e.at ?? ""), actor: String(e.actor ?? "-"), action: String(e.action ?? ""), ...(isStr(e.project) ? { project: e.project } : {}), ...(isScope(e.scope) ? { scope: e.scope } : {}), ...(typeof e.count === "number" ? { count: e.count } : {}), ...(isStr(e.detail) ? { detail: e.detail } : {}) }))
241
+ }
242
+ async runRetention(): Promise<unknown> {
243
+ return this.call("POST", "/v1/admin/retention/run", {}, true)
244
+ }
245
+
246
+ // ---- single sign-on (see sso.ts; the first two need no token) ----
247
+ async ssoConfig(): Promise<{ enabled: boolean; issuer?: string; authorizationEndpoint?: string; clientId?: string; scopes?: string }> {
248
+ const r = await this.call("GET", "/v1/auth/sso/config", undefined, true, false)
249
+ if (!isObj(r) || typeof r.enabled !== "boolean") return bad("sign-in configuration")
250
+ return { enabled: r.enabled, ...(isStr(r.issuer) ? { issuer: r.issuer } : {}), ...(isStr(r.authorizationEndpoint) ? { authorizationEndpoint: r.authorizationEndpoint } : {}), ...(isStr(r.clientId) ? { clientId: r.clientId } : {}), ...(isStr(r.scopes) ? { scopes: r.scopes } : {}) }
251
+ }
252
+ /** Not retried: an authorization code works once. The token in the answer is for the caller to store, never to print. */
253
+ async ssoExchange(req: { code: string; codeVerifier: string; redirectUri: string; nonce: string }): Promise<{ token: string; user: OrgUser; expiresAt: string }> {
254
+ const r = await this.call("POST", "/v1/auth/sso/exchange", req, false, false)
255
+ if (!isObj(r) || !isStr(r.token) || !isStr(r.expiresAt)) return bad("sign-in answer")
256
+ return { token: r.token as string, user: parseUser(r.user), expiresAt: r.expiresAt as string }
257
+ }
258
+ async getSso(): Promise<Record<string, unknown>> {
259
+ const r = await this.call("GET", "/v1/admin/sso", undefined, true)
260
+ return isObj(r) ? r : (bad("sso configuration") as never)
261
+ }
262
+ async setSso(cfg: Record<string, unknown>): Promise<Record<string, unknown>> {
263
+ const r = await this.call("PUT", "/v1/admin/sso", cfg, true)
264
+ return isObj(r) ? r : (bad("sso configuration") as never)
265
+ }
266
+ }
267
+
268
+ // ---- remote memory --------------------------------------------------------------------------------------------------
269
+
270
+ interface PolicyFns {
271
+ checkItem(item: MemoryItem, policy: Policy): { ok: true } | { ok: false; reason: string }
272
+ effectiveScope(requested: Scope | undefined, policy: Policy): { ok: true; scope: Scope } | { ok: false; code: string; reason: string }
273
+ }
274
+
275
+ /** policy.ts is loaded lazily (and by a computed specifier) so this file does not depend on it being present to compile or to load. */
276
+ async function loadPolicy(): Promise<PolicyFns> {
277
+ const file = "./policy.ts"
278
+ return (await import(file)) as PolicyFns
279
+ }
280
+
281
+ export interface RemoteMemoryOptions extends OrgClientOptions {
282
+ /** For tests: replaces the policy functions. */
283
+ policy?: PolicyFns
284
+ now?: () => number
285
+ /** How long the whoami answer (and the policy in it) is reused. Default 5 minutes. */
286
+ policyTtlMs?: number
287
+ }
288
+
289
+ export type RemoteIngestResult = { stored: number; dropped?: Array<{ id: string; reason: string }>; reason?: string }
290
+
291
+ export function openRemoteMemory(cfg: OrgConfig, opts: RemoteMemoryOptions = {}): Omit<BrainMemory, "ingest"> & { ingest(project: ProjectKey, items: MemoryItem[], o?: { scope?: Scope; team?: string }): Promise<RemoteIngestResult>; client: OrgClient | undefined } {
292
+ let client: OrgClient | undefined
293
+ let clientErr: string | undefined
294
+ try {
295
+ client = new OrgClient(cfg, opts)
296
+ } catch (e) {
297
+ clientErr = (e as Error).message
298
+ }
299
+ const now = opts.now ?? Date.now
300
+ const ttl = opts.policyTtlMs ?? 5 * 60_000
301
+ let cached: { at: number; who: WhoAmI } | undefined
302
+ let policyFns: PolicyFns | undefined
303
+
304
+ const reasonOf = (e: unknown): string => {
305
+ if (e instanceof OrgHttpError) return e.status === 401 ? "the organisation server rejected the token (revoked, expired or disabled): run `brain org login` again" : e.message
306
+ return String((e as Error)?.message ?? e).split("\n")[0]!
307
+ }
308
+ const who = async (): Promise<WhoAmI> => {
309
+ if (!client) throw new Error(clientErr)
310
+ if (cached && now() - cached.at < ttl) return cached.who
311
+ const w = await client.whoami()
312
+ cached = { at: now(), who: w }
313
+ return w
314
+ }
315
+
316
+ return {
317
+ client,
318
+ async status(): Promise<MemoryStatus> {
319
+ try {
320
+ const w = await who()
321
+ const projects = await client!.projects().catch(() => [])
322
+ return { available: true, store: `${cfg.server} (organisation ${w.org.name}, you are ${w.user.id}, ${w.user.role})`, items: projects.reduce((n, p) => n + p.items, 0) }
323
+ } catch (e) {
324
+ cached = undefined
325
+ return { available: false, why: reasonOf(e) }
326
+ }
327
+ },
328
+
329
+ async ingest(project, items, o = {}): Promise<RemoteIngestResult> {
330
+ let w: WhoAmI
331
+ try {
332
+ w = await who()
333
+ } catch (e) {
334
+ return { stored: 0, reason: reasonOf(e) }
335
+ }
336
+ policyFns ??= opts.policy ?? (await loadPolicy().catch(() => undefined))
337
+ const requested = o.scope ?? cfg.defaultScope
338
+ let scope: Scope | undefined = requested
339
+ if (policyFns) {
340
+ const eff = policyFns.effectiveScope(requested, w.policy)
341
+ if (!eff.ok) return { stored: 0, reason: eff.reason }
342
+ scope = eff.scope
343
+ } else if (requested && scopeRank(requested) > scopeRank(w.policy.maxScope)) return { stored: 0, reason: `scope ${requested} is above the organisation's limit (${w.policy.maxScope})` }
344
+ // Denied items never leave the machine.
345
+ const keep: MemoryItem[] = []
346
+ const dropped: Array<{ id: string; reason: string }> = []
347
+ for (const item of items) {
348
+ const r = policyFns ? policyFns.checkItem(item, w.policy) : { ok: true as const }
349
+ if (r.ok) keep.push(item)
350
+ else dropped.push({ id: item.id, reason: r.reason })
351
+ }
352
+ if (!keep.length) return { stored: 0, dropped }
353
+ try {
354
+ const out: IngestResponse = { stored: 0, dropped: [], scope: scope ?? w.policy.defaultScope }
355
+ for (let i = 0; i < keep.length; i += 200) {
356
+ const r = await client!.ingest(project, keep.slice(i, i + 200), { ...(scope ? { scope } : {}), ...(o.team ? { team: o.team } : {}) })
357
+ out.stored += r.stored
358
+ out.dropped.push(...r.dropped)
359
+ }
360
+ return { stored: out.stored, dropped: [...dropped, ...out.dropped] }
361
+ } catch (e) {
362
+ if (e instanceof OrgHttpError && e.status === 401) cached = undefined
363
+ return { stored: 0, dropped, reason: reasonOf(e) }
364
+ }
365
+ },
366
+
367
+ async recall(project, query, o = {}): Promise<Recalled[]> {
368
+ if (!client || !query.trim()) return []
369
+ try {
370
+ return (await client.recall(project, query, o)).map((r) => ({ text: r.text, ...(r.kind ? { kind: r.kind } : {}), ...(r.name ? { name: r.name } : {}), score: r.score, ...(r.source ? { source: r.source } : {}), scope: r.scope, owner: r.owner }))
371
+ } catch {
372
+ return []
373
+ }
374
+ },
375
+
376
+ async forget(project, o = {}): Promise<number> {
377
+ if (!client) return 0
378
+ try {
379
+ return await client.forget({ project, mine: true, ...(o.source ? { source: o.source } : {}) })
380
+ } catch {
381
+ return 0
382
+ }
383
+ },
384
+
385
+ async counts(project?: ProjectKey): Promise<MemoryCounts> {
386
+ try {
387
+ const ps = (await client!.projects()).filter((p) => !project || p.id === project.id)
388
+ return { total: ps.reduce((n, p) => n + p.items, 0), byKind: {} }
389
+ } catch {
390
+ return { total: 0, byKind: {} }
391
+ }
392
+ },
393
+
394
+ async projects() {
395
+ try {
396
+ return await client!.projects()
397
+ } catch {
398
+ return []
399
+ }
400
+ },
401
+
402
+ close() {},
403
+ }
404
+ }
@@ -0,0 +1,44 @@
1
+ // The user-side organisation config: <home>/.brain/org.json (file mode 0600, folder 0700). It holds the server URL and the bearer
2
+ // token, so it is the one file here that must never be world-readable. Absent file = no organisation = the local store, as before.
3
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"
4
+ import { dirname, join } from "node:path"
5
+ import { SCOPES, type OrgConfig, type Scope } from "./types.ts"
6
+
7
+ export const orgConfigPath = (home: string): string => join(home, ".brain", "org.json")
8
+
9
+ /** The config, or undefined when there is none or it is unusable (wrong shape: a hand-edited file must not crash the brain). */
10
+ export function readOrgConfig(home: string): OrgConfig | undefined {
11
+ const p = orgConfigPath(home)
12
+ if (!existsSync(p)) return undefined
13
+ try {
14
+ const raw = JSON.parse(readFileSync(p, "utf8")) as Record<string, unknown>
15
+ if (typeof raw?.server !== "string" || !raw.server || typeof raw.token !== "string" || !raw.token) return undefined
16
+ const cfg: OrgConfig = { server: raw.server, token: raw.token }
17
+ if (typeof raw.defaultScope === "string" && (SCOPES as string[]).includes(raw.defaultScope)) cfg.defaultScope = raw.defaultScope as Scope
18
+ if (typeof raw.ca === "string" && raw.ca) cfg.ca = raw.ca
19
+ return cfg
20
+ } catch {
21
+ return undefined
22
+ }
23
+ }
24
+
25
+ /** Writes the config atomically with mode 0600 (the temp file is created 0600 too, so the token is never readable by others). */
26
+ export function writeOrgConfig(home: string, cfg: OrgConfig): void {
27
+ const p = orgConfigPath(home)
28
+ mkdirSync(dirname(p), { recursive: true, mode: 0o700 })
29
+ try {
30
+ chmodSync(dirname(p), 0o700)
31
+ } catch {}
32
+ const tmp = `${p}.${process.pid}.tmp`
33
+ writeFileSync(tmp, `${JSON.stringify({ server: cfg.server, token: cfg.token, ...(cfg.ca ? { ca: cfg.ca } : {}), ...(cfg.defaultScope ? { defaultScope: cfg.defaultScope } : {}) }, null, 2)}\n`, { mode: 0o600 })
34
+ chmodSync(tmp, 0o600)
35
+ renameSync(tmp, p)
36
+ }
37
+
38
+ /** Removes the config (logout). Returns whether there was one. */
39
+ export function clearOrgConfig(home: string): boolean {
40
+ const p = orgConfigPath(home)
41
+ if (!existsSync(p)) return false
42
+ rmSync(p, { force: true })
43
+ return true
44
+ }