@100xprompt/brain 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +48 -0
- package/README.md +172 -2
- package/package.json +48 -4
- package/src/adapters/aider/index.ts +8 -0
- package/src/adapters/aider/reader.ts +129 -0
- package/src/adapters/aider/records.ts +220 -0
- package/src/adapters/aider/writer.ts +269 -0
- package/src/adapters/amazon-q-cli/index.ts +8 -0
- package/src/adapters/amazon-q-cli/reader.ts +84 -0
- package/src/adapters/amazon-q-cli/records.ts +191 -0
- package/src/adapters/amazon-q-cli/writer.ts +370 -0
- package/src/adapters/amazon-q-ide/index.ts +9 -0
- package/src/adapters/amazon-q-ide/reader.ts +131 -0
- package/src/adapters/amazon-q-ide/records.ts +141 -0
- package/src/adapters/amazon-q-ide/writer.ts +325 -0
- package/src/adapters/antigravity/index.ts +9 -0
- package/src/adapters/antigravity/proto.ts +194 -0
- package/src/adapters/antigravity/reader.ts +424 -0
- package/src/adapters/antigravity/records.ts +236 -0
- package/src/adapters/antigravity/writer.ts +463 -0
- package/src/adapters/auggie/index.ts +36 -0
- package/src/adapters/claude-code/index.ts +8 -0
- package/src/adapters/claude-code/reader.ts +639 -0
- package/src/adapters/claude-code/records.ts +88 -0
- package/src/adapters/claude-code/writer.ts +329 -0
- package/src/adapters/cline/index.ts +12 -0
- package/src/adapters/cline/reader.ts +432 -0
- package/src/adapters/cline/records.ts +151 -0
- package/src/adapters/cline/vscdb.ts +56 -0
- package/src/adapters/cline/writer.ts +656 -0
- package/src/adapters/cline/xml.ts +177 -0
- package/src/adapters/cline4/index.ts +9 -0
- package/src/adapters/cline4/reader.ts +179 -0
- package/src/adapters/cline4/records.ts +92 -0
- package/src/adapters/cline4/writer.ts +361 -0
- package/src/adapters/codebuff/index.ts +8 -0
- package/src/adapters/codebuff/project.ts +207 -0
- package/src/adapters/codebuff/reader.ts +215 -0
- package/src/adapters/codebuff/records.ts +173 -0
- package/src/adapters/codebuff/writer.ts +383 -0
- package/src/adapters/codex/index.ts +8 -0
- package/src/adapters/codex/native.ts +239 -0
- package/src/adapters/codex/reader.ts +747 -0
- package/src/adapters/codex/records.ts +114 -0
- package/src/adapters/codex/writer.ts +479 -0
- package/src/adapters/continue/index.ts +9 -0
- package/src/adapters/continue/reader.ts +68 -0
- package/src/adapters/continue/records.ts +418 -0
- package/src/adapters/continue/writer.ts +162 -0
- package/src/adapters/copilot-cli/index.ts +9 -0
- package/src/adapters/copilot-cli/reader.ts +186 -0
- package/src/adapters/copilot-cli/records.ts +241 -0
- package/src/adapters/copilot-cli/writer.ts +353 -0
- package/src/adapters/crush/index.ts +8 -0
- package/src/adapters/crush/reader.ts +180 -0
- package/src/adapters/crush/records.ts +169 -0
- package/src/adapters/crush/writer.ts +333 -0
- package/src/adapters/cursor-cli/index.ts +405 -0
- package/src/adapters/cursor-ide/agentkv.ts +95 -0
- package/src/adapters/cursor-ide/index.ts +501 -0
- package/src/adapters/cursor-ide/transcripts.ts +122 -0
- package/src/adapters/cursor-ide/workspaces.ts +119 -0
- package/src/adapters/cursor-ide/writer.ts +170 -0
- package/src/adapters/devin/index.ts +8 -0
- package/src/adapters/devin/reader.ts +305 -0
- package/src/adapters/devin/records.ts +277 -0
- package/src/adapters/devin/writer.ts +523 -0
- package/src/adapters/factory-droid/index.ts +12 -0
- package/src/adapters/factory-droid/reader.ts +184 -0
- package/src/adapters/factory-droid/records.ts +79 -0
- package/src/adapters/factory-droid/writer.ts +350 -0
- package/src/adapters/gemini-cli/exchanges.ts +228 -0
- package/src/adapters/gemini-cli/index.ts +396 -0
- package/src/adapters/gemini-cli/records.ts +521 -0
- package/src/adapters/gemini-cli/writer.ts +354 -0
- package/src/adapters/goose/index.ts +8 -0
- package/src/adapters/goose/reader.ts +138 -0
- package/src/adapters/goose/records.ts +199 -0
- package/src/adapters/goose/writer.ts +340 -0
- package/src/adapters/grok-cli/image.ts +44 -0
- package/src/adapters/grok-cli/index.ts +8 -0
- package/src/adapters/grok-cli/reader.ts +302 -0
- package/src/adapters/grok-cli/records.ts +172 -0
- package/src/adapters/grok-cli/writer.ts +329 -0
- package/src/adapters/hermes/index.ts +8 -0
- package/src/adapters/hermes/reader.ts +249 -0
- package/src/adapters/hermes/records.ts +285 -0
- package/src/adapters/hermes/writer.ts +452 -0
- package/src/adapters/iflow-cli/index.ts +72 -0
- package/src/adapters/iflow-cli/records.ts +215 -0
- package/src/adapters/iflow-cli/writer.ts +204 -0
- package/src/adapters/index.ts +112 -0
- package/src/adapters/kilo-cli/index.ts +21 -0
- package/src/adapters/kilo-code/index.ts +11 -0
- package/src/adapters/kilo-code/writer.ts +92 -0
- package/src/adapters/kimi/index.ts +9 -0
- package/src/adapters/kimi/reader.ts +92 -0
- package/src/adapters/kimi/records.ts +221 -0
- package/src/adapters/kimi/writer.ts +367 -0
- package/src/adapters/kiro-cli/index.ts +8 -0
- package/src/adapters/kiro-cli/reader.ts +87 -0
- package/src/adapters/kiro-cli/records.ts +260 -0
- package/src/adapters/kiro-cli/writer.ts +305 -0
- package/src/adapters/kiro-ide/index.ts +8 -0
- package/src/adapters/kiro-ide/writer.ts +52 -0
- package/src/adapters/letta/index.ts +8 -0
- package/src/adapters/letta/reader.ts +177 -0
- package/src/adapters/letta/records.ts +119 -0
- package/src/adapters/letta/writer.ts +413 -0
- package/src/adapters/mastracode/index.ts +8 -0
- package/src/adapters/mastracode/reader.ts +323 -0
- package/src/adapters/mastracode/records.ts +336 -0
- package/src/adapters/mastracode/writer.ts +385 -0
- package/src/adapters/mistral-vibe/index.ts +9 -0
- package/src/adapters/mistral-vibe/reader.ts +62 -0
- package/src/adapters/mistral-vibe/records.ts +104 -0
- package/src/adapters/mistral-vibe/writer.ts +359 -0
- package/src/adapters/muse/index.ts +10 -0
- package/src/adapters/muse/reader.ts +332 -0
- package/src/adapters/muse/records.ts +104 -0
- package/src/adapters/muse/writer.ts +387 -0
- package/src/adapters/omp/index.ts +13 -0
- package/src/adapters/omp/reader.ts +314 -0
- package/src/adapters/omp/records.ts +153 -0
- package/src/adapters/omp/writer.ts +216 -0
- package/src/adapters/opencode/ids.ts +52 -0
- package/src/adapters/opencode/index.ts +31 -0
- package/src/adapters/opencode/note.ts +8 -0
- package/src/adapters/opencode/reader.ts +452 -0
- package/src/adapters/opencode/tools.ts +174 -0
- package/src/adapters/opencode/v2.ts +367 -0
- package/src/adapters/opencode/writer.ts +379 -0
- package/src/adapters/openhands/index.ts +8 -0
- package/src/adapters/openhands/reader.ts +222 -0
- package/src/adapters/openhands/records.ts +136 -0
- package/src/adapters/openhands/writer.ts +404 -0
- package/src/adapters/pearai/index.ts +10 -0
- package/src/adapters/pi/index.ts +28 -0
- package/src/adapters/pi/reader.ts +261 -0
- package/src/adapters/pi/records.ts +99 -0
- package/src/adapters/pi/writer.ts +362 -0
- package/src/adapters/qoder-cli/index.ts +8 -0
- package/src/adapters/qoder-cli/reader.ts +83 -0
- package/src/adapters/qoder-cli/records.ts +45 -0
- package/src/adapters/qoder-cli/writer.ts +72 -0
- package/src/adapters/qwen-code/index.ts +84 -0
- package/src/adapters/qwen-code/records.ts +194 -0
- package/src/adapters/qwen-code/writer.ts +224 -0
- package/src/adapters/tabnine-cli/index.ts +8 -0
- package/src/adapters/tabnine-cli/reader.ts +106 -0
- package/src/adapters/tabnine-cli/records.ts +33 -0
- package/src/adapters/tabnine-cli/writer.ts +156 -0
- package/src/adapters/types.ts +73 -0
- package/src/adapters/void/index.ts +9 -0
- package/src/adapters/void/reader.ts +48 -0
- package/src/adapters/void/records.ts +285 -0
- package/src/adapters/void/writer.ts +93 -0
- package/src/adapters/vscode-copilot/index.ts +9 -0
- package/src/adapters/vscode-copilot/reader.ts +212 -0
- package/src/adapters/vscode-copilot/records.ts +388 -0
- package/src/adapters/vscode-copilot/shared.ts +68 -0
- package/src/adapters/vscode-copilot/writer.ts +546 -0
- package/src/adapters/zed/index.ts +8 -0
- package/src/adapters/zed/reader.ts +198 -0
- package/src/adapters/zed/records.ts +87 -0
- package/src/adapters/zed/writer.ts +341 -0
- package/src/brief-writers.ts +71 -0
- package/src/brief.ts +238 -0
- package/src/cli.ts +326 -0
- package/src/fsutil.ts +133 -0
- package/src/gateway/connect.ts +845 -0
- package/src/gateway/signin.ts +323 -0
- package/src/handoff.ts +104 -0
- package/src/hooks.ts +455 -0
- package/src/memory/audit.ts +514 -0
- package/src/memory/blobs.ts +94 -0
- package/src/memory/cli.ts +117 -0
- package/src/memory/distill.ts +381 -0
- package/src/memory/externalize.ts +435 -0
- package/src/memory/lookups.ts +70 -0
- package/src/memory/mcp-install.ts +401 -0
- package/src/memory/mcp-server.ts +296 -0
- package/src/memory/prepare.ts +179 -0
- package/src/memory/preview.ts +154 -0
- package/src/memory/project.ts +35 -0
- package/src/memory/redact.ts +72 -0
- package/src/memory/relevance.ts +187 -0
- package/src/memory/repomap.ts +516 -0
- package/src/memory/store.ts +270 -0
- package/src/memory/sync.ts +166 -0
- package/src/memory/tidy.ts +193 -0
- package/src/memory/types.ts +118 -0
- package/src/org/admin-ops.ts +96 -0
- package/src/org/auth.ts +112 -0
- package/src/org/backup.ts +115 -0
- package/src/org/cli.ts +438 -0
- package/src/org/client.ts +404 -0
- package/src/org/config.ts +44 -0
- package/src/org/crypto.ts +537 -0
- package/src/org/db.ts +435 -0
- package/src/org/graph.ts +318 -0
- package/src/org/oidc.ts +435 -0
- package/src/org/policy.ts +398 -0
- package/src/org/server.ts +920 -0
- package/src/org/sso.ts +316 -0
- package/src/org/tls.ts +66 -0
- package/src/org/types.ts +193 -0
- package/src/self.ts +68 -0
- package/src/session/attachments.ts +53 -0
- package/src/session/peekcache.ts +50 -0
- package/src/session/types.ts +104 -0
- package/src/session/util.ts +130 -0
- package/src/sqlite.ts +48 -0
|
@@ -0,0 +1,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
|
+
}
|