@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,136 @@
1
+ /**
2
+ * The backend's own database (docs/decisions.md: one datastore per component). It answers exactly one
3
+ * question that a restarted process cannot answer from RAM: was a turn in flight when it went away
4
+ * (docs/architecture.md Phase 4.5).
5
+ *
6
+ * It is a second file, next to the memory store, on purpose. The memory database keeps `jobs`/`job_runs`
7
+ * and `conversations`/`turns` because `memory_audit.job_run_id` and `memories.turn_id` are real foreign
8
+ * keys inside it; a run row belongs to whoever runs pi sessions, so it lives here and names the session
9
+ * file directly rather than joining through the memory store for it. Neither file is opened by the other
10
+ * component's code path.
11
+ */
12
+ import { existsSync } from "node:fs";
13
+ import { DatabaseSync } from "node:sqlite";
14
+ import type { FlypiPaths } from "../agent/paths.ts";
15
+ import { migrate, openFlypiDb, type Migration } from "../shared/sqlite.ts";
16
+
17
+ export const RUN_MIGRATIONS: Migration[] = [
18
+ {
19
+ version: 1,
20
+ sql: `
21
+ CREATE TABLE runs (
22
+ conversation_id TEXT PRIMARY KEY,
23
+ session_file TEXT,
24
+ status TEXT NOT NULL CHECK (status IN ('running','idle','aborted','interrupted')),
25
+ last_event_seq INTEGER NOT NULL DEFAULT 0,
26
+ updated_at TEXT NOT NULL
27
+ );
28
+ CREATE INDEX runs_session ON runs(session_file);
29
+ `,
30
+ },
31
+ ];
32
+
33
+ const now = () => new Date().toISOString();
34
+
35
+ export class RunStore {
36
+ constructor(private readonly db: DatabaseSync) {}
37
+
38
+ /**
39
+ * Which conversation is running a turn right now. `aborted` sticks: a stopped turn settles as `idle` a
40
+ * moment later, and why it stopped is the part worth keeping.
41
+ *
42
+ * shortcut: `last_event_seq` is the conversation's in-memory event counter as it stood at that write. The
43
+ * `running` write holds what the log already had — 0 for a brand-new chat, the seeded history for one that
44
+ * was reopened — and never how far the turn got; the settling write holds the whole turn. A row turned
45
+ * `interrupted` keeps the `running` number, because the process that knew the rest is gone. The counter
46
+ * restarts with the process and when a chat moves to another session file, so this is a record, not a
47
+ * resume cursor: nothing reads it, and a client that ever needs to resume mid-turn needs it advanced per
48
+ * event instead.
49
+ */
50
+ setRunStatus(conversationId: string, status: "running" | "idle" | "aborted", { seq, sessionFile }: { seq: number; sessionFile?: string }): void {
51
+ this.db
52
+ .prepare(
53
+ `INSERT INTO runs (conversation_id, session_file, status, last_event_seq, updated_at) VALUES (?, ?, ?, ?, ?)
54
+ ON CONFLICT(conversation_id) DO UPDATE SET
55
+ session_file = COALESCE(excluded.session_file, runs.session_file),
56
+ status = CASE WHEN runs.status = 'aborted' AND excluded.status = 'idle' THEN 'aborted' ELSE excluded.status END,
57
+ last_event_seq = excluded.last_event_seq,
58
+ updated_at = excluded.updated_at`,
59
+ )
60
+ .run(conversationId, sessionFile ?? null, status, seq, now());
61
+ }
62
+
63
+ /**
64
+ * The start-up reconciliation: every row still `running` belongs to a process that is gone — this process
65
+ * has started no turns yet and is the only writer (risk 3). They become `interrupted`, and the chat says
66
+ * so when it is opened. Nothing here re-runs a half-finished turn: the tools may already have written to
67
+ * the world.
68
+ */
69
+ reconcileInterrupted(): string[] {
70
+ const stale = this.db.prepare("SELECT conversation_id FROM runs WHERE status = 'running'").all() as unknown as { conversation_id: string }[];
71
+ if (stale.length === 0) return [];
72
+ this.db.prepare("UPDATE runs SET status = 'interrupted', updated_at = ? WHERE status = 'running'").run(now());
73
+ return stale.map((row) => row.conversation_id);
74
+ }
75
+
76
+ /**
77
+ * The most recent turn a restart cut short in one session file. A newer turn that finished supersedes it
78
+ * — an interruption is not a permanent property of a chat, and re-showing it would be.
79
+ */
80
+ interruptedFor(sessionFile: string): { at: string } | undefined {
81
+ const row = this.db
82
+ .prepare("SELECT updated_at FROM runs WHERE session_file = ? AND status = 'interrupted' ORDER BY updated_at DESC LIMIT 1")
83
+ .get(sessionFile) as unknown as { updated_at: string } | undefined;
84
+ if (!row) return undefined;
85
+ const newer = this.db
86
+ .prepare("SELECT count(*) AS n FROM runs WHERE session_file = ? AND status IN ('idle','running') AND updated_at > ?")
87
+ .get(sessionFile, row.updated_at) as unknown as { n: number };
88
+ return newer.n > 0 ? undefined : { at: row.updated_at };
89
+ }
90
+
91
+ /**
92
+ * Is a turn in flight in this process right now? The daemon's sweep trigger asks, because a sweep
93
+ * reorganises the store a live conversation is being answered from (docs/memory.md Phase 6).
94
+ */
95
+ runningTurns(): number {
96
+ return Number((this.db.prepare("SELECT count(*) AS n FROM runs WHERE status = 'running'").get() as unknown as { n: number }).n);
97
+ }
98
+
99
+ close(): void {
100
+ this.db.close();
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Bring the pre-split memory database's `runs` rows across, before memory migration v5 drops that table.
106
+ * One-time by construction: the copy is keyed on the legacy table still being there, and `OR IGNORE` makes
107
+ * it harmless if some path already copied them.
108
+ */
109
+ function importLegacyRuns(memoryFile: string, db: DatabaseSync): void {
110
+ if (!existsSync(memoryFile)) return;
111
+ const legacy = new DatabaseSync(memoryFile, { readOnly: true });
112
+ try {
113
+ if (!legacy.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'runs'").get()) return;
114
+ // The old table had no session file of its own; it joined through the memory store's `conversations`.
115
+ const rows = legacy
116
+ .prepare(
117
+ `SELECT runs.conversation_id, conversations.pi_session_file, runs.status, runs.last_event_seq, runs.updated_at
118
+ FROM runs LEFT JOIN conversations ON conversations.id = runs.conversation_id`,
119
+ )
120
+ .all() as unknown as { conversation_id: string; pi_session_file: string | null; status: string; last_event_seq: number; updated_at: string }[];
121
+ for (const row of rows) {
122
+ db.prepare("INSERT OR IGNORE INTO runs (conversation_id, session_file, status, last_event_seq, updated_at) VALUES (?, ?, ?, ?, ?)")
123
+ .run(row.conversation_id, row.pi_session_file, row.status, row.last_event_seq, row.updated_at);
124
+ }
125
+ } finally {
126
+ legacy.close();
127
+ }
128
+ }
129
+
130
+ /** Open (creating if needed) the backend's run database, copying any rows the old combined file holds. */
131
+ export function openRunStore(paths: FlypiPaths): RunStore {
132
+ const db = openFlypiDb(paths.runDbPath);
133
+ migrate(db, RUN_MIGRATIONS);
134
+ importLegacyRuns(paths.memoryDbPath, db);
135
+ return new RunStore(db);
136
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The Secrets tab's server side (docs/secrets.md Phase 5.5): the vault's rows in the wire's shape, plus the
3
+ * references that say what would break if a name went away.
4
+ *
5
+ * Two things this file is responsible for:
6
+ *
7
+ * - Nothing here copies a secret value into a snapshot. The rows carry `mask` and `fingerprint`; the only
8
+ * plaintext in this API is a `reveal`, which the caller asked for by name.
9
+ * - `references` comes from the config files, not from the vault: a secret does not know what points at it,
10
+ * and deleting one whose name is written in mcp.json would break a server the user did not mean to touch.
11
+ */
12
+ import type { FlypiPaths } from "../agent/paths.ts";
13
+ import { MIN_SECRET_CHARS } from "../secrets/redact.ts";
14
+ import type { SecretStore } from "../secrets/vault.ts";
15
+ import { secretReferences } from "../secrets/config.ts";
16
+ import type { KeyState, RevealedSecret, SecretEdit, SecretsSnapshot } from "./protocol.ts";
17
+
18
+ export interface SecretsApi {
19
+ /** Never a value: masks, counters, key state and the references. */
20
+ snapshot(): SecretsSnapshot;
21
+ /** Put, rotate, delete or restore. Returns the refreshed snapshot; a refused edit throws, and the panel shows why. */
22
+ apply(edits: SecretEdit[]): SecretsSnapshot;
23
+ /** The one call that returns a plaintext, and the vault records it as `revealed`. */
24
+ reveal(name: string): RevealedSecret | undefined;
25
+ }
26
+
27
+ export function secretsApi(vault: SecretStore, paths: FlypiPaths): SecretsApi {
28
+ const snapshot = (): SecretsSnapshot => {
29
+ const drift = vault.keyDrift();
30
+ const key: KeyState = {
31
+ provider: drift.keyProvider,
32
+ currentVersion: drift.current,
33
+ rows: drift.rows,
34
+ stale: drift.stale,
35
+ reencryptQueued: drift.reencryptQueued,
36
+ };
37
+ return {
38
+ secrets: vault.list("", true).map((row) => ({
39
+ id: row.id,
40
+ name: row.name,
41
+ kind: row.kind,
42
+ mask: row.mask,
43
+ meta: row.meta,
44
+ status: row.status,
45
+ createdAt: row.created_at,
46
+ ...(row.updated_at === null ? {} : { updatedAt: row.updated_at }),
47
+ ...(row.last_used_at === null ? {} : { lastUsedAt: row.last_used_at }),
48
+ useCount: row.use_count,
49
+ keyVersion: row.key_version,
50
+ fingerprint: row.fingerprint,
51
+ })),
52
+ references: secretReferences(paths),
53
+ key,
54
+ // The gap in the redaction, said out loud: a value this short is not matched in a memory or a
55
+ // transcript, so a user who stored one should know which names are unprotected (src/secrets/redact.ts).
56
+ tooShortToRedact: vault.activeValues().filter((entry) => entry.value.length < MIN_SECRET_CHARS).map((entry) => entry.name),
57
+ };
58
+ };
59
+
60
+ return {
61
+ snapshot,
62
+ apply: (edits) => {
63
+ for (const edit of edits) {
64
+ if (edit.remove === true) {
65
+ const gone = vault.remove(edit.name, { actor: "ui" });
66
+ if (gone.status === "not found") throw new Error(`no secret named "${edit.name}"`);
67
+ continue;
68
+ }
69
+ if (edit.restore === true) {
70
+ const back = vault.restore(edit.name, { actor: "ui" });
71
+ if (back.status === "not found") throw new Error(`nothing deleted named "${edit.name}"`);
72
+ continue;
73
+ }
74
+ if (edit.value === undefined) throw new Error(`${edit.name}: nothing to store`);
75
+ const result = vault.row(edit.name)
76
+ ? vault.rotate({ name: edit.name, value: edit.value, kind: edit.kind, meta: edit.meta, actor: "ui" })
77
+ : vault.put({ name: edit.name, value: edit.value, kind: edit.kind, meta: edit.meta, actor: "ui" });
78
+ if (result.status === "exists") throw new Error(`that value is already stored as "${result.name}"`);
79
+ if (result.status === "name taken") throw new Error(`"${edit.name}" holds a different value — rotate it or choose another name`);
80
+ }
81
+ return snapshot();
82
+ },
83
+ reveal: (name) => {
84
+ const value = vault.value(name, { actor: "ui", action: "revealed" });
85
+ return value === undefined ? undefined : { name, value };
86
+ },
87
+ };
88
+ }