@makerstufferupper/flypi 0.0.0-stage → 0.1.2

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 +44 -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 +303 -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 +35 -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,396 @@
1
+ /**
2
+ * The secrets vault (docs/secrets.md Phase 5.5).
3
+ *
4
+ * Same shape as `MemoryStore`, same rules: nothing here ever hands back a plaintext unless the call asks for
5
+ * one by name (`value`, `activeValues`), every read path returns the mask, deleting is a tombstone, and every
6
+ * change and use leaves an audit row with the pre-image it replaced. The ciphertext lives in the memory
7
+ * database, so there is one store, one writer and one backup.
8
+ *
9
+ * The values are decrypted in this process and never serialised on the way out: a list, a debug frame or a
10
+ * memory row can only carry what this file chooses to put in it.
11
+ */
12
+ import type { DatabaseSync } from "node:sqlite";
13
+ import type { FlypiPaths } from "../agent/paths.ts";
14
+ import { openMemoryDb, openMemoryDbReadOnly } from "../memory/db.ts";
15
+ import { keyProviderFor, secretsConfig, type SecretsConfig } from "./config.ts";
16
+ import { fingerprint, mask, open as unseal, seal, validate, type KeyProvider, type SecretKind } from "./crypto.ts";
17
+ import { SECRET_NAME } from "./config.ts";
18
+ import { closeSecretSource, invalidateSecrets, setSecretSource } from "./redact.ts";
19
+
20
+ export type { SecretKind } from "./crypto.ts";
21
+ export { SECRET_KINDS } from "./crypto.ts";
22
+
23
+ export interface SecretRow {
24
+ id: number;
25
+ name: string;
26
+ kind: SecretKind;
27
+ /** What a list or the UI shows. Never the value. */
28
+ mask: string;
29
+ meta: Record<string, string>;
30
+ status: "active" | "deleted";
31
+ created_at: string;
32
+ updated_at: string | null;
33
+ last_used_at: string | null;
34
+ use_count: number;
35
+ key_version: number;
36
+ fingerprint: string;
37
+ }
38
+
39
+ export interface SecretUse {
40
+ action: string;
41
+ actor: string;
42
+ reason: string | null;
43
+ created_at: string;
44
+ }
45
+
46
+ export interface SecretInput {
47
+ name: string;
48
+ value: string;
49
+ kind?: SecretKind;
50
+ meta?: Record<string, string>;
51
+ actor: string;
52
+ reason?: string;
53
+ }
54
+
55
+ export const REENCRYPT_JOB = "secrets.reencrypt";
56
+
57
+ export class SecretStore {
58
+ /**
59
+ * `readOnly` is not a hint: it is the state of the handle. A second process (`flypi secret print` from a
60
+ * config file, `npm run serve`) reads the vault through a read-only connection so it never competes with
61
+ * the writer, and that means its reads cannot record a use. Those reads still answer — the alternative is
62
+ * that a config file cannot resolve its own API key while the daemon is running — they just leave no
63
+ * audit row, which is why the flag is explicit rather than something each write discovers by failing.
64
+ */
65
+ constructor(
66
+ readonly db: DatabaseSync,
67
+ readonly keys: KeyProvider,
68
+ readonly readOnly: boolean = false,
69
+ ) {}
70
+
71
+ /** A change cannot be made here. Said plainly, rather than as SQLite's "attempt to write a readonly database". */
72
+ private writable(): void {
73
+ if (this.readOnly) throw new Error("this vault is open read-only — a change has to go through the process that owns the database, such as `flypi secret …` or the web UI");
74
+ }
75
+
76
+ /**
77
+ * This process just changed the set of secrets, so the redaction boundary must ask again rather than answer
78
+ * from what it read a moment ago. Redaction itself is a pull (src/secrets/redact.ts): a process that only
79
+ * reads the vault is covered by the cache's short lifetime, and one that writes invalidates it here.
80
+ */
81
+ private changed(): void {
82
+ invalidateSecrets();
83
+ }
84
+
85
+ /** Active rows, optionally with the tombstones. No plaintext in any of them. */
86
+ list(query = "", includeDeleted = false): SecretRow[] {
87
+ return this.rows(query, includeDeleted);
88
+ }
89
+
90
+ row(name: string): SecretRow | undefined {
91
+ const found = this.db.prepare("SELECT id, name, kind, mask, meta, status, created_at, updated_at, last_used_at, use_count, key_version, fingerprint FROM secrets WHERE name = ?").get(name);
92
+ return found === undefined ? undefined : decodeRow(found as unknown as StoredRow);
93
+ }
94
+
95
+ /** The one call that returns a plaintext, and the reason it is in the audit trail. */
96
+ value(name: string, { actor, action = "used" }: { actor: string; action?: "used" | "revealed" }): string | undefined {
97
+ const row = this.stored(name);
98
+ if (!row) return undefined;
99
+ const key = this.keys.key(row.key_version);
100
+ if (key === undefined) {
101
+ // Loud, not a wrong plaintext: the row was sealed under a key this provider no longer holds.
102
+ throw new Error(`the vault has no key v${row.key_version} for "${name}" — rotate it to a new value, or restore that key`);
103
+ }
104
+ const plaintext = unseal({ valueCipher: row.value_cipher, valueNonce: row.value_nonce, valueTag: row.value_tag }, key);
105
+ if (this.readOnly) return plaintext;
106
+ this.db
107
+ .prepare("UPDATE secrets SET last_used_at = ?, use_count = use_count + 1 WHERE id = ?")
108
+ .run(now(), row.id);
109
+ // A use is not a change, so there is no pre-image: the audit row names the row and nothing more.
110
+ this.audit({ id: row.id, name: row.name }, action, actor, undefined, undefined);
111
+ return plaintext;
112
+ }
113
+
114
+ /**
115
+ * Store a value. The fingerprint is checked first, so the same credential written twice comes back as
116
+ * `already stored as "name"` instead of a second row or a second name — the case where a user pastes the
117
+ * same API key under two handles and later cannot tell which is which.
118
+ */
119
+ put(input: SecretInput): { status: "stored"; id: number } | { status: "exists"; name: string; id: number } | { status: "name taken"; name: string; id: number } {
120
+ this.writable();
121
+ if (!SECRET_NAME.test(input.name)) throw new Error(`"${input.name}" is not a usable secret name: letters, digits, dot, dash and underscore`);
122
+ const kind = input.kind ?? "generic";
123
+ // The value is checked against the kind before anything is written, so a CLI, a tool call and the web UI
124
+ // cannot each decide what a `yaml` secret means (src/secrets/crypto.ts `validate`).
125
+ const problem = validate(input.value, kind);
126
+ if (problem) throw new Error(`"${input.name}" ${problem}`);
127
+ const print = fingerprint(input.value);
128
+ const same = this.db.prepare("SELECT name, id FROM secrets WHERE status = 'active' AND fingerprint = ?").get(print) as { name: string; id: number } | undefined;
129
+ if (same) return { status: "exists", ...same };
130
+ const taken = this.db.prepare("SELECT id FROM secrets WHERE status = 'active' AND name = ?").get(input.name) as { id: number } | undefined;
131
+ if (taken) return { status: "name taken", name: input.name, id: taken.id };
132
+
133
+ const version = this.keys.currentVersion();
134
+ const key = this.keys.key(version);
135
+ if (key === undefined) throw new Error(`the ${this.keys.name} key provider reports v${version} as current but does not hold it`);
136
+ const sealed = seal(input.value, key);
137
+ const result = this.db
138
+ .prepare(
139
+ `INSERT INTO secrets (name, kind, value_cipher, value_nonce, value_tag, key_version, fingerprint, mask, meta, status, created_at)
140
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?)`,
141
+ )
142
+ .run(
143
+ input.name,
144
+ kind,
145
+ Buffer.from(sealed.valueCipher),
146
+ Buffer.from(sealed.valueNonce),
147
+ Buffer.from(sealed.valueTag),
148
+ version,
149
+ print,
150
+ mask(input.value, kind),
151
+ JSON.stringify(input.meta ?? {}),
152
+ now(),
153
+ );
154
+ this.audit({ id: Number(result.lastInsertRowid), name: input.name, key_version: version }, "put", input.actor, undefined, input.reason);
155
+ this.changed();
156
+ return { status: "stored", id: Number(result.lastInsertRowid) };
157
+ }
158
+
159
+ /** A new value for an existing name. The old row is what the audit row keeps (masked, never plaintext). */
160
+ rotate(input: SecretInput): { status: "rotated"; id: number } | { status: "not found"; name: string } {
161
+ this.writable();
162
+ const row = this.stored(input.name);
163
+ if (!row) return { status: "not found", name: input.name };
164
+ const kind = (input.kind ?? row.kind) as SecretKind;
165
+ const problem = validate(input.value, kind);
166
+ if (problem) throw new Error(`"${input.name}" ${problem}`);
167
+ const version = this.keys.currentVersion();
168
+ const key = this.keys.key(version);
169
+ if (key === undefined) throw new Error(`the ${this.keys.name} key provider reports v${version} as current but does not hold it`);
170
+ const sealed = seal(input.value, key);
171
+ this.db
172
+ .prepare(
173
+ "UPDATE secrets SET kind = ?, value_cipher = ?, value_nonce = ?, value_tag = ?, key_version = ?, fingerprint = ?, mask = ?, meta = ?, updated_at = ? WHERE id = ?",
174
+ )
175
+ .run(
176
+ kind,
177
+ Buffer.from(sealed.valueCipher),
178
+ Buffer.from(sealed.valueNonce),
179
+ Buffer.from(sealed.valueTag),
180
+ version,
181
+ fingerprint(input.value),
182
+ mask(input.value, kind),
183
+ JSON.stringify(input.meta ?? JSON.parse(String(row.meta))),
184
+ now(),
185
+ row.id,
186
+ );
187
+ this.audit(row, "rotated", input.actor, preImage(row), input.reason);
188
+ this.changed();
189
+ return { status: "rotated", id: row.id };
190
+ }
191
+
192
+ /** A tombstone, never an erasure: history is not the vault's to delete (same rule as memories). */
193
+ remove(name: string, { actor, reason }: { actor: string; reason?: string }): { status: "deleted"; id: number } | { status: "not found"; name: string } {
194
+ this.writable();
195
+ const row = this.stored(name);
196
+ if (!row) return { status: "not found", name };
197
+ this.db.prepare("UPDATE secrets SET status = 'deleted', updated_at = ? WHERE id = ?").run(now(), row.id);
198
+ this.audit(row, "deleted", actor, preImage(row), reason);
199
+ this.changed();
200
+ return { status: "deleted", id: row.id };
201
+ }
202
+
203
+ /** Undo, which a tombstone makes possible without ever having held the plaintext client-side. */
204
+ restore(name: string, { actor, reason }: { actor: string; reason?: string }): { status: "restored"; id: number } | { status: "not found"; name: string } {
205
+ this.writable();
206
+ const row = this.db.prepare("SELECT * FROM secrets WHERE name = ? AND status = 'deleted'").get(name) as unknown as StoredRow | undefined;
207
+ if (!row) return { status: "not found", name };
208
+ this.db.prepare("UPDATE secrets SET status = 'active', updated_at = ? WHERE id = ?").run(now(), row.id);
209
+ this.audit(row, "restored", actor, preImage(row), reason);
210
+ this.changed();
211
+ return { status: "restored", id: row.id };
212
+ }
213
+
214
+ uses(name: string, limit = 20): SecretUse[] {
215
+ const row = this.row(name);
216
+ if (!row) return [];
217
+ return this.db
218
+ .prepare("SELECT action, actor, reason, created_at FROM secret_audit WHERE secret_id = ? ORDER BY id DESC LIMIT ?")
219
+ .all(row.id, limit) as unknown as SecretUse[];
220
+ }
221
+
222
+ /**
223
+ * Key drift, reported the way the embedding drift is: the vault answers, the status panel shows it, a job
224
+ * does the work. Nothing rotates a key on a timer — a credential outliving its usefulness is a human
225
+ * judgement (risk 9).
226
+ */
227
+ keyDrift(): { keyProvider: string; current: number; rows: number; stale: number; reencryptQueued: boolean } {
228
+ const current = this.keys.currentVersion();
229
+ const rows = (this.db.prepare("SELECT count(*) AS n FROM secrets WHERE status = 'active'").get() as { n: number }).n;
230
+ const stale = (this.db.prepare("SELECT count(*) AS n FROM secrets WHERE status = 'active' AND key_version <> ?").get(current) as { n: number }).n;
231
+ const queued = this.db.prepare("SELECT id FROM jobs WHERE kind = ? AND locked_by IS NULL").get(REENCRYPT_JOB) !== undefined;
232
+ return { keyProvider: this.keys.name, current, rows, stale, reencryptQueued: queued };
233
+ }
234
+
235
+ /** Ask for a new key. The rows are re-sealed by the job, not here: on a large vault that is background work. */
236
+ rotateKey(actor: string, reason?: string): { version: number; reencryptQueued: number[] } {
237
+ this.writable();
238
+ const version = this.keys.rotate();
239
+ return { version, reencryptQueued: this.queueReencrypt(reason ?? `key rotated to v${version} by ${actor}`) };
240
+ }
241
+
242
+ queueReencrypt(reason: string): number[] {
243
+ this.writable();
244
+ const at = now();
245
+ const existing = this.db.prepare("SELECT id FROM jobs WHERE kind = ? AND locked_by IS NULL").all(REENCRYPT_JOB) as { id: number }[];
246
+ if (existing.length > 0) {
247
+ this.db.prepare("UPDATE jobs SET next_run_at = ?, payload = ? WHERE kind = ? AND locked_by IS NULL").run(at, JSON.stringify({ reason }), REENCRYPT_JOB);
248
+ return existing.map((row) => row.id);
249
+ }
250
+ const result = this.db.prepare("INSERT INTO jobs (kind, next_run_at, payload) VALUES (?, ?, ?)").run(REENCRYPT_JOB, at, JSON.stringify({ reason }));
251
+ return [Number(result.lastInsertRowid)];
252
+ }
253
+
254
+ /**
255
+ * Re-seal every row under the current key, then let the provider forget the ones no row needs. Each row
256
+ * keeps an audit row with its pre-image, which is what "rotate, then re-encrypt, then forget the old key"
257
+ * means when someone later asks what a row used to be.
258
+ */
259
+ reencrypt(): { rows: number; keyVersion: number } {
260
+ this.writable();
261
+ const version = this.keys.currentVersion();
262
+ const stale = this.db.prepare("SELECT * FROM secrets WHERE status = 'active' AND key_version <> ?").all(version) as unknown as StoredRow[];
263
+ const key = this.keys.key(version);
264
+ if (key === undefined) throw new Error(`the ${this.keys.name} key provider reports v${version} as current but does not hold it`);
265
+ for (const row of stale) {
266
+ // Read with the old key first: a row that cannot be opened must stop the pass, not be skipped, or a
267
+ // vault would quietly lose a secret while looking healthy.
268
+ const old = this.keys.key(row.key_version);
269
+ if (old === undefined) throw new Error(`the vault has no key v${row.key_version} for "${row.name}" — restore that key before re-encrypting`);
270
+ const plaintext = unseal({ valueCipher: row.value_cipher, valueNonce: row.value_nonce, valueTag: row.value_tag }, old);
271
+ const sealed = seal(plaintext, key);
272
+ this.db
273
+ .prepare("UPDATE secrets SET value_cipher = ?, value_nonce = ?, value_tag = ?, key_version = ? WHERE id = ?")
274
+ .run(Buffer.from(sealed.valueCipher), Buffer.from(sealed.valueNonce), Buffer.from(sealed.valueTag), version, row.id);
275
+ this.audit(row, "reencrypt", "job", preImage(row), `re-sealed under v${version}`);
276
+ }
277
+ this.keys.retain([version]);
278
+ return { rows: stale.length, keyVersion: version };
279
+ }
280
+
281
+ /**
282
+ * This process is done with the vault. Redaction stops reading it, rather than being left pointing at a
283
+ * closed database — one vault per process, so dropping the source is the whole of it.
284
+ */
285
+ close(): void {
286
+ closeSecretSource();
287
+ }
288
+
289
+ /** Every active name with its plaintext — the redaction boundary's input, and nothing else uses it. */
290
+ activeValues(): { name: string; value: string }[] {
291
+ const out: { name: string; value: string }[] = [];
292
+ for (const row of this.db.prepare("SELECT * FROM secrets WHERE status = 'active'").all() as unknown as StoredRow[]) {
293
+ const key = this.keys.key(row.key_version);
294
+ if (key === undefined) continue;
295
+ try {
296
+ out.push({ name: row.name, value: unseal({ valueCipher: row.value_cipher, valueNonce: row.value_nonce, valueTag: row.value_tag }, key) });
297
+ } catch {
298
+ // A row this process cannot open is reported by `value()` when someone asks for it; the other
299
+ // secrets still get redacted.
300
+ }
301
+ }
302
+ return out;
303
+ }
304
+
305
+ private rows(query = "", includeDeleted = false): SecretRow[] {
306
+ return (this.db
307
+ .prepare(
308
+ `SELECT id, name, kind, mask, meta, status, created_at, updated_at, last_used_at, use_count, key_version, fingerprint
309
+ FROM secrets WHERE (? = '' OR name LIKE ? OR meta LIKE ?) AND (? = 1 OR status <> 'deleted') ORDER BY name`,
310
+ )
311
+ .all(query, `%${query}%`, `%${query}%`, includeDeleted ? 1 : 0) as unknown as StoredRow[]).map(decodeRow);
312
+ }
313
+
314
+ private stored(name: string): StoredRow | undefined {
315
+ return this.db.prepare("SELECT * FROM secrets WHERE name = ? AND status = 'active'").get(name) as unknown as StoredRow | undefined;
316
+ }
317
+
318
+ private audit(row: { id: number; name: string; key_version?: number }, action: string, actor: string, preImage: unknown, reason?: string): void {
319
+ this.db
320
+ .prepare("INSERT INTO secret_audit (secret_id, action, actor, pre_image, reason, created_at) VALUES (?, ?, ?, ?, ?, ?)")
321
+ .run(row.id, action, actor, JSON.stringify(preImage ?? { name: row.name, key_version: row.key_version ?? null }), reason ?? null, now());
322
+ }
323
+ }
324
+
325
+ interface StoredRow {
326
+ id: number;
327
+ name: string;
328
+ kind: string;
329
+ value_cipher: Uint8Array;
330
+ value_nonce: Uint8Array;
331
+ value_tag: Uint8Array;
332
+ key_version: number;
333
+ fingerprint: string;
334
+ mask: string;
335
+ meta: string;
336
+ status: "active" | "deleted";
337
+ created_at: string;
338
+ updated_at: string | null;
339
+ last_used_at: string | null;
340
+ use_count: number;
341
+ }
342
+
343
+ function decodeRow(row: StoredRow): SecretRow {
344
+ let meta: Record<string, string> = {};
345
+ try {
346
+ meta = JSON.parse(String(row.meta)) as Record<string, string>;
347
+ } catch {
348
+ // A meta column that is not JSON is a broken annotation, not a broken secret: the row still lists.
349
+ }
350
+ return {
351
+ id: row.id,
352
+ name: row.name,
353
+ kind: row.kind as SecretKind,
354
+ mask: row.mask,
355
+ meta,
356
+ status: row.status,
357
+ created_at: row.created_at,
358
+ updated_at: row.updated_at,
359
+ last_used_at: row.last_used_at,
360
+ use_count: row.use_count,
361
+ key_version: row.key_version,
362
+ fingerprint: row.fingerprint,
363
+ };
364
+ }
365
+
366
+ /** What an audit row keeps about the row it replaced — identity and key version, never the value. */
367
+ function preImage(row: StoredRow): Record<string, unknown> {
368
+ return { name: row.name, kind: row.kind, mask: row.mask, fingerprint: row.fingerprint, key_version: row.key_version };
369
+ }
370
+
371
+ const now = () => new Date().toISOString();
372
+
373
+ /**
374
+ * Open the vault for this process (docs/secrets.md Phase 5.5). This is the one call that connects the vault
375
+ * to redaction: whatever process opens a vault also becomes a process that redacts, because that is the only
376
+ * way a secret stored by one process is protected by another. A process that already holds the memory
377
+ * database passes its own handle — the daemon does, and a second connection to the same file in one process
378
+ * buys nothing.
379
+ *
380
+ * `write: false` (the default) opens the file read-only, which is right for a process that does not own it:
381
+ * `npm run serve` without a daemon, or a `flypi secret list` run while the daemon is up. Such a process can
382
+ * read and redact; a mutation it attempts fails with SQLite's own "attempt to write a readonly database",
383
+ * which names the real problem — that this process is not the vault's owner.
384
+ */
385
+ export function openSecretVault(
386
+ paths: FlypiPaths,
387
+ options: { db?: DatabaseSync; write?: boolean; config?: SecretsConfig } = {},
388
+ ): SecretStore {
389
+ const config = options.config ?? secretsConfig(paths);
390
+ const db = options.db ?? (options.write === true ? openMemoryDb(paths.memoryDbPath) : openMemoryDbReadOnly(paths.memoryDbPath));
391
+ // A process that does not own the writer opens the vault read-only, so it can answer `print NAME` while the
392
+ // daemon is running, and it is told which of the two it has (see SecretStore.readOnly).
393
+ const vault = new SecretStore(db, keyProviderFor(config), options.write !== true);
394
+ setSecretSource(() => vault.activeValues());
395
+ return vault;
396
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Per-conversation event log (docs/architecture.md Phase 1: "per-session ring buffer of
3
+ * recent events + `resume` replay on reconnect").
4
+ *
5
+ * `seq` is assigned here and nowhere else, so replayed frames and live frames share one
6
+ * numbering and the client can dedupe on it. When the ring overwrites old events the gap is
7
+ * reported rather than hidden: a client that asks for something evicted is told, not fed a
8
+ * transcript that quietly starts later.
9
+ */
10
+ export interface BufferedEvent<T> {
11
+ seq: number;
12
+ event: T;
13
+ }
14
+
15
+ export class EventLog<T> {
16
+ private readonly events: BufferedEvent<T>[] = [];
17
+ private nextSeq = 1;
18
+
19
+ constructor(private readonly capacity: number = 5000) {}
20
+
21
+ push(event: T): BufferedEvent<T> {
22
+ const buffered: BufferedEvent<T> = { seq: this.nextSeq++, event };
23
+ this.events.push(buffered);
24
+ if (this.events.length > this.capacity) this.events.shift();
25
+ return buffered;
26
+ }
27
+
28
+ get lastSeq(): number {
29
+ return this.nextSeq - 1;
30
+ }
31
+
32
+ /** Start over. Opening another session replaces the transcript a client is looking at. */
33
+ reset(): void {
34
+ this.events.length = 0;
35
+ this.nextSeq = 1;
36
+ }
37
+
38
+ /** Highest seq already dropped from the ring (0 when nothing has been dropped). */
39
+ get evictedThroughSeq(): number {
40
+ return this.events[0] ? this.events[0].seq - 1 : this.lastSeq;
41
+ }
42
+
43
+ /** Every event after `afterSeq`, plus whether older events were already evicted. */
44
+ replayAfter(afterSeq: number): { events: BufferedEvent<T>[]; gap: boolean } {
45
+ const first = this.events[0];
46
+ const gap = afterSeq + 1 < (first?.seq ?? this.nextSeq);
47
+ return { events: this.events.filter((entry) => entry.seq > afterSeq), gap };
48
+ }
49
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The bridge between an extension's `ctx.ui` and the browser (docs/protocol.md `extensionUi`).
3
+ *
4
+ * pi hands every extension a UI context, and a headless session gets a no-op one — so an extension
5
+ * command that answers with `ctx.ui.notify(...)` or asks with `ctx.ui.select(...)` would say nothing at
6
+ * all in flypi's chat. One bridge per conversation: the four calls a browser can serve become frames,
7
+ * and a question waits for the page's `extensionReply`.
8
+ *
9
+ * Everything else on that interface is terminal-only — widgets, footers, themes, raw input, the custom
10
+ * component escape hatch. They stay no-ops, so an extension that calls one gets "nothing happened"
11
+ * instead of an exception in the middle of its handler.
12
+ */
13
+ import { randomUUID } from "node:crypto";
14
+ import type { ExtensionUIContext, ExtensionUIDialogOptions } from "@earendil-works/pi-coding-agent";
15
+ import type { ExtensionUiFrame } from "./protocol.ts";
16
+
17
+ /** The frame body; the WS layer puts `type` and the conversation's id on it. */
18
+ export type ExtensionUiPush = (frame: Omit<ExtensionUiFrame, "type" | "conversationId">) => void;
19
+
20
+ export interface ExtensionUiBridge {
21
+ /** Pass to `bindExtensions` for this conversation. */
22
+ uiContext: ExtensionUIContext;
23
+ /** The page answered. False when nothing waits on that id — already answered, timed out, or gone. */
24
+ reply(dialogId: string, value: string | null): boolean;
25
+ /** The conversation is going away: release every handler still waiting rather than hang on a closed tab. */
26
+ close(): void;
27
+ }
28
+
29
+ /** Nobody answers a dialog for this long, so the extension is told it was cancelled, as a dismissed TUI dialog would be. */
30
+ const DIALOG_TIMEOUT_MS = 5 * 60_000;
31
+
32
+ export function createExtensionUiBridge(push: ExtensionUiPush): ExtensionUiBridge {
33
+ const waiting = new Map<string, (value: string | null) => void>();
34
+
35
+ /** One question, one id, one answer: null means cancelled (dismissed, aborted, or nobody answered). */
36
+ const ask = (
37
+ kind: "select" | "confirm" | "input",
38
+ title: string,
39
+ message: string,
40
+ options: ExtensionUIDialogOptions | undefined,
41
+ choices: string[] | undefined,
42
+ ): Promise<string | null> =>
43
+ new Promise((resolve) => {
44
+ const dialogId = randomUUID();
45
+ const settle = (value: string | null) => {
46
+ waiting.delete(dialogId);
47
+ clearTimeout(timer);
48
+ options?.signal?.removeEventListener("abort", cancel);
49
+ resolve(value);
50
+ };
51
+ const cancel = () => settle(null);
52
+ const timer = setTimeout(settle, DIALOG_TIMEOUT_MS, null);
53
+ timer.unref();
54
+ waiting.set(dialogId, settle);
55
+ // pi passes a signal when it wants the question dismissed — an aborted turn, or its own timeout.
56
+ if (options?.signal) {
57
+ if (options.signal.aborted) return settle(null);
58
+ options.signal.addEventListener("abort", cancel, { once: true });
59
+ }
60
+ push({ kind, title, message, dialogId, ...(choices ? { options: choices } : {}) });
61
+ });
62
+
63
+ const uiContext = {
64
+ ...terminalOnlyUi,
65
+ notify: (message: string, level?: "info" | "warning" | "error") =>
66
+ push({ kind: "notify", message, ...(level ? { level } : {}) }),
67
+ select: async (title: string, options: string[], opts?: ExtensionUIDialogOptions) =>
68
+ (await ask("select", title, "", opts, options)) ?? undefined,
69
+ confirm: async (title: string, message: string, opts?: ExtensionUIDialogOptions) =>
70
+ (await ask("confirm", title, message, opts, undefined)) !== null,
71
+ input: async (title: string, placeholder?: string, opts?: ExtensionUIDialogOptions) =>
72
+ (await ask("input", title, placeholder ?? "", opts, undefined)) ?? undefined,
73
+ } as unknown as ExtensionUIContext; // `theme` is the one member left absent: there is no terminal palette to hand out
74
+
75
+ return {
76
+ uiContext,
77
+ reply: (dialogId, value) => {
78
+ const settle = waiting.get(dialogId);
79
+ if (!settle) return false;
80
+ settle(value);
81
+ return true;
82
+ },
83
+ close: () => {
84
+ for (const settle of waiting.values()) settle(null);
85
+ },
86
+ };
87
+ }
88
+
89
+ /**
90
+ * The terminal half of `ExtensionUIContext`, answered the way pi's own no-op context answers it.
91
+ * `theme` is left out entirely: flypi has no terminal theme to hand out, and an extension only reads it
92
+ * inside a `ctx.mode === "tui"` branch, which this session never enters.
93
+ */
94
+ const terminalOnlyUi = {
95
+ onTerminalInput: () => () => {},
96
+ setStatus: () => {},
97
+ setWorkingMessage: () => {},
98
+ setWorkingVisible: () => {},
99
+ setWorkingIndicator: () => {},
100
+ setHiddenThinkingLabel: () => {},
101
+ setWidget: () => {},
102
+ setFooter: () => {},
103
+ setHeader: () => {},
104
+ setTitle: () => {},
105
+ pasteToEditor: () => {},
106
+ setEditorText: () => {},
107
+ addAutocompleteProvider: () => {},
108
+ setEditorComponent: () => {},
109
+ getEditorText: () => "",
110
+ getEditorComponent: () => undefined,
111
+ getAllThemes: () => [],
112
+ getTheme: () => undefined,
113
+ setTheme: () => ({ success: false, error: "flypi has no theme UI" }),
114
+ getToolsExpanded: () => false,
115
+ setToolsExpanded: () => {},
116
+ custom: async () => undefined,
117
+ editor: async () => undefined,
118
+ } satisfies Partial<Record<keyof ExtensionUIContext, unknown>>;