@makerstufferupper/flypi 0.0.0-stage → 0.1.3

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 (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +64 -2
  3. package/assets/screenshot.png +0 -0
  4. package/bin/flypi.mjs +28 -0
  5. package/dist/ui/assets/index-CG1CuGxu.js +105 -0
  6. package/dist/ui/assets/index-qaKjH5fH.css +1 -0
  7. package/dist/ui/flypi-logo-dark.jpeg +0 -0
  8. package/dist/ui/flypi-logo-light.jpeg +0 -0
  9. package/dist/ui/index.html +17 -0
  10. package/package.json +71 -5
  11. package/scripts/benchmark-nli.ts +273 -0
  12. package/scripts/flypi.mjs +310 -0
  13. package/scripts/probe-turn-boundary.ts +97 -0
  14. package/scripts/probe.ts +119 -0
  15. package/scripts/record-transcript.ts +63 -0
  16. package/scripts/reembed-memory.ts +41 -0
  17. package/scripts/release.mjs +38 -0
  18. package/scripts/secret.ts +24 -0
  19. package/scripts/serve-daemon.ts +101 -0
  20. package/scripts/serve-memory.ts +78 -0
  21. package/scripts/serve.ts +89 -0
  22. package/scripts/smoke-memory.mjs +239 -0
  23. package/scripts/smoke-secrets.mjs +153 -0
  24. package/scripts/smoke.mjs +59 -0
  25. package/scripts/start.mjs +294 -0
  26. package/scripts/turn-boundary.ts +52 -0
  27. package/scripts/update.ts +92 -0
  28. package/scripts/warm-models.ts +41 -0
  29. package/src/agent/config-files.ts +529 -0
  30. package/src/agent/mcp-signin.ts +137 -0
  31. package/src/agent/mcp-status.ts +168 -0
  32. package/src/agent/onboarding.ts +74 -0
  33. package/src/agent/packages.ts +150 -0
  34. package/src/agent/paths.ts +80 -0
  35. package/src/agent/permission-gate.ts +70 -0
  36. package/src/agent/pi-mcp.ts +97 -0
  37. package/src/agent/runtime.ts +347 -0
  38. package/src/daemon/index.ts +442 -0
  39. package/src/daemon/jobs.ts +202 -0
  40. package/src/daemon/update.ts +248 -0
  41. package/src/memory/capture.ts +109 -0
  42. package/src/memory/config.ts +90 -0
  43. package/src/memory/consolidate.ts +218 -0
  44. package/src/memory/db.ts +393 -0
  45. package/src/memory/embed.ts +154 -0
  46. package/src/memory/judge.ts +167 -0
  47. package/src/memory/mcp.ts +496 -0
  48. package/src/memory/media.ts +135 -0
  49. package/src/memory/progress.ts +39 -0
  50. package/src/memory/recall.ts +528 -0
  51. package/src/memory/review.ts +86 -0
  52. package/src/memory/store.ts +1034 -0
  53. package/src/memory/wiki.ts +140 -0
  54. package/src/secrets/cli.ts +248 -0
  55. package/src/secrets/config.ts +133 -0
  56. package/src/secrets/crypto.ts +180 -0
  57. package/src/secrets/extension.ts +253 -0
  58. package/src/secrets/redact.ts +132 -0
  59. package/src/secrets/vault.ts +396 -0
  60. package/src/server/event-log.ts +49 -0
  61. package/src/server/extension-ui.ts +118 -0
  62. package/src/server/index.ts +398 -0
  63. package/src/server/memory-api.ts +158 -0
  64. package/src/server/protocol.ts +947 -0
  65. package/src/server/run-store.ts +136 -0
  66. package/src/server/secrets-api.ts +88 -0
  67. package/src/server/session-registry.ts +593 -0
  68. package/src/shared/attachments.ts +40 -0
  69. package/src/shared/cache-probe.ts +47 -0
  70. package/src/shared/markdown.ts +17 -0
  71. package/src/shared/metrics.ts +212 -0
  72. package/src/shared/sqlite.ts +74 -0
  73. package/src/shared/transcript.ts +404 -0
  74. package/src/shared/turn-boundary.ts +98 -0
  75. package/src/shared/views.ts +31 -0
@@ -0,0 +1,393 @@
1
+ /**
2
+ * The SQLite store behind flypi's memory (docs/memory.md Phase 3).
3
+ *
4
+ * One file, one writer (docs/risks.md risk 3): the process holding the memory store owns this file, and
5
+ * anything else that ever opens it (a future `flypi memory …` subcommand, risk 15) opens it read-only with
6
+ * `busy_timeout`. The shared pragmas and migration engine live in `src/shared/sqlite.ts`, because the
7
+ * backend has its own database under the same rules and a second copy of them is how the two would drift.
8
+ *
9
+ * What belongs in this file is what the facts are joined to: `memories`, `media`, `memory_audit`, the
10
+ * `jobs`/`job_runs` queue that every automated memory mutation is audited against (`memory_audit.job_run_id`),
11
+ * and the `conversations`/`turns` record of what retrieval was shown and what a reply cited
12
+ * (`memories.turn_id`). The one table that did not belong here — `runs`, "is a turn in flight right now" —
13
+ * moved to the backend's own store in v5.
14
+ */
15
+ import { DatabaseSync } from "node:sqlite";
16
+ import { migrate, openFlypiDb, schemaVersion, type Migration } from "../shared/sqlite.ts";
17
+
18
+ const SCHEMA_V1 = `
19
+ CREATE TABLE conversations (
20
+ id TEXT PRIMARY KEY,
21
+ cwd TEXT NOT NULL,
22
+ title TEXT,
23
+ pi_session_file TEXT,
24
+ created_at TEXT NOT NULL
25
+ );
26
+
27
+ CREATE TABLE turns (
28
+ id INTEGER PRIMARY KEY,
29
+ conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
30
+ user_text TEXT NOT NULL,
31
+ assistant_text TEXT,
32
+ tool_calls_json TEXT,
33
+ usage_json TEXT,
34
+ created_at TEXT NOT NULL
35
+ );
36
+
37
+ -- Daemon-restart recovery (Phase 4.5): a row left 'running' by a dead process becomes 'interrupted'.
38
+ CREATE TABLE runs (
39
+ conversation_id TEXT PRIMARY KEY REFERENCES conversations(id) ON DELETE CASCADE,
40
+ status TEXT NOT NULL CHECK (status IN ('running','idle','aborted','interrupted')),
41
+ last_event_seq INTEGER NOT NULL DEFAULT 0,
42
+ updated_at TEXT NOT NULL
43
+ );
44
+
45
+ CREATE TABLE jobs (
46
+ id INTEGER PRIMARY KEY,
47
+ kind TEXT NOT NULL,
48
+ next_run_at TEXT NOT NULL,
49
+ payload TEXT NOT NULL DEFAULT '{}',
50
+ locked_by TEXT,
51
+ locked_at TEXT
52
+ );
53
+
54
+ CREATE TABLE job_runs (
55
+ id INTEGER PRIMARY KEY,
56
+ job_id INTEGER NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
57
+ started_at TEXT NOT NULL,
58
+ finished_at TEXT,
59
+ status TEXT NOT NULL CHECK (status IN ('running','ok','failed')),
60
+ detail TEXT
61
+ );
62
+
63
+ -- origin_class/session_kind are authority, and only code sets them (docs/memory.md Phase 4).
64
+ CREATE TABLE memories (
65
+ id INTEGER PRIMARY KEY,
66
+ kind TEXT NOT NULL,
67
+ body TEXT NOT NULL,
68
+ tags TEXT NOT NULL DEFAULT '[]',
69
+ embedding BLOB,
70
+ embed_model TEXT,
71
+ dims INTEGER,
72
+ embed_quantized INTEGER,
73
+ embedded_at TEXT,
74
+ origin_class TEXT NOT NULL CHECK (origin_class IN ('owner','agent','untrusted','system')),
75
+ session_kind TEXT NOT NULL CHECK (session_kind IN ('interactive','background','subagent','unknown')),
76
+ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','superseded','deleted')),
77
+ supersedes INTEGER REFERENCES memories(id),
78
+ superseded_by INTEGER REFERENCES memories(id),
79
+ merged_from TEXT NOT NULL DEFAULT '[]',
80
+ strength REAL NOT NULL DEFAULT 1.0,
81
+ use_count INTEGER NOT NULL DEFAULT 0,
82
+ cited_count INTEGER NOT NULL DEFAULT 0,
83
+ created_at TEXT NOT NULL,
84
+ last_verified_at TEXT,
85
+ session_id TEXT,
86
+ turn_id INTEGER REFERENCES turns(id)
87
+ );
88
+ CREATE INDEX memories_status ON memories(status);
89
+ CREATE INDEX memories_embed_identity ON memories(embed_model, dims, embed_quantized);
90
+
91
+ -- Trigger-maintained: an app-side dual write is how a search index drifts from its rows.
92
+ CREATE VIRTUAL TABLE memories_fts USING fts5(body, tags, content=memories, content_rowid=id);
93
+ CREATE TRIGGER memories_ai AFTER INSERT ON memories BEGIN
94
+ INSERT INTO memories_fts(rowid, body, tags) VALUES (new.id, new.body, new.tags);
95
+ END;
96
+ CREATE TRIGGER memories_ad AFTER DELETE ON memories BEGIN
97
+ INSERT INTO memories_fts(memories_fts, rowid, body, tags) VALUES ('delete', old.id, old.body, old.tags);
98
+ END;
99
+ CREATE TRIGGER memories_au AFTER UPDATE ON memories BEGIN
100
+ INSERT INTO memories_fts(memories_fts, rowid, body, tags) VALUES ('delete', old.id, old.body, old.tags);
101
+ INSERT INTO memories_fts(rowid, body, tags) VALUES (new.id, new.body, new.tags);
102
+ END;
103
+
104
+ -- Every automated mutation keeps its pre-image, so anything a sweep did is undoable (risk 9).
105
+ CREATE TABLE memory_audit (
106
+ id INTEGER PRIMARY KEY,
107
+ memory_id INTEGER NOT NULL REFERENCES memories(id) ON DELETE CASCADE,
108
+ action TEXT NOT NULL CHECK (action IN ('create','edit','supersede','forget','verify','reembed')),
109
+ actor TEXT NOT NULL,
110
+ pre_image TEXT NOT NULL,
111
+ reason TEXT,
112
+ job_run_id INTEGER REFERENCES job_runs(id),
113
+ created_at TEXT NOT NULL
114
+ );
115
+ CREATE INDEX memory_audit_memory ON memory_audit(memory_id);
116
+ `;
117
+
118
+ export const MIGRATIONS: Migration[] = [
119
+ { version: 1, sql: SCHEMA_V1 },
120
+ {
121
+ // Multimodal memory (docs/decisions.md): the bytes are the memory, so they live here and not
122
+ // in a directory that can drift away from the row. `modality` is what the embedder was shown.
123
+ version: 2,
124
+ sql: `
125
+ ALTER TABLE memories ADD COLUMN modality TEXT NOT NULL DEFAULT 'text' CHECK (modality IN ('text','image','audio'));
126
+
127
+ CREATE TABLE media (
128
+ id INTEGER PRIMARY KEY,
129
+ memory_id INTEGER NOT NULL REFERENCES memories(id) ON DELETE CASCADE,
130
+ kind TEXT NOT NULL CHECK (kind IN ('image','audio')),
131
+ mime TEXT NOT NULL,
132
+ path TEXT NOT NULL,
133
+ bytes BLOB NOT NULL,
134
+ created_at TEXT NOT NULL
135
+ );
136
+ CREATE INDEX media_memory ON media(memory_id);
137
+ `,
138
+ },
139
+ {
140
+ // What a turn was shown and what it cited (docs/memory.md Phase 4). `memories.cited_count` says a
141
+ // fact was useful; only a per-turn record says how often retrieval paid off, which is what the
142
+ // dreaming promotion gates will read. The memory server stays the only writer, so a turn arrives
143
+ // through the MCP surface rather than from the agent process.
144
+ version: 3,
145
+ sql: `
146
+ ALTER TABLE turns ADD COLUMN retrieved_json TEXT;
147
+ ALTER TABLE turns ADD COLUMN cited_json TEXT;
148
+ `,
149
+ },
150
+ {
151
+ // A recorded attempt outlives the queue entry that scheduled it (docs/architecture.md Phase 4.5).
152
+ // `reembed()` closes its job row once the index is clean, and `ON DELETE CASCADE` was taking the
153
+ // history of every attempt with it — so the scheduler could not say when the last run was or what it
154
+ // changed, and `memory_audit.job_run_id` would have pointed at a row that no longer exists.
155
+ version: 4,
156
+ deferForeignKeys: true,
157
+ sql: `
158
+ CREATE TABLE job_runs_v4 (
159
+ id INTEGER PRIMARY KEY,
160
+ job_id INTEGER REFERENCES jobs(id) ON DELETE SET NULL,
161
+ kind TEXT NOT NULL DEFAULT '',
162
+ started_at TEXT NOT NULL,
163
+ finished_at TEXT,
164
+ status TEXT NOT NULL CHECK (status IN ('running','ok','failed')),
165
+ detail TEXT
166
+ );
167
+ INSERT INTO job_runs_v4 (id, job_id, started_at, finished_at, status, detail) SELECT id, job_id, started_at, finished_at, status, detail FROM job_runs;
168
+ DROP TABLE job_runs;
169
+ ALTER TABLE job_runs_v4 RENAME TO job_runs;
170
+ CREATE INDEX job_runs_job ON job_runs(job_id);
171
+ `,
172
+ },
173
+ {
174
+ // The store split (docs/decisions.md: one database per component). `runs` recorded which conversation
175
+ // had a turn in flight — a fact about the process running pi sessions, not about a memory, and the
176
+ // backend was the only thing that ever wrote it. It now lives in the backend's own database, which
177
+ // copies the rows out of this file before migrating it (`openRunStore`), so no record is lost.
178
+ // `conversations` stays: it is the row `turns`, and so every memory's provenance, points at.
179
+ version: 5,
180
+ sql: `
181
+ DROP TABLE IF EXISTS runs;
182
+ `,
183
+ },
184
+ {
185
+ // The secrets vault (docs/secrets.md Phase 5.5) lives in this file on purpose: one store, one writer, one
186
+ // backup, and the redaction boundary that keeps a credential out of `turns` and `memories` is the one
187
+ // thing both halves need. Ciphertext only — value_cipher/value_nonce/value_tag are an AES-256-GCM seal,
188
+ // so a tampered row fails loudly instead of decrypting to garbage — and mask/fingerprint are what every
189
+ // read path returns instead of the value.
190
+ //
191
+ // `name` is unique only among active rows: deleting is a tombstone, so a name has to be usable again
192
+ // without losing the row that used to hold it. Same rule as memories.
193
+ version: 6,
194
+ sql: `
195
+ CREATE TABLE secrets (
196
+ id INTEGER PRIMARY KEY,
197
+ name TEXT NOT NULL,
198
+ kind TEXT NOT NULL CHECK (kind IN ('api_key','token','password','ssh_private_key','ssh_public_key','generic')),
199
+ value_cipher BLOB NOT NULL,
200
+ value_nonce BLOB NOT NULL,
201
+ value_tag BLOB NOT NULL,
202
+ key_version INTEGER NOT NULL,
203
+ fingerprint TEXT NOT NULL,
204
+ mask TEXT NOT NULL,
205
+ meta TEXT NOT NULL DEFAULT '{}',
206
+ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','deleted')),
207
+ created_at TEXT NOT NULL,
208
+ updated_at TEXT,
209
+ last_used_at TEXT,
210
+ use_count INTEGER NOT NULL DEFAULT 0
211
+ );
212
+ CREATE UNIQUE INDEX secrets_name_active ON secrets(name) WHERE status = 'active';
213
+ CREATE INDEX secrets_status ON secrets(status);
214
+ CREATE INDEX secrets_key_version ON secrets(key_version);
215
+
216
+ -- Every use and every change is recorded, with the pre-image a rotation leaves behind. "reencrypt" is not in
217
+ -- the doc's list of actions: Phase 5.5 asks for a pre-image after a key rotation, and re-sealing every row
218
+ -- under a new key is exactly what an audit row is for.
219
+ CREATE TABLE secret_audit (
220
+ id INTEGER PRIMARY KEY,
221
+ secret_id INTEGER NOT NULL REFERENCES secrets(id) ON DELETE CASCADE,
222
+ action TEXT NOT NULL CHECK (action IN ('put','rotated','deleted','restored','used','revealed','reencrypt')),
223
+ actor TEXT NOT NULL,
224
+ pre_image TEXT,
225
+ reason TEXT,
226
+ created_at TEXT NOT NULL
227
+ );
228
+ CREATE INDEX secret_audit_secret ON secret_audit(secret_id);
229
+ `,
230
+ },
231
+ {
232
+ // A `yaml` kind: a kubeconfig or an MCP config blob stored whole so `secret_file` can hand it to a
233
+ // command, with its syntax checked on the way in (src/secrets/crypto.ts `validate`). SQLite cannot alter
234
+ // a CHECK, so `secrets` is rebuilt — same rows, same ids, so the audit rows still point at them.
235
+ // `secret_audit` is rebuilt too rather than left alone: renaming a table rewrites the foreign keys that
236
+ // refer to it, so its constraint would otherwise end up naming the dropped copy.
237
+ version: 7,
238
+ deferForeignKeys: true,
239
+ sql: `
240
+ ALTER TABLE secrets RENAME TO secrets_v6;
241
+ -- The rename left these indexes behind on the old table, and the new one needs their names back.
242
+ DROP INDEX secrets_name_active;
243
+ DROP INDEX secrets_status;
244
+ DROP INDEX secrets_key_version;
245
+ CREATE TABLE secrets (
246
+ id INTEGER PRIMARY KEY,
247
+ name TEXT NOT NULL,
248
+ kind TEXT NOT NULL CHECK (kind IN ('api_key','token','password','ssh_private_key','ssh_public_key','yaml','generic')),
249
+ value_cipher BLOB NOT NULL,
250
+ value_nonce BLOB NOT NULL,
251
+ value_tag BLOB NOT NULL,
252
+ key_version INTEGER NOT NULL,
253
+ fingerprint TEXT NOT NULL,
254
+ mask TEXT NOT NULL,
255
+ meta TEXT NOT NULL DEFAULT '{}',
256
+ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','deleted')),
257
+ created_at TEXT NOT NULL,
258
+ updated_at TEXT,
259
+ last_used_at TEXT,
260
+ use_count INTEGER NOT NULL DEFAULT 0
261
+ );
262
+ INSERT INTO secrets SELECT * FROM secrets_v6;
263
+ CREATE UNIQUE INDEX secrets_name_active ON secrets(name) WHERE status = 'active';
264
+ CREATE INDEX secrets_status ON secrets(status);
265
+ CREATE INDEX secrets_key_version ON secrets(key_version);
266
+ ALTER TABLE secret_audit RENAME TO secret_audit_v6;
267
+ DROP INDEX secret_audit_secret;
268
+ CREATE TABLE secret_audit (
269
+ id INTEGER PRIMARY KEY,
270
+ secret_id INTEGER NOT NULL REFERENCES secrets(id) ON DELETE CASCADE,
271
+ action TEXT NOT NULL CHECK (action IN ('put','rotated','deleted','restored','used','revealed','reencrypt')),
272
+ actor TEXT NOT NULL,
273
+ pre_image TEXT,
274
+ reason TEXT,
275
+ created_at TEXT NOT NULL
276
+ );
277
+ INSERT INTO secret_audit SELECT * FROM secret_audit_v6;
278
+ CREATE INDEX secret_audit_secret ON secret_audit(secret_id);
279
+ DROP TABLE secret_audit_v6;
280
+ DROP TABLE secrets_v6;
281
+ `,
282
+ },
283
+ {
284
+ // One `password` kind where there were three: `api_key`, `token` and `password` are the same shape — one line
285
+ // of characters injected as-is — and the only thing the extra labels did was make the dropdown a guess
286
+ // (src/secrets/crypto.ts). The CHECK is rebuilt around the smaller list, mapping the labels that go away onto
287
+ // the one that stays. `secret_audit` moves with it for the reason v7 learned: renaming a table rewrites the
288
+ // foreign keys that refer to it, and leaving it behind would point them at the copy being dropped.
289
+ version: 8,
290
+ deferForeignKeys: true,
291
+ sql: `
292
+ ALTER TABLE secrets RENAME TO secrets_v7;
293
+ DROP INDEX secrets_name_active;
294
+ DROP INDEX secrets_status;
295
+ DROP INDEX secrets_key_version;
296
+ CREATE TABLE secrets (
297
+ id INTEGER PRIMARY KEY,
298
+ name TEXT NOT NULL,
299
+ kind TEXT NOT NULL CHECK (kind IN ('password','ssh_private_key','ssh_public_key','yaml','generic')),
300
+ value_cipher BLOB NOT NULL,
301
+ value_nonce BLOB NOT NULL,
302
+ value_tag BLOB NOT NULL,
303
+ key_version INTEGER NOT NULL,
304
+ fingerprint TEXT NOT NULL,
305
+ mask TEXT NOT NULL,
306
+ meta TEXT NOT NULL DEFAULT '{}',
307
+ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','deleted')),
308
+ created_at TEXT NOT NULL,
309
+ updated_at TEXT,
310
+ last_used_at TEXT,
311
+ use_count INTEGER NOT NULL DEFAULT 0
312
+ );
313
+ INSERT INTO secrets (id, name, kind, value_cipher, value_nonce, value_tag, key_version, fingerprint, mask, meta, status, created_at, updated_at, last_used_at, use_count)
314
+ SELECT id, name, CASE WHEN kind IN ('api_key','token') THEN 'password' ELSE kind END, value_cipher, value_nonce, value_tag, key_version, fingerprint, mask, meta, status, created_at, updated_at, last_used_at, use_count
315
+ FROM secrets_v7;
316
+ CREATE UNIQUE INDEX secrets_name_active ON secrets(name) WHERE status = 'active';
317
+ CREATE INDEX secrets_status ON secrets(status);
318
+ CREATE INDEX secrets_key_version ON secrets(key_version);
319
+ ALTER TABLE secret_audit RENAME TO secret_audit_v7;
320
+ DROP INDEX secret_audit_secret;
321
+ CREATE TABLE secret_audit (
322
+ id INTEGER PRIMARY KEY,
323
+ secret_id INTEGER NOT NULL REFERENCES secrets(id) ON DELETE CASCADE,
324
+ action TEXT NOT NULL CHECK (action IN ('put','rotated','deleted','restored','used','revealed','reencrypt')),
325
+ actor TEXT NOT NULL,
326
+ pre_image TEXT,
327
+ reason TEXT,
328
+ created_at TEXT NOT NULL
329
+ );
330
+ INSERT INTO secret_audit SELECT * FROM secret_audit_v7;
331
+ CREATE INDEX secret_audit_secret ON secret_audit(secret_id);
332
+ DROP TABLE secret_audit_v7;
333
+ DROP TABLE secrets_v7;
334
+ `,
335
+ },
336
+ {
337
+ // The wiki (docs/memory.md Phase 6): a render-only projection of the `active` rows, so it is a table of
338
+ // derived text and not a second copy of the facts. Nothing reads it back as a memory — recall keeps
339
+ // reading `memories` — which is why it can be dropped and rebuilt from scratch at any time (wiki.ts).
340
+ // `claim_ids` is the provenance spine: the rows that back a page or a section, written by code, never
341
+ // chosen by a model. `model` is null while the prose is the template's rather than a model's.
342
+ version: 9,
343
+ sql: `
344
+ CREATE TABLE wiki_pages (
345
+ slug TEXT PRIMARY KEY,
346
+ title TEXT NOT NULL,
347
+ topic_key TEXT NOT NULL,
348
+ body TEXT NOT NULL,
349
+ claim_ids TEXT NOT NULL DEFAULT '[]',
350
+ model TEXT,
351
+ generated_at TEXT NOT NULL
352
+ );
353
+
354
+ CREATE TABLE wiki_sections (
355
+ id INTEGER PRIMARY KEY,
356
+ slug TEXT NOT NULL REFERENCES wiki_pages(slug) ON DELETE CASCADE,
357
+ heading TEXT NOT NULL,
358
+ body TEXT NOT NULL,
359
+ claim_ids TEXT NOT NULL DEFAULT '[]',
360
+ generated_at TEXT NOT NULL
361
+ );
362
+ CREATE INDEX wiki_sections_slug ON wiki_sections(slug);
363
+ `,
364
+ },
365
+ ];
366
+
367
+ /**
368
+ * Open (creating if needed) the memory database and bring it up to date. Migrations run in
369
+ * version order inside a transaction each, and a file newer than this build is an error rather
370
+ * than a silent downgrade.
371
+ */
372
+ export function openMemoryDb(file: string): DatabaseSync {
373
+ const db = openFlypiDb(file);
374
+ migrate(db, MIGRATIONS);
375
+ return db;
376
+ }
377
+
378
+ /**
379
+ * The same file without owning it: read-only, so a second process (`flypi secret print`, risk 15) can answer
380
+ * from it while the daemon holds the write lock — WAL readers do not wait for the writer, and `busy_timeout`
381
+ * covers the rest. No migrations run here, because a read-only connection cannot run them, so a file older
382
+ * than this build says so instead of reporting a table that is not there.
383
+ */
384
+ export function openMemoryDbReadOnly(file: string): DatabaseSync {
385
+ const db = new DatabaseSync(file, { readOnly: true });
386
+ db.exec("PRAGMA busy_timeout = 5000");
387
+ const version = schemaVersion(db);
388
+ if (version < MIGRATIONS.length) {
389
+ db.close();
390
+ throw new Error(`${file} is at schema v${version}, and this build needs v${MIGRATIONS.length}: start flypi once to migrate it`);
391
+ }
392
+ return db;
393
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Embeddings (docs/memory.md Phase 3, docs/risks.md risk 7/8/11).
3
+ *
4
+ * One model, three inputs: EmbeddingGemma 2 maps text, images and audio into the same 768-dimensional
5
+ * space, which is what lets a typed query reach a voice recording (docs/decisions.md, "Multimodal
6
+ * memory"). The model loads once per process and is reused; `identity` is what a stored vector's
7
+ * `embed_model`/`embed_quantized` columns must match for the two to be comparable at all. A missing or
8
+ * under-downloaded model is not an error: it returns the FTS-only embedder *with the reason kept*, so
9
+ * recall can say it degraded instead of pretending it did not (risk 8).
10
+ *
11
+ * Nothing here truncates. The tokenizer's `model_max_length` is effectively unlimited, so an input
12
+ * longer than the model's 8,192-token window is counted and refused — a silently half-read memory is
13
+ * the failure this replaced (a 512-token model used to index the first third of a memory and report
14
+ * success).
15
+ */
16
+ import { randomUUID } from "node:crypto";
17
+ import { rmSync, writeFileSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ import type { EmbeddingsConfig } from "./config.ts";
21
+ import type { ModelProgress } from "./progress.ts";
22
+ import { decodeWav, type MemoryMedia } from "./media.ts";
23
+
24
+ export interface EmbedIdentity {
25
+ model: string;
26
+ quantized: boolean;
27
+ }
28
+
29
+ /** A vector, or the reason there isn't one — never a silent "close enough". */
30
+ export type EmbedResult = { ok: true; vector: Float32Array } | { ok: false; error: string };
31
+
32
+ export interface Embedder {
33
+ /** `null` means no vectors: search is FTS-only and must report it. */
34
+ identity: EmbedIdentity | null;
35
+ /** Why there are no vectors, when there are none — shown to the caller, never swallowed. */
36
+ error?: string;
37
+ /** Asymmetric retrieval: EG2 was trained with a query prefix and a document prefix. */
38
+ embedQuery(text: string): Promise<EmbedResult>;
39
+ embedDocument(text: string): Promise<EmbedResult>;
40
+ embedMedia(media: MemoryMedia): Promise<EmbedResult>;
41
+ }
42
+
43
+ /** EmbeddingGemma 2's context, shared by every modality (model card: 8,192 tokens). */
44
+ export const EMBED_WINDOW_TOKENS = 8192;
45
+
46
+ /** The window check, apart from the model so it can be tested without downloading one. */
47
+ export function overWindow(tokens: number, window = EMBED_WINDOW_TOKENS): string | undefined {
48
+ if (tokens <= window) return undefined;
49
+ return `input is ${tokens} tokens, over the ${window}-token window — split it into two memories, or shorten it`;
50
+ }
51
+
52
+ export function noneEmbedder(error?: string): Embedder {
53
+ const unavailable = async () => ({ ok: false as const, error: "embeddings are not configured" });
54
+ return {
55
+ identity: null,
56
+ ...(error ? { error } : {}),
57
+ embedQuery: unavailable,
58
+ embedDocument: unavailable,
59
+ embedMedia: unavailable,
60
+ };
61
+ }
62
+
63
+ export async function createEmbedder(config: EmbeddingsConfig, onProgress?: (progress: ModelProgress) => void): Promise<Embedder> {
64
+ if (config.provider === "none") return noneEmbedder();
65
+ try {
66
+ return await transformersEmbedder(config, onProgress);
67
+ } catch (error) {
68
+ return noneEmbedder(`embeddings unavailable: ${(error as Error).message}`);
69
+ }
70
+ }
71
+
72
+ /**
73
+ * The configured model's **identity** without loading the model. A second process that reads the memory
74
+ * database (`npm run serve`, the dev server) has to know which vector space the stored rows are in —
75
+ * otherwise it reports every vector as "from another model" — and it must not produce one: every embed
76
+ * refuses, which is also what stops a read-only process quietly adding a vector it cannot reproduce.
77
+ * No download, no ~100 MB runtime.
78
+ */
79
+ export async function configuredIdentity(config: EmbeddingsConfig): Promise<Embedder> {
80
+ if (config.provider === "none") return noneEmbedder();
81
+ const unavailable = async () => ({ ok: false as const, error: "this process does not load the embedding model — `flypi start` is the one that writes vectors" });
82
+ return {
83
+ identity: { model: config.model, quantized: config.quantized },
84
+ embedQuery: unavailable,
85
+ embedDocument: unavailable,
86
+ embedMedia: unavailable,
87
+ };
88
+ }
89
+
90
+ const queryInstruction = (text: string) => `task: search result | query: ${text}`;
91
+ const documentInstruction = (text: string) => `title: none | text: ${text}`;
92
+
93
+ async function transformersEmbedder(config: EmbeddingsConfig, onProgress?: (progress: ModelProgress) => void): Promise<Embedder> {
94
+ // Imported lazily: it is ~100 MB of runtime and only needed when vectors were asked for.
95
+ const { AutoModel, AutoProcessor, RawImage } = await import("@huggingface/transformers");
96
+ const processor = await AutoProcessor.from_pretrained(config.model, { cache_dir: config.cacheDir, ...(onProgress ? { progress_callback: onProgress } : {}) });
97
+ // v4 picks the weights by dtype per component. Never fp16: this model's activation range exceeds
98
+ // float16's and returns NaN or quietly degraded vectors.
99
+ const dtype = config.quantized ? "q8" : "fp32";
100
+ const model = await AutoModel.from_pretrained(config.model, {
101
+ cache_dir: config.cacheDir,
102
+ dtype: { model: dtype, vision_encoder: dtype, audio_encoder: dtype },
103
+ ...(onProgress ? { progress_callback: onProgress } : {}),
104
+ });
105
+ const identity = { model: config.model, quantized: config.quantized };
106
+
107
+ const encode = async (...inputs: Parameters<typeof processor>): Promise<EmbedResult> => {
108
+ const prepared = await processor(...inputs);
109
+ const tooLong = overWindow(prepared.input_ids?.dims?.[1] ?? 0);
110
+ if (tooLong) return { ok: false, error: tooLong };
111
+ const output = await model(prepared);
112
+ const [vector] = (output.sentence_embedding ?? output.last_hidden_state).tolist() as number[][];
113
+ if (!vector) return { ok: false, error: "the model returned no embedding" };
114
+ return { ok: true, vector: Float32Array.from(vector) };
115
+ };
116
+
117
+ return {
118
+ identity,
119
+ embedQuery: (text) => encode(queryInstruction(text)),
120
+ embedDocument: (text) => encode(documentInstruction(text)),
121
+ // Prefixes apply to text only (model card), so media goes in bare. Audio is decoded from the bytes
122
+ // we hold. An image has to pass through a file — RawImage takes a path or a URL, and Node has no
123
+ // createImageBitmap — so it goes through a temp file holding those same bytes: the stored bytes stay
124
+ // authoritative and `media.path` stays provenance, which is what lets an old media row re-embed after
125
+ // its original file moved.
126
+ embedMedia: (media) =>
127
+ media.kind === "image" ? withImageFile(media, (image) => encode(null, image)) : encode(null, null, decodeWav(media.bytes).samples),
128
+ };
129
+
130
+ async function withImageFile<T>(media: MemoryMedia, then: (image: InstanceType<typeof RawImage>) => Promise<T>): Promise<T> {
131
+ const file = join(tmpdir(), `flypi-memory-${randomUUID()}.${media.mime.split("/")[1] ?? "img"}`);
132
+ writeFileSync(file, media.bytes);
133
+ try {
134
+ return await then(await RawImage.read(file));
135
+ } finally {
136
+ rmSync(file, { force: true });
137
+ }
138
+ }
139
+ }
140
+
141
+ /** Cosine, computed from the norms rather than trusting the writer to have normalised. */
142
+ export function cosine(a: Float32Array, b: Float32Array): number {
143
+ if (a.length !== b.length) return 0;
144
+ let dot = 0;
145
+ let normA = 0;
146
+ let normB = 0;
147
+ for (let i = 0; i < a.length; i += 1) {
148
+ dot += a[i]! * b[i]!;
149
+ normA += a[i]! * a[i]!;
150
+ normB += b[i]! * b[i]!;
151
+ }
152
+ if (normA === 0 || normB === 0) return 0;
153
+ return dot / (Math.sqrt(normA) * Math.sqrt(normB));
154
+ }