@100xprompt/brain 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +48 -0
- package/README.md +235 -2
- package/package.json +48 -4
- package/src/adapters/aider/index.ts +8 -0
- package/src/adapters/aider/reader.ts +129 -0
- package/src/adapters/aider/records.ts +220 -0
- package/src/adapters/aider/writer.ts +269 -0
- package/src/adapters/amazon-q-cli/index.ts +8 -0
- package/src/adapters/amazon-q-cli/reader.ts +84 -0
- package/src/adapters/amazon-q-cli/records.ts +191 -0
- package/src/adapters/amazon-q-cli/writer.ts +370 -0
- package/src/adapters/amazon-q-ide/index.ts +9 -0
- package/src/adapters/amazon-q-ide/reader.ts +131 -0
- package/src/adapters/amazon-q-ide/records.ts +141 -0
- package/src/adapters/amazon-q-ide/writer.ts +325 -0
- package/src/adapters/antigravity/index.ts +9 -0
- package/src/adapters/antigravity/proto.ts +194 -0
- package/src/adapters/antigravity/reader.ts +424 -0
- package/src/adapters/antigravity/records.ts +236 -0
- package/src/adapters/antigravity/writer.ts +463 -0
- package/src/adapters/auggie/index.ts +36 -0
- package/src/adapters/claude-code/index.ts +8 -0
- package/src/adapters/claude-code/reader.ts +639 -0
- package/src/adapters/claude-code/records.ts +88 -0
- package/src/adapters/claude-code/writer.ts +329 -0
- package/src/adapters/cline/index.ts +12 -0
- package/src/adapters/cline/reader.ts +432 -0
- package/src/adapters/cline/records.ts +151 -0
- package/src/adapters/cline/vscdb.ts +56 -0
- package/src/adapters/cline/writer.ts +656 -0
- package/src/adapters/cline/xml.ts +177 -0
- package/src/adapters/cline4/index.ts +9 -0
- package/src/adapters/cline4/reader.ts +179 -0
- package/src/adapters/cline4/records.ts +92 -0
- package/src/adapters/cline4/writer.ts +361 -0
- package/src/adapters/codebuff/index.ts +8 -0
- package/src/adapters/codebuff/project.ts +207 -0
- package/src/adapters/codebuff/reader.ts +215 -0
- package/src/adapters/codebuff/records.ts +173 -0
- package/src/adapters/codebuff/writer.ts +383 -0
- package/src/adapters/codex/index.ts +8 -0
- package/src/adapters/codex/native.ts +239 -0
- package/src/adapters/codex/reader.ts +747 -0
- package/src/adapters/codex/records.ts +114 -0
- package/src/adapters/codex/writer.ts +479 -0
- package/src/adapters/continue/index.ts +9 -0
- package/src/adapters/continue/reader.ts +68 -0
- package/src/adapters/continue/records.ts +418 -0
- package/src/adapters/continue/writer.ts +162 -0
- package/src/adapters/copilot-cli/index.ts +9 -0
- package/src/adapters/copilot-cli/reader.ts +186 -0
- package/src/adapters/copilot-cli/records.ts +241 -0
- package/src/adapters/copilot-cli/writer.ts +353 -0
- package/src/adapters/crush/index.ts +8 -0
- package/src/adapters/crush/reader.ts +180 -0
- package/src/adapters/crush/records.ts +169 -0
- package/src/adapters/crush/writer.ts +333 -0
- package/src/adapters/cursor-cli/index.ts +405 -0
- package/src/adapters/cursor-ide/agentkv.ts +95 -0
- package/src/adapters/cursor-ide/index.ts +501 -0
- package/src/adapters/cursor-ide/transcripts.ts +122 -0
- package/src/adapters/cursor-ide/workspaces.ts +119 -0
- package/src/adapters/cursor-ide/writer.ts +170 -0
- package/src/adapters/devin/index.ts +8 -0
- package/src/adapters/devin/reader.ts +305 -0
- package/src/adapters/devin/records.ts +277 -0
- package/src/adapters/devin/writer.ts +523 -0
- package/src/adapters/factory-droid/index.ts +12 -0
- package/src/adapters/factory-droid/reader.ts +184 -0
- package/src/adapters/factory-droid/records.ts +79 -0
- package/src/adapters/factory-droid/writer.ts +350 -0
- package/src/adapters/gemini-cli/exchanges.ts +228 -0
- package/src/adapters/gemini-cli/index.ts +396 -0
- package/src/adapters/gemini-cli/records.ts +521 -0
- package/src/adapters/gemini-cli/writer.ts +354 -0
- package/src/adapters/goose/index.ts +8 -0
- package/src/adapters/goose/reader.ts +138 -0
- package/src/adapters/goose/records.ts +199 -0
- package/src/adapters/goose/writer.ts +340 -0
- package/src/adapters/grok-cli/image.ts +44 -0
- package/src/adapters/grok-cli/index.ts +8 -0
- package/src/adapters/grok-cli/reader.ts +302 -0
- package/src/adapters/grok-cli/records.ts +172 -0
- package/src/adapters/grok-cli/writer.ts +329 -0
- package/src/adapters/hermes/index.ts +8 -0
- package/src/adapters/hermes/reader.ts +249 -0
- package/src/adapters/hermes/records.ts +285 -0
- package/src/adapters/hermes/writer.ts +452 -0
- package/src/adapters/iflow-cli/index.ts +72 -0
- package/src/adapters/iflow-cli/records.ts +215 -0
- package/src/adapters/iflow-cli/writer.ts +204 -0
- package/src/adapters/index.ts +112 -0
- package/src/adapters/kilo-cli/index.ts +21 -0
- package/src/adapters/kilo-code/index.ts +11 -0
- package/src/adapters/kilo-code/writer.ts +92 -0
- package/src/adapters/kimi/index.ts +9 -0
- package/src/adapters/kimi/reader.ts +92 -0
- package/src/adapters/kimi/records.ts +221 -0
- package/src/adapters/kimi/writer.ts +367 -0
- package/src/adapters/kiro-cli/index.ts +8 -0
- package/src/adapters/kiro-cli/reader.ts +87 -0
- package/src/adapters/kiro-cli/records.ts +260 -0
- package/src/adapters/kiro-cli/writer.ts +305 -0
- package/src/adapters/kiro-ide/index.ts +8 -0
- package/src/adapters/kiro-ide/writer.ts +52 -0
- package/src/adapters/letta/index.ts +8 -0
- package/src/adapters/letta/reader.ts +177 -0
- package/src/adapters/letta/records.ts +119 -0
- package/src/adapters/letta/writer.ts +413 -0
- package/src/adapters/mastracode/index.ts +8 -0
- package/src/adapters/mastracode/reader.ts +323 -0
- package/src/adapters/mastracode/records.ts +336 -0
- package/src/adapters/mastracode/writer.ts +385 -0
- package/src/adapters/mistral-vibe/index.ts +9 -0
- package/src/adapters/mistral-vibe/reader.ts +62 -0
- package/src/adapters/mistral-vibe/records.ts +104 -0
- package/src/adapters/mistral-vibe/writer.ts +359 -0
- package/src/adapters/muse/index.ts +10 -0
- package/src/adapters/muse/reader.ts +332 -0
- package/src/adapters/muse/records.ts +104 -0
- package/src/adapters/muse/writer.ts +387 -0
- package/src/adapters/omp/index.ts +13 -0
- package/src/adapters/omp/reader.ts +314 -0
- package/src/adapters/omp/records.ts +153 -0
- package/src/adapters/omp/writer.ts +216 -0
- package/src/adapters/opencode/ids.ts +52 -0
- package/src/adapters/opencode/index.ts +31 -0
- package/src/adapters/opencode/note.ts +8 -0
- package/src/adapters/opencode/reader.ts +452 -0
- package/src/adapters/opencode/tools.ts +174 -0
- package/src/adapters/opencode/v2.ts +367 -0
- package/src/adapters/opencode/writer.ts +379 -0
- package/src/adapters/openhands/index.ts +8 -0
- package/src/adapters/openhands/reader.ts +222 -0
- package/src/adapters/openhands/records.ts +136 -0
- package/src/adapters/openhands/writer.ts +404 -0
- package/src/adapters/pearai/index.ts +10 -0
- package/src/adapters/pi/index.ts +28 -0
- package/src/adapters/pi/reader.ts +261 -0
- package/src/adapters/pi/records.ts +99 -0
- package/src/adapters/pi/writer.ts +362 -0
- package/src/adapters/qoder-cli/index.ts +8 -0
- package/src/adapters/qoder-cli/reader.ts +83 -0
- package/src/adapters/qoder-cli/records.ts +45 -0
- package/src/adapters/qoder-cli/writer.ts +72 -0
- package/src/adapters/qwen-code/index.ts +84 -0
- package/src/adapters/qwen-code/records.ts +194 -0
- package/src/adapters/qwen-code/writer.ts +224 -0
- package/src/adapters/tabnine-cli/index.ts +8 -0
- package/src/adapters/tabnine-cli/reader.ts +106 -0
- package/src/adapters/tabnine-cli/records.ts +33 -0
- package/src/adapters/tabnine-cli/writer.ts +156 -0
- package/src/adapters/types.ts +73 -0
- package/src/adapters/void/index.ts +9 -0
- package/src/adapters/void/reader.ts +48 -0
- package/src/adapters/void/records.ts +285 -0
- package/src/adapters/void/writer.ts +93 -0
- package/src/adapters/vscode-copilot/index.ts +9 -0
- package/src/adapters/vscode-copilot/reader.ts +212 -0
- package/src/adapters/vscode-copilot/records.ts +388 -0
- package/src/adapters/vscode-copilot/shared.ts +68 -0
- package/src/adapters/vscode-copilot/writer.ts +546 -0
- package/src/adapters/zed/index.ts +8 -0
- package/src/adapters/zed/reader.ts +198 -0
- package/src/adapters/zed/records.ts +87 -0
- package/src/adapters/zed/writer.ts +341 -0
- package/src/brief-writers.ts +71 -0
- package/src/brief.ts +238 -0
- package/src/cli.ts +351 -0
- package/src/dashboard/cli.ts +148 -0
- package/src/dashboard/conversions.ts +170 -0
- package/src/dashboard/report.ts +261 -0
- package/src/dashboard/send.ts +213 -0
- package/src/dashboard/state.ts +95 -0
- package/src/fsutil.ts +133 -0
- package/src/gateway/common.ts +232 -0
- package/src/gateway/connect.ts +847 -0
- package/src/gateway/connectors/aider.ts +356 -0
- package/src/gateway/connectors/amazon-q-cli.ts +6 -0
- package/src/gateway/connectors/amazon-q-ide.ts +6 -0
- package/src/gateway/connectors/antigravity.ts +6 -0
- package/src/gateway/connectors/auggie.ts +6 -0
- package/src/gateway/connectors/cline-sdk.ts +85 -0
- package/src/gateway/connectors/cline.ts +34 -0
- package/src/gateway/connectors/codebuff.ts +6 -0
- package/src/gateway/connectors/continue.ts +579 -0
- package/src/gateway/connectors/copilot-cli.ts +138 -0
- package/src/gateway/connectors/crush.ts +313 -0
- package/src/gateway/connectors/cursor-cli.ts +6 -0
- package/src/gateway/connectors/cursor.ts +6 -0
- package/src/gateway/connectors/devin.ts +6 -0
- package/src/gateway/connectors/factory-droid.ts +308 -0
- package/src/gateway/connectors/goose.ts +79 -0
- package/src/gateway/connectors/grok-cli.ts +82 -0
- package/src/gateway/connectors/hermes.ts +230 -0
- package/src/gateway/connectors/iflow-cli.ts +50 -0
- package/src/gateway/connectors/index.ts +86 -0
- package/src/gateway/connectors/kilo-cli.ts +434 -0
- package/src/gateway/connectors/kilo-code.ts +32 -0
- package/src/gateway/connectors/kimi-code.ts +241 -0
- package/src/gateway/connectors/kiro-cli.ts +6 -0
- package/src/gateway/connectors/kiro-ide.ts +6 -0
- package/src/gateway/connectors/letta-code.ts +170 -0
- package/src/gateway/connectors/mastracode.ts +91 -0
- package/src/gateway/connectors/mistral-vibe.ts +495 -0
- package/src/gateway/connectors/muse.ts +27 -0
- package/src/gateway/connectors/omp.ts +279 -0
- package/src/gateway/connectors/openhands.ts +296 -0
- package/src/gateway/connectors/pearai.ts +52 -0
- package/src/gateway/connectors/pi.ts +357 -0
- package/src/gateway/connectors/qoder-cli.ts +6 -0
- package/src/gateway/connectors/qwen-code.ts +411 -0
- package/src/gateway/connectors/roo.ts +43 -0
- package/src/gateway/connectors/tabnine-cli.ts +6 -0
- package/src/gateway/connectors/void.ts +6 -0
- package/src/gateway/connectors/vscode-copilot.ts +286 -0
- package/src/gateway/connectors/windsurf.ts +6 -0
- package/src/gateway/connectors/zed.ts +29 -0
- package/src/gateway/signin.ts +323 -0
- package/src/handoff.ts +104 -0
- package/src/hooks.ts +455 -0
- package/src/memory/audit.ts +514 -0
- package/src/memory/blobs.ts +94 -0
- package/src/memory/cli.ts +117 -0
- package/src/memory/distill.ts +381 -0
- package/src/memory/externalize.ts +435 -0
- package/src/memory/lookups.ts +70 -0
- package/src/memory/mcp-install.ts +401 -0
- package/src/memory/mcp-server.ts +296 -0
- package/src/memory/prepare.ts +179 -0
- package/src/memory/preview.ts +154 -0
- package/src/memory/project.ts +35 -0
- package/src/memory/redact.ts +72 -0
- package/src/memory/relevance.ts +187 -0
- package/src/memory/repomap.ts +516 -0
- package/src/memory/store.ts +270 -0
- package/src/memory/sync.ts +166 -0
- package/src/memory/tidy.ts +193 -0
- package/src/memory/types.ts +118 -0
- package/src/org/admin-ops.ts +96 -0
- package/src/org/auth.ts +112 -0
- package/src/org/backup.ts +115 -0
- package/src/org/cli.ts +438 -0
- package/src/org/client.ts +404 -0
- package/src/org/config.ts +44 -0
- package/src/org/crypto.ts +537 -0
- package/src/org/db.ts +435 -0
- package/src/org/graph.ts +318 -0
- package/src/org/oidc.ts +435 -0
- package/src/org/policy.ts +398 -0
- package/src/org/server.ts +920 -0
- package/src/org/sso.ts +316 -0
- package/src/org/tls.ts +66 -0
- package/src/org/types.ts +193 -0
- package/src/self.ts +68 -0
- package/src/session/attachments.ts +53 -0
- package/src/session/peekcache.ts +50 -0
- package/src/session/types.ts +104 -0
- package/src/session/util.ts +130 -0
- package/src/sqlite.ts +48 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// brain memory: spend fewer tokens when a session moves tools, restarts or grows.
|
|
2
|
+
//
|
|
3
|
+
// Measured on nine real sessions (562K tokens): tool results 49%, tool calls 31%, file edits 7%, assistant prose 7%,
|
|
4
|
+
// the person's messages 3%, compaction summaries 3%. So what you and the model said is about a tenth of a long session;
|
|
5
|
+
// the rest is tool input and output. Two mechanisms, both zero-model:
|
|
6
|
+
//
|
|
7
|
+
// 1. externalize: a big tool call/result is replaced by a one-line digest and a pointer; the full text goes to a blob
|
|
8
|
+
// store on disk and comes back on demand (`brain_expand`). Standalone: needs no Chitta.
|
|
9
|
+
// 2. memory: what the session established (decisions, constraints, preferences, files, failed approaches, commands) is
|
|
10
|
+
// distilled into small items and stored in the brain's OWN Chitta store, per project; a new session in any tool is
|
|
11
|
+
// started with a budgeted handful of them and can ask for more (`brain_recall`). Chitta is optional: without it
|
|
12
|
+
// memory is unavailable and everything else still works.
|
|
13
|
+
//
|
|
14
|
+
// Hard rules: the brain never writes to the person's personal Chitta store (CONTEXT_DB / ~/.local/share/100xprompt);
|
|
15
|
+
// its store is `<home>/.brain/memory/context.db`. Nothing here calls a model. Secrets are filtered before anything is stored.
|
|
16
|
+
|
|
17
|
+
import type { Project, Session } from "../session/types.ts"
|
|
18
|
+
|
|
19
|
+
/** Rough token count (chars / 4), the same estimate the measurements use. */
|
|
20
|
+
export const approxTokens = (text: string): number => Math.ceil(text.length / 4)
|
|
21
|
+
|
|
22
|
+
// ---- externalize --------------------------------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
export interface BlobRef {
|
|
25
|
+
/** Content hash (short, stable): the same text always gets the same id. */
|
|
26
|
+
id: string
|
|
27
|
+
chars: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Full texts that were replaced by digests in a session, on disk under `<home>/.brain/blobs/`. */
|
|
31
|
+
export interface BlobStore {
|
|
32
|
+
dir: string
|
|
33
|
+
/** Stores the text (idempotent: same text, same id) and returns its reference. */
|
|
34
|
+
put(text: string, meta?: { tool?: string; project?: string }): BlobRef
|
|
35
|
+
/** The full text, or undefined when it was never stored or has been removed. */
|
|
36
|
+
get(id: string): string | undefined
|
|
37
|
+
/** The tool kind recorded when the blob was stored (read, shell, search, ...), when known. */
|
|
38
|
+
kindOf?(id: string): string | undefined
|
|
39
|
+
/** Removes blobs not used since `olderThanMs`; returns how many were removed. */
|
|
40
|
+
prune(olderThanMs: number): number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ExternalizeOptions {
|
|
44
|
+
/** Tool calls/results shorter than this stay as they are. Default 1500 characters. */
|
|
45
|
+
minChars?: number
|
|
46
|
+
/** The newest this-many tool turns are never replaced (the model is most likely to need them). Default 8. */
|
|
47
|
+
keepRecent?: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ExternalizeResult {
|
|
51
|
+
/** A copy of the session with big tool calls/results replaced by digest stubs. The input is not modified. */
|
|
52
|
+
session: Session
|
|
53
|
+
/** How many turns were replaced. */
|
|
54
|
+
replaced: number
|
|
55
|
+
/** Characters before and after, over the whole session. */
|
|
56
|
+
before: number
|
|
57
|
+
after: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The stub text written in place of a big tool result: starts with this so a reader can tell it is one. */
|
|
61
|
+
export const STUB_PREFIX = "[brain: output stored"
|
|
62
|
+
|
|
63
|
+
// ---- memory -------------------------------------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
export type MemoryKind = "decision" | "constraint" | "preference" | "failed" | "file" | "command" | "summary" | "state"
|
|
66
|
+
|
|
67
|
+
/** A project: its remote when it has one (so a clone anywhere shares memory), else its folder. Stable across tools. */
|
|
68
|
+
export interface ProjectKey {
|
|
69
|
+
id: string
|
|
70
|
+
label: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface MemoryItem {
|
|
74
|
+
/** Stable: re-distilling the same session gives the same ids, so re-ingesting updates rather than duplicates. */
|
|
75
|
+
id: string
|
|
76
|
+
kind: MemoryKind
|
|
77
|
+
/** A short title (one line). */
|
|
78
|
+
name: string
|
|
79
|
+
/** The memory itself, self-contained, at most a few hundred characters. */
|
|
80
|
+
text: string
|
|
81
|
+
/** Which session it came from (portable id), for provenance. */
|
|
82
|
+
source: string
|
|
83
|
+
occurredAt?: string
|
|
84
|
+
entities?: Array<{ name: string; type?: string }>
|
|
85
|
+
relations?: Array<{ from: string; to: string; type: string }>
|
|
86
|
+
procedures?: Array<{ trigger?: string; action: string }>
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface Recalled {
|
|
90
|
+
text: string
|
|
91
|
+
kind?: MemoryKind
|
|
92
|
+
name?: string
|
|
93
|
+
score: number
|
|
94
|
+
source?: string
|
|
95
|
+
/** Set by the organisation memory (src/org): who may see the item and whose it is. */
|
|
96
|
+
scope?: "private" | "project" | "team" | "org"
|
|
97
|
+
owner?: string
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface MemoryStatus {
|
|
101
|
+
available: boolean
|
|
102
|
+
/** Why it is unavailable, when it is ("Chitta is not installed", ...). */
|
|
103
|
+
why?: string
|
|
104
|
+
store?: string
|
|
105
|
+
items?: number
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface Memory {
|
|
109
|
+
status(): Promise<MemoryStatus>
|
|
110
|
+
/** `opts.scope`/`opts.team` are used by the organisation memory (src/org); the local memory ignores them (everything local is private to the person). */
|
|
111
|
+
ingest(project: ProjectKey, items: MemoryItem[], opts?: { scope?: "private" | "project" | "team" | "org"; team?: string }): Promise<{ stored: number }>
|
|
112
|
+
/** The best matches for a question within a project, at most `limit` items and `maxTokens` tokens in total. */
|
|
113
|
+
recall(project: ProjectKey, query: string, opts?: { limit?: number; maxTokens?: number }): Promise<Recalled[]>
|
|
114
|
+
/** Removes a project's memories (all of them, or those from one session). Returns how many. */
|
|
115
|
+
forget(project: ProjectKey, opts?: { source?: string }): Promise<number>
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type { Project, Session }
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// brain org admin: the offline operations on the database file: `init --encrypt`, `encrypt`, `rekey`, `backup`, `restore`. They run on the
|
|
2
|
+
// server's machine, need no login, and never print a key: they print where a key file is, and that is all. cli.ts routes to here.
|
|
3
|
+
import { existsSync } from "node:fs"
|
|
4
|
+
import { resolve } from "node:path"
|
|
5
|
+
import { backupDatabase, restoreDatabase } from "./backup.ts"
|
|
6
|
+
import { OrgKeyError, convertDatabase, dbState, generateKey, readKeyFile, resolveKey, wipeFile, writeKeyFile } from "./crypto.ts"
|
|
7
|
+
|
|
8
|
+
type Out = (line: string) => void
|
|
9
|
+
interface Args {
|
|
10
|
+
flags: Record<string, string>
|
|
11
|
+
bools: Set<string>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The key for the encrypted database `init --encrypt` / `encrypt` is about to create: an existing key file is used as it is, a missing one
|
|
15
|
+
* is created with 256 random bits (mode 0600, never overwriting), and without `--key-file` the key comes from BRAIN_ORG_KEY, or a new
|
|
16
|
+
* file `<db>.key` is made. Says where the key is, never what it is. */
|
|
17
|
+
export function keyForNewDatabase(db: string, keyFileFlag: string | undefined, out: Out): string {
|
|
18
|
+
if (keyFileFlag) {
|
|
19
|
+
const f = resolve(keyFileFlag)
|
|
20
|
+
if (existsSync(f)) {
|
|
21
|
+
const r = readKeyFile(f)
|
|
22
|
+
if (r.warning) out(`warning: ${r.warning}`)
|
|
23
|
+
out(`using the existing key file ${f}`)
|
|
24
|
+
return r.key
|
|
25
|
+
}
|
|
26
|
+
const key = generateKey()
|
|
27
|
+
writeKeyFile(f, key)
|
|
28
|
+
out(`a new random 256-bit key was written to ${f} (mode 0600). Keep a copy somewhere safe and separate: without this key the database cannot be read, by anyone, ever.`)
|
|
29
|
+
return key
|
|
30
|
+
}
|
|
31
|
+
const env = resolveKey()
|
|
32
|
+
if (env.key) {
|
|
33
|
+
out("using the key from BRAIN_ORG_KEY")
|
|
34
|
+
return env.key
|
|
35
|
+
}
|
|
36
|
+
return keyForNewDatabase(db, `${db}.key`, out)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The current key of an existing database: `--key-file`, else BRAIN_ORG_KEY; none for a plain database. */
|
|
40
|
+
function currentKey(a: Args, out: Out): string | undefined {
|
|
41
|
+
const r = resolveKey({ keyFile: a.flags["key-file"] ? resolve(a.flags["key-file"]) : undefined })
|
|
42
|
+
if (r.warning) out(`warning: ${r.warning}`)
|
|
43
|
+
return r.key
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function adminOps(verb: "encrypt" | "rekey" | "backup" | "restore", a: Args, out: Out): Promise<number> {
|
|
47
|
+
try {
|
|
48
|
+
const f = a.flags
|
|
49
|
+
if (verb === "encrypt") {
|
|
50
|
+
if (!f.db) return (out("usage: brain org admin encrypt --db <path> [--key-file <path>] [--remove-backup]"), 2)
|
|
51
|
+
const db = resolve(f.db)
|
|
52
|
+
if (dbState(db) === "missing") return (out(`no database at ${db}`), 1)
|
|
53
|
+
if (dbState(db) === "encrypted") return (out(`${db} is already encrypted. To change its key use: brain org admin rekey --db ${db} --key-file <current key file> --new-key-file <new key file>`), 1)
|
|
54
|
+
const key = keyForNewDatabase(db, f["key-file"], out)
|
|
55
|
+
const r = convertDatabase({ path: db, newKey: key })
|
|
56
|
+
out(`${db} is now encrypted (${Object.keys(r.tables).length} tables copied, row counts and integrity verified).`)
|
|
57
|
+
if (a.bools.has("remove-backup")) {
|
|
58
|
+
wipeFile(r.backup)
|
|
59
|
+
out(`the plain-text copy ${r.backup} was overwritten and removed (best effort: not a guarantee on SSDs or copy-on-write file systems).`)
|
|
60
|
+
} else {
|
|
61
|
+
out(`the previous, UNENCRYPTED file is kept as ${r.backup}: once you have started the server and checked it works, remove it (brain org admin encrypt --remove-backup does that at conversion time), and remember copies of it elsewhere (snapshots, earlier backups) are still plain text.`)
|
|
62
|
+
}
|
|
63
|
+
out("start the server with the key: BRAIN_ORG_KEY=... or --key-file <path>")
|
|
64
|
+
return 0
|
|
65
|
+
}
|
|
66
|
+
if (verb === "rekey") {
|
|
67
|
+
if (!f.db || !f["new-key-file"]) return (out("usage: brain org admin rekey --db <path> --key-file <current key file> --new-key-file <new key file> (the current key may also be in BRAIN_ORG_KEY; the new file is created if missing)"), 2)
|
|
68
|
+
const db = resolve(f.db)
|
|
69
|
+
if (dbState(db) === "missing") return (out(`no database at ${db}`), 1)
|
|
70
|
+
if (dbState(db) !== "encrypted") return (out(`${db} is not encrypted: use \`brain org admin encrypt --db ${db}\``), 1)
|
|
71
|
+
const old = currentKey(a, out)
|
|
72
|
+
if (!old) return (out("give the current key with --key-file or BRAIN_ORG_KEY"), 2)
|
|
73
|
+
if (f["key-file"] && resolve(f["key-file"]) === resolve(f["new-key-file"])) return (out("--new-key-file must be a different file from --key-file"), 2)
|
|
74
|
+
const key = keyForNewDatabase(db, f["new-key-file"], out)
|
|
75
|
+
if (key === old) return (out("the new key is the same as the current one: nothing to do"), 1)
|
|
76
|
+
const r = convertDatabase({ path: db, oldKey: old, newKey: key })
|
|
77
|
+
out(`${db} is now encrypted with the new key (${Object.keys(r.tables).length} tables copied, row counts and integrity verified).`)
|
|
78
|
+
out(`the previous file (still encrypted with the OLD key) is kept as ${r.backup}. Backups made earlier also need the old key. Remove them once you no longer need them, and put the new key where the server reads it before starting it.`)
|
|
79
|
+
return 0
|
|
80
|
+
}
|
|
81
|
+
if (verb === "backup") {
|
|
82
|
+
if (!f.db || !f.out) return (out("usage: brain org admin backup --db <path> --out <file> [--key-file <path>] (an encrypted database is backed up encrypted with the same key)"), 2)
|
|
83
|
+
const r = backupDatabase({ db: f.db, out: f.out, key: currentKey(a, out) })
|
|
84
|
+
out(`backup written to ${r.out} (${r.bytes} bytes, ${r.items} items, ${r.encrypted ? "encrypted with the database's key" : "NOT encrypted: protect it like the database"}), mode 0600, verified.`)
|
|
85
|
+
return 0
|
|
86
|
+
}
|
|
87
|
+
// restore
|
|
88
|
+
if (!f.from || !f.db) return (out("usage: brain org admin restore --from <backup file> --db <path> [--key-file <path>] [--force] (stop the server first)"), 2)
|
|
89
|
+
const r = restoreDatabase({ from: f.from, db: f.db, key: currentKey(a, out), force: a.bools.has("force") })
|
|
90
|
+
out(`restored ${r.items} items into ${r.db}${r.movedAside ? `; the previous database was moved to ${r.movedAside}` : ""}. Start the server with the key the backup was made with.`)
|
|
91
|
+
out("everything after the backup is gone from this database: tokens created or revoked since, and items added or forgotten since.")
|
|
92
|
+
return 0
|
|
93
|
+
} catch (e) {
|
|
94
|
+
return (out(`error: ${e instanceof OrgKeyError ? e.message : String((e as Error)?.message ?? e).split("\n")[0]}`), 1)
|
|
95
|
+
}
|
|
96
|
+
}
|
package/src/org/auth.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// brain org: authentication primitives for the organisation server. Pure functions and two small limiters; no I/O except
|
|
2
|
+
// randomness. Nothing here ever logs, returns or stores a token: a token is generated here, handed to the caller ONCE, and
|
|
3
|
+
// from then on only its SHA-256 is kept.
|
|
4
|
+
//
|
|
5
|
+
// Token format: brn_<16 hex id>_<43 base64url chars = 256 random bits>
|
|
6
|
+
// * the id is a public handle (listing, revoking, rate-limit key, audit); knowing it gives no access;
|
|
7
|
+
// * the secret is 256 bits from the OS CSPRNG, so the stored SHA-256 (no salt, no stretching needed for a high-entropy
|
|
8
|
+
// secret) cannot be brute forced;
|
|
9
|
+
// * the server looks the row up by id, then compares SHA-256(candidate) with the stored hash in CONSTANT TIME. For an
|
|
10
|
+
// unknown id it still performs a comparison against a dummy hash so "no such id" and "wrong secret" cost the same.
|
|
11
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto"
|
|
12
|
+
import type { OrgDb, UserRow } from "./db.ts"
|
|
13
|
+
|
|
14
|
+
export const TOKEN_PREFIX = "brn_"
|
|
15
|
+
const TOKEN_RE = /^brn_([0-9a-f]{16})_([A-Za-z0-9_-]{43})$/
|
|
16
|
+
|
|
17
|
+
export const sha256Hex = (s: string): string => createHash("sha256").update(s).digest("hex")
|
|
18
|
+
|
|
19
|
+
export interface NewToken {
|
|
20
|
+
id: string
|
|
21
|
+
/** The secret string to give to the user. Shown once; never stored. */
|
|
22
|
+
token: string
|
|
23
|
+
/** What is stored. */
|
|
24
|
+
hash: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function generateToken(): NewToken {
|
|
28
|
+
const id = randomBytes(8).toString("hex")
|
|
29
|
+
const secret = randomBytes(32).toString("base64url")
|
|
30
|
+
const token = `${TOKEN_PREFIX}${id}_${secret}`
|
|
31
|
+
return { id, token, hash: sha256Hex(token) }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The id of a well-formed token, or undefined. */
|
|
35
|
+
export function parseToken(token: string): { id: string } | undefined {
|
|
36
|
+
const m = TOKEN_RE.exec(token)
|
|
37
|
+
return m ? { id: m[1]! } : undefined
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Constant-time equality of two hex SHA-256 strings (both are always 64 chars; anything else is simply unequal). */
|
|
41
|
+
export function hashesEqual(a: string, b: string): boolean {
|
|
42
|
+
if (a.length !== 64 || b.length !== 64) return false
|
|
43
|
+
return timingSafeEqual(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const DUMMY_HASH = sha256Hex("brain-org-dummy")
|
|
47
|
+
|
|
48
|
+
export type AuthFailure = "missing" | "malformed" | "unknown" | "bad_secret" | "revoked" | "expired" | "disabled"
|
|
49
|
+
export type AuthResult = { ok: true; user: UserRow; tokenId: string } | { ok: false; reason: AuthFailure; actor?: string }
|
|
50
|
+
|
|
51
|
+
/** `Authorization: Bearer <token>` -> the active user it belongs to, or why not. The caller answers every failure with the same 401. */
|
|
52
|
+
export function authenticate(db: OrgDb, header: string | null, nowMs: number): AuthResult {
|
|
53
|
+
if (!header) return { ok: false, reason: "missing" }
|
|
54
|
+
const m = /^Bearer ([^\s]+)$/i.exec(header.trim())
|
|
55
|
+
if (!m) return { ok: false, reason: "malformed" }
|
|
56
|
+
const token = m[1]!
|
|
57
|
+
const parsed = parseToken(token)
|
|
58
|
+
if (!parsed) return { ok: false, reason: "malformed" }
|
|
59
|
+
const row = db.token(parsed.id)
|
|
60
|
+
const candidate = sha256Hex(token)
|
|
61
|
+
if (!row) {
|
|
62
|
+
hashesEqual(candidate, DUMMY_HASH) // keep the cost of the unknown-id path equal to the wrong-secret path
|
|
63
|
+
return { ok: false, reason: "unknown" }
|
|
64
|
+
}
|
|
65
|
+
if (!hashesEqual(candidate, row.hash)) return { ok: false, reason: "bad_secret" }
|
|
66
|
+
if (row.revoked_at != null) return { ok: false, reason: "revoked", actor: row.user_id }
|
|
67
|
+
if (row.expires_at != null && row.expires_at <= nowMs) return { ok: false, reason: "expired", actor: row.user_id }
|
|
68
|
+
const user = db.user(row.user_id)
|
|
69
|
+
if (!user || user.disabled) return { ok: false, reason: "disabled", actor: row.user_id }
|
|
70
|
+
// last-used is bookkeeping for admins; throttled so a busy client does not write on every request
|
|
71
|
+
if (row.last_used_at == null || nowMs - row.last_used_at > 60_000) db.touchToken(row.id, nowMs)
|
|
72
|
+
return { ok: true, user, tokenId: row.id }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Fixed-window counter per key (a token id, an IP address). Memory is bounded: expired windows are dropped when the map grows. */
|
|
76
|
+
export class WindowLimiter {
|
|
77
|
+
private windows = new Map<string, { start: number; n: number }>()
|
|
78
|
+
constructor(
|
|
79
|
+
private readonly limit: number,
|
|
80
|
+
private readonly windowMs = 60_000,
|
|
81
|
+
private readonly maxKeys = 10_000,
|
|
82
|
+
) {}
|
|
83
|
+
|
|
84
|
+
/** Counts one event; returns false when the key is over its limit for the current window. */
|
|
85
|
+
hit(key: string, nowMs: number): boolean {
|
|
86
|
+
const w = this.windows.get(key)
|
|
87
|
+
if (!w || nowMs - w.start >= this.windowMs) {
|
|
88
|
+
if (this.windows.size >= this.maxKeys) this.sweep(nowMs)
|
|
89
|
+
if (this.windows.size >= this.maxKeys) this.windows.clear() // under attack with many keys: forget rather than grow
|
|
90
|
+
this.windows.set(key, { start: nowMs, n: 1 })
|
|
91
|
+
return true
|
|
92
|
+
}
|
|
93
|
+
w.n++
|
|
94
|
+
return w.n <= this.limit
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Seconds until the key's window ends. */
|
|
98
|
+
retryAfter(key: string, nowMs: number): number {
|
|
99
|
+
const w = this.windows.get(key)
|
|
100
|
+
return w ? Math.max(1, Math.ceil((w.start + this.windowMs - nowMs) / 1000)) : 1
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** True when the key is already over its limit (does not count). */
|
|
104
|
+
blocked(key: string, nowMs: number): boolean {
|
|
105
|
+
const w = this.windows.get(key)
|
|
106
|
+
return !!w && nowMs - w.start < this.windowMs && w.n > this.limit
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private sweep(nowMs: number) {
|
|
110
|
+
for (const [k, w] of this.windows) if (nowMs - w.start >= this.windowMs) this.windows.delete(k)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// brain org: backup and restore of the organisation database (one file: Chitta's tables and the `org_*` tables together).
|
|
2
|
+
//
|
|
3
|
+
// BACKUP is `VACUUM INTO`: SQLite writes a consistent snapshot of the database as of one moment, through the same connection layer
|
|
4
|
+
// that reads it, so it is safe while the server is running (readers do not block the writer in WAL mode; the copy sees the
|
|
5
|
+
// state at the moment it started) and it includes whatever is still only in the -wal. From an encrypted database the copy is
|
|
6
|
+
// written encrypted with the SAME key (checked in test/org-crypto.test.ts by reading the copy's bytes); from a plain one it is plain.
|
|
7
|
+
// It refuses to overwrite anything (the output file is created exclusively, mode 0600) and verifies the copy before reporting success.
|
|
8
|
+
//
|
|
9
|
+
// RESTORE (`brain org admin restore`) puts a backup in place as the database: stop the server first (it refuses while a live server's
|
|
10
|
+
// lock file exists), it refuses to replace an existing database unless `--force` (the old file is then moved aside, never deleted),
|
|
11
|
+
// it removes a stale -wal/-shm of the old file (a leftover log applied to a different file would corrupt it), and it checks the backup
|
|
12
|
+
// opens with the key before touching anything. Plain `cp` of the backup file back to the database path, with the server stopped and the
|
|
13
|
+
// old -wal/-shm removed, is equivalent; `restore` just does the checks. The key that opens a backup is the key the database had when
|
|
14
|
+
// the backup was made: after `admin rekey`, older backups still need the OLD key, so keep it until they are gone.
|
|
15
|
+
// A restore brings back everything as of the backup: tokens created or revoked, and items added or forgotten, after it are lost.
|
|
16
|
+
import { chmodSync, closeSync, copyFileSync, existsSync, openSync, renameSync, rmSync, statSync } from "node:fs"
|
|
17
|
+
import { dirname, resolve } from "node:path"
|
|
18
|
+
import { OrgKeyError, dbState, liveServer, openSqlite, rebuildVectorIndexAt, validateKey } from "./crypto.ts"
|
|
19
|
+
|
|
20
|
+
export interface BackupResult {
|
|
21
|
+
out: string
|
|
22
|
+
bytes: number
|
|
23
|
+
encrypted: boolean
|
|
24
|
+
items: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Checks that `path` opens (with `key` when encrypted), passes SQLite's integrity check and holds an organisation. */
|
|
28
|
+
function verify(path: string, key: string | undefined): { items: number; org: string } {
|
|
29
|
+
const db = openSqlite(path, key)
|
|
30
|
+
try {
|
|
31
|
+
const ok = (db.query("PRAGMA integrity_check").get() as { integrity_check?: string } | null)?.integrity_check
|
|
32
|
+
if (ok !== "ok") throw new OrgKeyError(`${path} failed SQLite's integrity check`)
|
|
33
|
+
const org = db.query("SELECT value FROM org_meta WHERE key = 'org_id'").get() as { value: string } | null
|
|
34
|
+
if (!org) throw new OrgKeyError(`${path} holds no organisation`)
|
|
35
|
+
const items = (db.query("SELECT count(*) AS c FROM org_items").get() as { c: number }).c
|
|
36
|
+
return { items, org: org.value }
|
|
37
|
+
} finally {
|
|
38
|
+
db.close()
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const sqlStr = (s: string): string => `'${s.replaceAll("'", "''")}'`
|
|
43
|
+
|
|
44
|
+
export function backupDatabase(o: { db: string; out: string; key?: string }): BackupResult {
|
|
45
|
+
const db = resolve(o.db)
|
|
46
|
+
const out = resolve(o.out)
|
|
47
|
+
const state = dbState(db)
|
|
48
|
+
if (state === "missing" || state === "empty") throw new OrgKeyError(`no database at ${db}`)
|
|
49
|
+
if (state === "encrypted" && !o.key) throw new OrgKeyError(`${db} is encrypted: give the key with BRAIN_ORG_KEY or --key-file <path> (the backup stays encrypted with it)`)
|
|
50
|
+
if (state === "plain" && o.key) throw new OrgKeyError(`${db} is not encrypted but a key was given`)
|
|
51
|
+
if (o.key) validateKey(o.key)
|
|
52
|
+
if (existsSync(out)) throw new OrgKeyError(`${out} already exists; a backup never overwrites a file: pick another name`)
|
|
53
|
+
if (!existsSync(dirname(out))) throw new OrgKeyError(`the folder ${dirname(out)} does not exist`)
|
|
54
|
+
const src = openSqlite(db, o.key)
|
|
55
|
+
// VACUUM INTO refuses a target that exists (so it can never overwrite) and creates it itself: a restrictive umask makes it private from the first byte
|
|
56
|
+
const oldMask = process.umask(0o077)
|
|
57
|
+
try {
|
|
58
|
+
try {
|
|
59
|
+
src.exec("PRAGMA busy_timeout = 5000")
|
|
60
|
+
} catch {}
|
|
61
|
+
src.exec(`VACUUM INTO ${sqlStr(out)}`)
|
|
62
|
+
} catch (e) {
|
|
63
|
+
if (!/already exists/i.test(String((e as Error)?.message))) rmSync(out, { force: true }) // never delete a file that was not ours
|
|
64
|
+
throw e instanceof OrgKeyError ? e : new OrgKeyError(`the backup failed: ${String((e as Error)?.message ?? e).split("\n")[0]}`)
|
|
65
|
+
} finally {
|
|
66
|
+
process.umask(oldMask)
|
|
67
|
+
src.close()
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
chmodSync(out, 0o600)
|
|
71
|
+
if (o.key) rebuildVectorIndexAt(out, o.key)
|
|
72
|
+
const v = verify(out, o.key)
|
|
73
|
+
return { out, bytes: statSync(out).size, encrypted: state === "encrypted", items: v.items }
|
|
74
|
+
} catch (e) {
|
|
75
|
+
rmSync(out, { force: true })
|
|
76
|
+
throw e
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface RestoreResult {
|
|
81
|
+
db: string
|
|
82
|
+
items: number
|
|
83
|
+
/** The database that was moved aside (only with --force and an existing database). */
|
|
84
|
+
movedAside?: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function restoreDatabase(o: { from: string; db: string; key?: string; force?: boolean; now?: () => number }): RestoreResult {
|
|
88
|
+
const from = resolve(o.from)
|
|
89
|
+
const db = resolve(o.db)
|
|
90
|
+
if (!existsSync(from)) throw new OrgKeyError(`no backup at ${from}`)
|
|
91
|
+
if (from === db) throw new OrgKeyError("the backup and the database are the same file")
|
|
92
|
+
const state = dbState(from)
|
|
93
|
+
if (state === "empty" || state === "missing") throw new OrgKeyError(`${from} is empty`)
|
|
94
|
+
if (state === "encrypted" && !o.key) throw new OrgKeyError(`${from} is encrypted: give the key it was made with (BRAIN_ORG_KEY or --key-file)`)
|
|
95
|
+
if (state === "plain" && o.key) throw new OrgKeyError(`${from} is not encrypted but a key was given`)
|
|
96
|
+
const live = liveServer(db)
|
|
97
|
+
if (live !== undefined) throw new OrgKeyError(`a server (pid ${live > 0 ? live : "unknown"}) is running on ${db}: stop it first, then restore`)
|
|
98
|
+
if (existsSync(db) && !o.force) throw new OrgKeyError(`${db} already exists; restoring would replace it. Move it away, or pass --force to have it moved aside for you`)
|
|
99
|
+
const v = verify(from, o.key) // before anything is touched
|
|
100
|
+
if (!existsSync(dirname(db))) throw new OrgKeyError(`the folder ${dirname(db)} does not exist`)
|
|
101
|
+
const tmp = `${db}.restoring-${process.pid}.tmp`
|
|
102
|
+
rmSync(tmp, { force: true })
|
|
103
|
+
closeSync(openSync(tmp, "wx", 0o600))
|
|
104
|
+
copyFileSync(from, tmp)
|
|
105
|
+
chmodSync(tmp, 0o600)
|
|
106
|
+
let movedAside: string | undefined
|
|
107
|
+
if (existsSync(db)) {
|
|
108
|
+
movedAside = `${db}.replaced-${(o.now ?? Date.now)()}`
|
|
109
|
+
renameSync(db, movedAside)
|
|
110
|
+
for (const s of ["-wal", "-shm"]) if (existsSync(`${db}${s}`)) renameSync(`${db}${s}`, `${movedAside}${s}`) // the log goes with its file
|
|
111
|
+
}
|
|
112
|
+
for (const s of ["-wal", "-shm"]) rmSync(`${db}${s}`, { force: true }) // a stale log must never be applied to the restored file
|
|
113
|
+
renameSync(tmp, db)
|
|
114
|
+
return { db, items: v.items, ...(movedAside ? { movedAside } : {}) }
|
|
115
|
+
}
|