@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
package/src/org/db.ts ADDED
@@ -0,0 +1,435 @@
1
+ // brain org: the organisation's tables (SQLite). One database file holds BOTH these `org_*` tables and Chitta's own tables
2
+ // (nodes, edges, chunks, ...): the server opens Chitta on the file and runs these queries on Chitta's connection, so there is one
3
+ // connection, one file to back up, and the two always agree. `initOrg` (used by `brain org admin init`, before any server runs)
4
+ // opens its own connection.
5
+ //
6
+ // Every statement is parameterised: no value from a request is ever concatenated into SQL. The only dynamic SQL is the list of
7
+ // `?` placeholders for an IN (...) of a known length.
8
+ //
9
+ // Tokens: only SHA-256 hashes are stored (see auth.ts). The audit log never holds item text or token material.
10
+ import { Database } from "bun:sqlite"
11
+ import { chmodSync, closeSync, existsSync, openSync } from "node:fs"
12
+ import { randomBytes } from "node:crypto"
13
+ import type { MemoryKind } from "../memory/types.ts"
14
+ import { generateToken } from "./auth.ts"
15
+ import { ensureTransactions, openSqlite } from "./crypto.ts"
16
+ import { DEFAULT_POLICY, type AuditEvent, type OrgUser, type Policy, type Role, type Scope } from "./types.ts"
17
+
18
+ /** User, team and organisation ids: short, no ':' (Chitta principals are `u:<id>`, `team:<id>`, `proj:<hash>`). */
19
+ export const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._@-]{0,63}$/
20
+ export const isId = (s: unknown): s is string => typeof s === "string" && ID_RE.test(s)
21
+
22
+ export interface UserRow {
23
+ id: string
24
+ name: string
25
+ role: Role
26
+ disabled: boolean
27
+ created_at: number
28
+ }
29
+ export interface TokenRow {
30
+ id: string
31
+ user_id: string
32
+ hash: string
33
+ created_at: number
34
+ expires_at: number | null
35
+ last_used_at: number | null
36
+ revoked_at: number | null
37
+ }
38
+ export interface ItemRow {
39
+ rid: string
40
+ item_id: string
41
+ project: string
42
+ label: string
43
+ scope: Scope
44
+ owner: string
45
+ team: string | null
46
+ source: string
47
+ kind: MemoryKind
48
+ name: string
49
+ text: string
50
+ occurred_at: string | null
51
+ stored_at: number
52
+ }
53
+
54
+ const SCHEMA = `
55
+ CREATE TABLE IF NOT EXISTS org_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
56
+ CREATE TABLE IF NOT EXISTS org_users (
57
+ id TEXT PRIMARY KEY, name TEXT NOT NULL,
58
+ role TEXT NOT NULL CHECK (role IN ('viewer','editor','admin')),
59
+ disabled INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL);
60
+ CREATE TABLE IF NOT EXISTS org_tokens (
61
+ id TEXT PRIMARY KEY, user_id TEXT NOT NULL, hash TEXT NOT NULL,
62
+ created_at INTEGER NOT NULL, expires_at INTEGER, last_used_at INTEGER, revoked_at INTEGER);
63
+ CREATE TABLE IF NOT EXISTS org_teams (id TEXT PRIMARY KEY, name TEXT NOT NULL);
64
+ CREATE TABLE IF NOT EXISTS org_team_members (team TEXT NOT NULL, user TEXT NOT NULL, PRIMARY KEY (team, user));
65
+ CREATE INDEX IF NOT EXISTS org_team_members_user ON org_team_members(user);
66
+ CREATE TABLE IF NOT EXISTS org_project_members (
67
+ project TEXT NOT NULL, kind TEXT NOT NULL CHECK (kind IN ('user','team')), member TEXT NOT NULL,
68
+ PRIMARY KEY (project, kind, member));
69
+ CREATE TABLE IF NOT EXISTS org_items (
70
+ rid TEXT PRIMARY KEY, item_id TEXT NOT NULL, project TEXT NOT NULL, label TEXT NOT NULL,
71
+ scope TEXT NOT NULL CHECK (scope IN ('private','project','team','org')),
72
+ owner TEXT NOT NULL, team TEXT, source TEXT NOT NULL, kind TEXT NOT NULL, name TEXT NOT NULL, text TEXT NOT NULL,
73
+ occurred_at TEXT, stored_at INTEGER NOT NULL);
74
+ CREATE INDEX IF NOT EXISTS org_items_owner ON org_items(owner);
75
+ CREATE INDEX IF NOT EXISTS org_items_project ON org_items(project, scope);
76
+ CREATE INDEX IF NOT EXISTS org_items_item ON org_items(item_id);
77
+ CREATE INDEX IF NOT EXISTS org_items_stored ON org_items(stored_at);
78
+ CREATE TABLE IF NOT EXISTS org_audit (
79
+ id INTEGER PRIMARY KEY AUTOINCREMENT, at TEXT NOT NULL, actor TEXT NOT NULL, action TEXT NOT NULL,
80
+ project TEXT, scope TEXT, count INTEGER, detail TEXT);
81
+ CREATE INDEX IF NOT EXISTS org_audit_at ON org_audit(at);
82
+ CREATE TABLE IF NOT EXISTS org_item_entities (
83
+ rid TEXT NOT NULL, ekey TEXT NOT NULL, name TEXT NOT NULL, type TEXT, PRIMARY KEY (rid, ekey));
84
+ CREATE INDEX IF NOT EXISTS org_item_entities_key ON org_item_entities(ekey, rid);
85
+ CREATE TABLE IF NOT EXISTS org_item_relations (
86
+ rid TEXT NOT NULL, from_key TEXT NOT NULL, from_name TEXT NOT NULL, to_key TEXT NOT NULL, to_name TEXT NOT NULL, type TEXT NOT NULL,
87
+ PRIMARY KEY (rid, from_key, type, to_key));
88
+ CREATE INDEX IF NOT EXISTS org_item_relations_from ON org_item_relations(from_key, rid);
89
+ CREATE INDEX IF NOT EXISTS org_item_relations_to ON org_item_relations(to_key, rid);
90
+ CREATE TRIGGER IF NOT EXISTS org_items_graph_cascade AFTER DELETE ON org_items BEGIN
91
+ DELETE FROM org_item_entities WHERE rid = old.rid;
92
+ DELETE FROM org_item_relations WHERE rid = old.rid;
93
+ END;
94
+ `
95
+
96
+ /** What a user may see, as one SQL predicate over `org_items` (named parameter `$u`): the owner's own items, org-wide items,
97
+ * project items of projects the user is a member of (directly or through a team), team items of teams the user belongs to.
98
+ * The same predicate answers recall's final check and the project counts. Chitta's own ACL is a second, independent gate. */
99
+ export const VISIBLE_SQL = `(
100
+ org_items.owner = $u
101
+ OR org_items.scope = 'org'
102
+ OR (org_items.scope = 'project' AND EXISTS (
103
+ SELECT 1 FROM org_project_members pm WHERE pm.project = org_items.project AND (
104
+ (pm.kind = 'user' AND pm.member = $u)
105
+ OR (pm.kind = 'team' AND pm.member IN (SELECT team FROM org_team_members WHERE user = $u)))))
106
+ OR (org_items.scope = 'team' AND org_items.team IN (SELECT team FROM org_team_members WHERE user = $u)))`
107
+
108
+ const marks = (n: number): string => Array.from({ length: n }, () => "?").join(",")
109
+
110
+ export class OrgDb {
111
+ readonly db: Database
112
+ constructor(
113
+ db: Database,
114
+ private readonly now: () => number = Date.now,
115
+ ) {
116
+ this.db = ensureTransactions(db) // an encrypted (libSQL) handle gets bun:sqlite's `transaction` and named-parameter behaviour (crypto.ts)
117
+ }
118
+
119
+ /** Opens (creating it, mode 0600) the database file with its own connection. `key` opens an encrypted database (see crypto.ts). */
120
+ static open(path: string, now?: () => number, key?: string): OrgDb {
121
+ if (!existsSync(path)) closeSync(openSync(path, "a", 0o600))
122
+ try {
123
+ chmodSync(path, 0o600)
124
+ } catch {}
125
+ const db = openSqlite(path, key)
126
+ for (const p of ["PRAGMA journal_mode = WAL", "PRAGMA busy_timeout = 5000", "PRAGMA synchronous = NORMAL"]) {
127
+ try {
128
+ db.exec(p)
129
+ } catch {}
130
+ }
131
+ const o = new OrgDb(db, now)
132
+ o.migrate()
133
+ return o
134
+ }
135
+
136
+ migrate(): void {
137
+ this.db.exec(SCHEMA)
138
+ // org_tokens.label (e.g. "sso") was added after the first release: add it to databases created before.
139
+ if (!this.all<{ name: string }>("PRAGMA table_info(org_tokens)").some((c) => c.name === "label")) this.db.exec("ALTER TABLE org_tokens ADD COLUMN label TEXT")
140
+ }
141
+ close(): void {
142
+ this.db.close()
143
+ }
144
+
145
+ private all<T>(sql: string, ...p: unknown[]): T[] {
146
+ return this.db.query(sql).all(...(p as never[])) as T[]
147
+ }
148
+ private get<T>(sql: string, ...p: unknown[]): T | undefined {
149
+ return (this.db.query(sql).get(...(p as never[])) as T | null) ?? undefined
150
+ }
151
+ private run(sql: string, ...p: unknown[]): void {
152
+ this.db.query(sql).run(...(p as never[]))
153
+ }
154
+ tx<T>(fn: () => T): T {
155
+ return this.db.transaction(fn)()
156
+ }
157
+
158
+ // ---- meta, policy ----
159
+ private meta(key: string): string | undefined {
160
+ return this.get<{ value: string }>("SELECT value FROM org_meta WHERE key = ?", key)?.value
161
+ }
162
+ private setMeta(key: string, value: string): void {
163
+ this.run("INSERT INTO org_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", key, value)
164
+ }
165
+ org(): { id: string; name: string } | undefined {
166
+ const id = this.meta("org_id")
167
+ const name = this.meta("org_name")
168
+ return id && name !== undefined ? { id, name } : undefined
169
+ }
170
+ policy(): Policy {
171
+ const raw = this.meta("policy")
172
+ if (!raw) return { ...DEFAULT_POLICY, denyPaths: [], denyTerms: [] }
173
+ return JSON.parse(raw) as Policy
174
+ }
175
+ setPolicy(p: Policy): void {
176
+ this.setMeta("policy", JSON.stringify(p))
177
+ }
178
+ /** Secret used to hash queries and private project ids in the audit log (so an admin cannot confirm a guessed query). */
179
+ auditKey(): string {
180
+ let k = this.meta("audit_key")
181
+ if (!k) {
182
+ k = randomBytes(32).toString("hex")
183
+ this.setMeta("audit_key", k)
184
+ }
185
+ return k
186
+ }
187
+
188
+ // ---- single sign-on configuration (sso.ts owns the shape; the secret is its own row and is never read back by the API) ----
189
+ ssoRaw(): string | undefined {
190
+ return this.meta("sso")
191
+ }
192
+ setSsoRaw(json: string): void {
193
+ this.setMeta("sso", json)
194
+ }
195
+ ssoSecret(): string | undefined {
196
+ return this.meta("sso_secret")
197
+ }
198
+ setSsoSecret(secret: string): void {
199
+ this.setMeta("sso_secret", secret)
200
+ }
201
+
202
+ // ---- users ----
203
+ user(id: string): UserRow | undefined {
204
+ const r = this.get<{ id: string; name: string; role: Role; disabled: number; created_at: number }>("SELECT * FROM org_users WHERE id = ?", id)
205
+ return r ? { ...r, disabled: !!r.disabled } : undefined
206
+ }
207
+ users(): UserRow[] {
208
+ return this.all<{ id: string; name: string; role: Role; disabled: number; created_at: number }>("SELECT * FROM org_users ORDER BY id").map((r) => ({ ...r, disabled: !!r.disabled }))
209
+ }
210
+ addUser(id: string, name: string, role: Role): void {
211
+ this.run("INSERT INTO org_users (id, name, role, disabled, created_at) VALUES (?,?,?,0,?)", id, name, role, this.now())
212
+ }
213
+ setDisabled(id: string, disabled: boolean): void {
214
+ this.run("UPDATE org_users SET disabled = ? WHERE id = ?", disabled ? 1 : 0, id)
215
+ }
216
+ activeAdmins(): number {
217
+ return this.get<{ c: number }>("SELECT count(*) c FROM org_users WHERE role = 'admin' AND disabled = 0")!.c
218
+ }
219
+ teamsOf(userId: string): string[] {
220
+ return this.all<{ team: string }>("SELECT team FROM org_team_members WHERE user = ? ORDER BY team", userId).map((r) => r.team)
221
+ }
222
+ orgUser(row: UserRow): OrgUser {
223
+ return { id: row.id, name: row.name, role: row.role, teams: this.teamsOf(row.id), ...(row.disabled ? { disabled: true } : {}) }
224
+ }
225
+
226
+ // ---- tokens ----
227
+ createToken(userId: string, expiresInDays?: number, label?: string): { id: string; token: string } {
228
+ const t = generateToken()
229
+ const at = this.now()
230
+ const exp = expiresInDays ? at + expiresInDays * 86_400_000 : null
231
+ this.run("INSERT INTO org_tokens (id, user_id, hash, created_at, expires_at, label) VALUES (?,?,?,?,?,?)", t.id, userId, t.hash, at, exp, label ?? null)
232
+ return { id: t.id, token: t.token }
233
+ }
234
+ token(id: string): TokenRow | undefined {
235
+ return this.get<TokenRow>("SELECT * FROM org_tokens WHERE id = ?", id)
236
+ }
237
+ touchToken(id: string, at: number): void {
238
+ this.run("UPDATE org_tokens SET last_used_at = ? WHERE id = ?", at, id)
239
+ }
240
+ /** Token metadata for admins: never the token and never its hash. */
241
+ tokens(): Array<{ id: string; user: string; created: string; expires?: string; lastUsed?: string; revoked: boolean; label?: string }> {
242
+ const iso = (n: number) => new Date(n).toISOString()
243
+ return this.all<TokenRow & { label: string | null }>("SELECT id, user_id, created_at, expires_at, last_used_at, revoked_at, label FROM org_tokens ORDER BY created_at, id").map((r) => ({
244
+ id: r.id,
245
+ user: r.user_id,
246
+ created: iso(r.created_at),
247
+ ...(r.expires_at != null ? { expires: iso(r.expires_at) } : {}),
248
+ ...(r.last_used_at != null ? { lastUsed: iso(r.last_used_at) } : {}),
249
+ revoked: r.revoked_at != null,
250
+ ...(r.label ? { label: r.label } : {}),
251
+ }))
252
+ }
253
+ revokeToken(id: string): boolean {
254
+ const t = this.token(id)
255
+ if (!t) return false
256
+ if (t.revoked_at == null) this.run("UPDATE org_tokens SET revoked_at = ? WHERE id = ?", this.now(), id)
257
+ return true
258
+ }
259
+
260
+ // ---- teams and membership ----
261
+ team(id: string): { id: string; name: string } | undefined {
262
+ return this.get("SELECT id, name FROM org_teams WHERE id = ?", id)
263
+ }
264
+ teams(): Array<{ id: string; name: string }> {
265
+ return this.all("SELECT id, name FROM org_teams ORDER BY id")
266
+ }
267
+ teamMembers(team: string): string[] {
268
+ return this.all<{ user: string }>("SELECT user FROM org_team_members WHERE team = ? ORDER BY user", team).map((r) => r.user)
269
+ }
270
+ /** Creates or renames the team and REPLACES its member list. */
271
+ setTeam(id: string, name: string, members: string[]): void {
272
+ this.tx(() => {
273
+ this.run("INSERT INTO org_teams (id, name) VALUES (?,?) ON CONFLICT(id) DO UPDATE SET name = excluded.name", id, name)
274
+ this.run("DELETE FROM org_team_members WHERE team = ?", id)
275
+ for (const m of new Set(members)) this.run("INSERT INTO org_team_members (team, user) VALUES (?,?)", id, m)
276
+ })
277
+ }
278
+ addTeamMember(team: string, user: string): void {
279
+ this.run("INSERT OR IGNORE INTO org_team_members (team, user) VALUES (?,?)", team, user)
280
+ }
281
+ projectMembers(project: string): { users: string[]; teams: string[] } {
282
+ const rows = this.all<{ kind: "user" | "team"; member: string }>("SELECT kind, member FROM org_project_members WHERE project = ? ORDER BY member", project)
283
+ return { users: rows.filter((r) => r.kind === "user").map((r) => r.member), teams: rows.filter((r) => r.kind === "team").map((r) => r.member) }
284
+ }
285
+ addProjectMember(project: string, kind: "user" | "team", member: string): void {
286
+ this.run("INSERT OR IGNORE INTO org_project_members (project, kind, member) VALUES (?,?,?)", project, kind, member)
287
+ }
288
+ removeProjectMember(project: string, kind: "user" | "team", member: string): void {
289
+ this.run("DELETE FROM org_project_members WHERE project = ? AND kind = ? AND member = ?", project, kind, member)
290
+ }
291
+ /** Direct member, or member of a team that is a member. */
292
+ isProjectMember(user: string, project: string): boolean {
293
+ return !!this.get(
294
+ `SELECT 1 FROM org_project_members pm WHERE pm.project = $p AND (
295
+ (pm.kind = 'user' AND pm.member = $u) OR (pm.kind = 'team' AND pm.member IN (SELECT team FROM org_team_members WHERE user = $u))) LIMIT 1`,
296
+ { $p: project, $u: user },
297
+ )
298
+ }
299
+ isTeamMember(user: string, team: string): boolean {
300
+ return !!this.get("SELECT 1 FROM org_team_members WHERE team = ? AND user = ?", team, user)
301
+ }
302
+ /** Every project that has members, with its effective (direct + through teams) user ids: for syncing Chitta's groups. */
303
+ effectiveProjectMembers(): Map<string, string[]> {
304
+ const rows = this.all<{ project: string; user: string }>(
305
+ `SELECT project, member AS user FROM org_project_members WHERE kind = 'user'
306
+ UNION SELECT pm.project, tm.user FROM org_project_members pm JOIN org_team_members tm ON tm.team = pm.member WHERE pm.kind = 'team'`,
307
+ )
308
+ const out = new Map<string, string[]>()
309
+ for (const r of rows) (out.get(r.project) ?? out.set(r.project, []).get(r.project)!).push(r.user)
310
+ return out
311
+ }
312
+ projectsOfMember(user: string): string[] {
313
+ return this.all<{ project: string }>(
314
+ `SELECT DISTINCT project FROM org_project_members WHERE (kind = 'user' AND member = $u)
315
+ OR (kind = 'team' AND member IN (SELECT team FROM org_team_members WHERE user = $u))`,
316
+ { $u: user },
317
+ ).map((r) => r.project)
318
+ }
319
+
320
+ // ---- items ----
321
+ item(rid: string): ItemRow | undefined {
322
+ return this.get<ItemRow>("SELECT * FROM org_items WHERE rid = ?", rid)
323
+ }
324
+ insertItem(r: ItemRow): void {
325
+ this.run(
326
+ "INSERT INTO org_items (rid, item_id, project, label, scope, owner, team, source, kind, name, text, occurred_at, stored_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
327
+ r.rid, r.item_id, r.project, r.label, r.scope, r.owner, r.team, r.source, r.kind, r.name, r.text, r.occurred_at, r.stored_at,
328
+ )
329
+ }
330
+ updateItemContent(rid: string, c: { label: string; kind: MemoryKind; name: string; text: string; occurred_at: string | null; stored_at: number }): void {
331
+ this.run("UPDATE org_items SET label = ?, kind = ?, name = ?, text = ?, occurred_at = ?, stored_at = ? WHERE rid = ?", c.label, c.kind, c.name, c.text, c.occurred_at, c.stored_at, rid)
332
+ }
333
+ setItemScope(rid: string, scope: Scope, team: string | null): void {
334
+ this.run("UPDATE org_items SET scope = ?, team = ? WHERE rid = ?", scope, team, rid)
335
+ }
336
+ deleteItem(rid: string): void {
337
+ this.run("DELETE FROM org_items WHERE rid = ?", rid)
338
+ }
339
+ countOwned(owner: string): number {
340
+ return this.get<{ c: number }>("SELECT count(*) c FROM org_items WHERE owner = ?", owner)!.c
341
+ }
342
+ /** The caller's OWN rows with these item ids (any project). */
343
+ ownedByItemIds(owner: string, ids: string[]): ItemRow[] {
344
+ if (!ids.length) return []
345
+ return this.all<ItemRow>(`SELECT * FROM org_items WHERE owner = ? AND item_id IN (${marks(ids.length)})`, owner, ...ids)
346
+ }
347
+ /** Rows matching every given filter (an unset filter does not restrict). `owner` limits to one owner. */
348
+ itemsWhere(f: { owner?: string; project?: string; source?: string; ids?: string[] }): ItemRow[] {
349
+ const where: string[] = []
350
+ const p: unknown[] = []
351
+ if (f.owner !== undefined) (where.push("owner = ?"), p.push(f.owner))
352
+ if (f.project !== undefined) (where.push("project = ?"), p.push(f.project))
353
+ if (f.source !== undefined) (where.push("source = ?"), p.push(f.source))
354
+ if (f.ids) {
355
+ if (!f.ids.length) return []
356
+ where.push(`item_id IN (${marks(f.ids.length)})`)
357
+ p.push(...f.ids)
358
+ }
359
+ if (!where.length) return []
360
+ return this.all<ItemRow>(`SELECT * FROM org_items WHERE ${where.join(" AND ")}`, ...p)
361
+ }
362
+ /** Of these row ids, the ones `user` may see. */
363
+ visibleRows(user: string, rids: string[]): Map<string, ItemRow> {
364
+ const out = new Map<string, ItemRow>()
365
+ if (!rids.length) return out
366
+ const sql = `SELECT * FROM org_items WHERE rid IN (${rids.map((_, i) => `$r${i}`).join(",")}) AND ${VISIBLE_SQL}`
367
+ const params: Record<string, string> = { $u: user }
368
+ rids.forEach((r, i) => (params[`$r${i}`] = r))
369
+ for (const row of this.all<ItemRow>(sql, params)) out.set(row.rid, row)
370
+ return out
371
+ }
372
+ /** Projects `user` may see: those with at least one visible item, plus those they are a member of. */
373
+ visibleProjects(user: string): Array<{ id: string; label: string; items: number }> {
374
+ const rows = this.all<{ id: string; label: string; items: number }>(
375
+ `SELECT project AS id, max(label) AS label, count(*) AS items FROM org_items WHERE ${VISIBLE_SQL} GROUP BY project ORDER BY items DESC, project`,
376
+ { $u: user },
377
+ )
378
+ const have = new Set(rows.map((r) => r.id))
379
+ for (const id of this.projectsOfMember(user)) if (!have.has(id)) rows.push({ id, label: id, items: 0 })
380
+ return rows
381
+ }
382
+ expiredItems(before: number): ItemRow[] {
383
+ return this.all<ItemRow>("SELECT * FROM org_items WHERE stored_at < ?", before)
384
+ }
385
+
386
+ // ---- audit ----
387
+ audit(e: { actor: string; action: string; project?: string; scope?: Scope; count?: number; detail?: string }): void {
388
+ this.run(
389
+ "INSERT INTO org_audit (at, actor, action, project, scope, count, detail) VALUES (?,?,?,?,?,?,?)",
390
+ new Date(this.now()).toISOString(), e.actor, e.action, e.project ?? null, e.scope ?? null, e.count ?? null, e.detail ?? null,
391
+ )
392
+ }
393
+ /** The newest `limit` events at or after `sinceIso`, oldest first. */
394
+ auditEvents(sinceIso: string | undefined, limit: number): AuditEvent[] {
395
+ const rows = this.all<{ at: string; actor: string; action: string; project: string | null; scope: Scope | null; count: number | null; detail: string | null }>(
396
+ "SELECT at, actor, action, project, scope, count, detail FROM org_audit WHERE at >= ? ORDER BY id DESC LIMIT ?",
397
+ sinceIso ?? "",
398
+ limit,
399
+ )
400
+ return rows.reverse().map((r) => ({
401
+ at: r.at,
402
+ actor: r.actor,
403
+ action: r.action,
404
+ ...(r.project != null ? { project: r.project } : {}),
405
+ ...(r.scope != null ? { scope: r.scope } : {}),
406
+ ...(r.count != null ? { count: r.count } : {}),
407
+ ...(r.detail != null ? { detail: r.detail } : {}),
408
+ }))
409
+ }
410
+ }
411
+
412
+ export const openOrgDb = (path: string, now?: () => number, key?: string): OrgDb => OrgDb.open(path, now, key)
413
+
414
+ /** Creates the organisation database (mode 0600), the organisation, its first admin and the admin's first token. The token is returned
415
+ * ONCE: only its hash is stored. Refuses to run on a database that already holds an organisation. */
416
+ export function initOrg(dbPath: string, org: { id: string; name: string }, admin: { id: string; name: string }, opts: { key?: string } = {}): { token: string } {
417
+ if (!isId(org.id)) throw new Error("organisation id must be 1-64 characters: letters, digits, . _ @ - (starting with a letter or digit)")
418
+ if (!isId(admin.id)) throw new Error("admin id must be 1-64 characters: letters, digits, . _ @ - (starting with a letter or digit)")
419
+ if (!org.name.trim() || org.name.length > 200) throw new Error("organisation name must be 1-200 characters")
420
+ if (!admin.name.trim() || admin.name.length > 200) throw new Error("admin name must be 1-200 characters")
421
+ const o = OrgDb.open(dbPath, undefined, opts.key) // with a key the new database is created encrypted
422
+ try {
423
+ if (o.org()) throw new Error(`${dbPath} already holds an organisation (${o.org()!.id}); refusing to overwrite it`)
424
+ return o.tx(() => {
425
+ o.db.query("INSERT INTO org_meta (key, value) VALUES ('org_id', ?), ('org_name', ?), ('schema', '1'), ('policy', ?)").run(org.id, org.name.trim(), JSON.stringify(DEFAULT_POLICY))
426
+ o.auditKey()
427
+ o.addUser(admin.id, admin.name.trim(), "admin")
428
+ const t = o.createToken(admin.id)
429
+ o.audit({ actor: "-", action: "org.init", detail: `admin=${admin.id}` })
430
+ return { token: t.token }
431
+ })
432
+ } finally {
433
+ o.close()
434
+ }
435
+ }