@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,24 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The vault from a shell (docs/secrets.md Phase 5.5): `flypi secret list | get | print | put | rotate |
4
+ * delete | undelete | path | import-ssh | rotate-key`.
5
+ *
6
+ * echo 'sk-…' | flypi secret put openai_key --kind password
7
+ * flypi secret print openai_key # the value, for `!flypi secret print NAME` in a config file
8
+ *
9
+ * The value comes from stdin, not argv, so it does not appear in the process list next to a username. Read
10
+ * commands open the database read-only, which is why they answer while the daemon is running.
11
+ */
12
+ import { readFileSync } from "node:fs";
13
+ import { resolve } from "node:path";
14
+ import { runSecretCli } from "../src/secrets/cli.ts";
15
+
16
+ const argv = process.argv.slice(2);
17
+ const rootAt = argv.indexOf("--root");
18
+ const root = rootAt === -1 ? process.env.FLYPI_ROOT ?? process.cwd() : (argv[rootAt + 1] ?? "");
19
+ if (rootAt !== -1) argv.splice(rootAt, 2);
20
+
21
+ // A piped value, read here so `runSecretCli` stays synchronous and testable (src/secrets/cli.ts). A terminal
22
+ // would block on stdin, so only a pipe or a redirect counts.
23
+ const stdin = process.stdin.isTTY ? undefined : readFileSync(0, "utf8");
24
+ process.exit(runSecretCli(argv, { root: resolve(root), ...(stdin === undefined ? {} : { stdin }) }));
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The always-on daemon (docs/architecture.md Phase 4.5): the agent server, the built UI, the memory MCP
4
+ * surface and the job queue in one process. Foreground here; `flypi start` is what puts it in the
5
+ * background and writes `.flypi/daemon.log`.
6
+ *
7
+ * npm run daemon # ports and token from .flypi/agent/mcp.json
8
+ * npm run daemon -- --port 8787 # override the agent server
9
+ * npm run daemon -- --host 0.0.0.0 # bind every interface (a container: a published port
10
+ * # cannot reach the container's own loopback)
11
+ * npm run daemon -- --embeddings none # FTS-only memory, no model download
12
+ *
13
+ * Unlike `npm run serve`, this is the process that keeps conversations alive with no client attached,
14
+ * records which turn was running when it died, and runs `memory.reembed` while idle. It holds both
15
+ * databases — the memory store's and the backend's run record — because it is the one process that is both
16
+ * halves at once; `npm run serve` holds only the second.
17
+ */
18
+ import { readFileSync } from "node:fs";
19
+ import { resolve } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ import { flypiPaths } from "../src/agent/paths.ts";
22
+ import { builtUi, startFlypiDaemon } from "../src/daemon/index.ts";
23
+ import { memoryConfig } from "../src/memory/config.ts";
24
+ import { openMemoryDb } from "../src/memory/db.ts";
25
+ import { createEmbedder } from "../src/memory/embed.ts";
26
+ import { modelProgress } from "../src/memory/progress.ts";
27
+ import { createJudge } from "../src/memory/judge.ts";
28
+ import { MemoryStore } from "../src/memory/store.ts";
29
+ import { openRunStore } from "../src/server/run-store.ts";
30
+
31
+ const argv = process.argv.slice(2);
32
+ const flagValue = (name: string): string | undefined => {
33
+ const i = argv.indexOf(name);
34
+ return i === -1 ? undefined : argv[i + 1];
35
+ };
36
+
37
+ const paths = flypiPaths(resolve(flagValue("--root") ?? process.cwd()));
38
+ const config = memoryConfig(paths);
39
+ if (flagValue("--embeddings")) config.embeddings.provider = flagValue("--embeddings") === "none" ? "none" : "transformers";
40
+ if (flagValue("--judge")) config.judge.provider = flagValue("--judge") === "none" ? "none" : "transformers";
41
+
42
+ // The download is ~1 GB on a machine that has never run flypi, and the daemon is not listening until it
43
+ // finishes — so both loaders report what they are fetching, into the log `flypi start` echoes back.
44
+ const embedder = await createEmbedder(config.embeddings, modelProgress(config.embeddings.model));
45
+ if (embedder.error) console.log(`flypi: ${embedder.error} — memory search will be keyword-only`);
46
+ // A second resident model (~400 MB next to the embedder, docs/decisions.md). Not loading it is supported:
47
+ // the store judges what tokens can judge and says so. With no embedder there is no candidate set.
48
+ const judge = embedder.identity ? await createJudge(config.judge, modelProgress(config.judge.model)) : { classify: undefined };
49
+ if (judge.error) console.log(`flypi: ${judge.error}`);
50
+
51
+ const db = openMemoryDb(paths.memoryDbPath);
52
+ const store = new MemoryStore(db, embedder, { classify: judge.classify });
53
+ // The backend's own database: which conversation had a turn in flight. Separate file, separate writer.
54
+ const runs = openRunStore(paths);
55
+
56
+ const token = flagValue("--token") ?? process.env.FLYPI_TOKEN;
57
+ // The package the code ships in — not `paths.root`, which is the state directory (`~/.flypi`'s parent for
58
+ // an installed app, the checkout in a dev tree).
59
+ const pkgDir = resolve(fileURLToPath(import.meta.url), "..", "..");
60
+ // The built UI ships next to the code, not next to the state: `--root` says which `.flypi/` the daemon owns,
61
+ // and for the installed app that is the home directory, which has no dist/ui in it.
62
+ const uiDir = builtUi(pkgDir);
63
+ // The version the health page and `flypi status` report. Read from the package rather than from npm's
64
+ // environment: the CLI spawns this directly, so npm_package_version is only set when it came through a script.
65
+ const version = (JSON.parse(readFileSync(resolve(pkgDir, "package.json"), "utf8")).version ?? "dev") as string;
66
+ const daemon = await startFlypiDaemon({
67
+ paths,
68
+ db,
69
+ store,
70
+ runs,
71
+ ...(flagValue("--port") ? { port: Number(flagValue("--port")) } : {}),
72
+ // Set by `flypi start` when the port came from an earlier start rather than from this command line: a port
73
+ // nobody named today is a preference, so the daemon binds another one instead of dying on `EADDRINUSE`.
74
+ ...(argv.includes("--port-may-move") ? { portMayMove: true } : {}),
75
+ ...(flagValue("--host") ? { host: flagValue("--host") } : {}),
76
+ ...(token ? { token } : {}),
77
+ ...(uiDir ? { uiDir } : {}),
78
+ version,
79
+ });
80
+ console.log(`flypi: daemon pid ${process.pid}`);
81
+ console.log(`flypi: agent server ${daemon.url}`);
82
+ console.log(`flypi: memory MCP http://127.0.0.1:${daemon.memoryPort}/mcp`);
83
+ console.log(`flypi: embeddings ${embedder.identity ? embedder.identity.model : "none (FTS-only)"}`);
84
+ console.log(`flypi: judge ${judge.classify ? config.judge.model : "none (token rule only)"}`);
85
+ if (!uiDir) console.log("flypi: dist/ui is not built — running headless (`npm run build` for the UI)");
86
+ console.log("flypi: `flypi stop`, or Ctrl-C, to stop`");
87
+
88
+ // The plan's rule: an interrupted turn is not re-run, because the tools may already have written to the
89
+ // world. So shutdown only has to stop the loop, close the sockets and leave the record.
90
+ let stopping = false;
91
+ for (const signal of ["SIGINT", "SIGTERM"] as const) {
92
+ process.on(signal, () => {
93
+ if (stopping) return;
94
+ stopping = true;
95
+ void daemon.close().then(() => {
96
+ db.close();
97
+ runs.close();
98
+ process.exit(0);
99
+ });
100
+ });
101
+ }
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Start the memory store as an MCP server over HTTP (docs/memory.md Phase 3).
4
+ *
5
+ * npm run memory # ephemeral port, generated token
6
+ * npm run memory -- --port 8790 --token … # fixed
7
+ * npm run memory -- --embeddings none # FTS-only, no model download
8
+ * npm run memory -- --judge none # no contradiction model (token rule only)
9
+ *
10
+ * Point an MCP client at it with:
11
+ * { "name": "flypi-memory", "url": "http://127.0.0.1:<port>/mcp", "headers": { "Authorization": "Bearer <token>" } }
12
+ *
13
+ * Two mounts, one store: `/mcp` is an ordinary caller, `/mcp/background` is an unattended writer — same token,
14
+ * same database, and the store refuses it `memory_supersede`/`memory_forget`/`memory_capture`
15
+ * (src/memory/mcp.ts, docs/memory.md Phase 4.8). flypi points a review fork's entry at the second one.
16
+ */
17
+ import { resolve } from "node:path";
18
+ import { flypiPaths } from "../src/agent/paths.ts";
19
+ import { memoryConfig } from "../src/memory/config.ts";
20
+ import { openMemoryDb } from "../src/memory/db.ts";
21
+ import { createEmbedder } from "../src/memory/embed.ts";
22
+ import { createJudge } from "../src/memory/judge.ts";
23
+ import { startMemoryServer } from "../src/memory/mcp.ts";
24
+ import { MemoryStore } from "../src/memory/store.ts";
25
+ import { openSecretVault } from "../src/secrets/vault.ts";
26
+
27
+ const argv = process.argv.slice(2);
28
+ const flagValue = (name: string): string | undefined => {
29
+ const i = argv.indexOf(name);
30
+ return i === -1 ? undefined : argv[i + 1];
31
+ };
32
+
33
+ const paths = flypiPaths(resolve(flagValue("--root") ?? process.cwd()));
34
+ const config = memoryConfig(paths);
35
+ if (flagValue("--embeddings")) config.embeddings.provider = flagValue("--embeddings") === "none" ? "none" : "transformers";
36
+ if (flagValue("--judge")) config.judge.provider = flagValue("--judge") === "none" ? "none" : "transformers";
37
+
38
+ const embedder = await createEmbedder(config.embeddings);
39
+ if (embedder.error) console.log(`flypi memory: ${embedder.error} — search will be keyword-only`);
40
+ // A second resident model (~400 MB of RSS next to the embedder, docs/decisions.md). Refusing to load it
41
+ // is a supported state, not a failure: the store keeps judging what tokens can judge and says so.
42
+ // With no embedder there is no candidate set, so a judge would be 400 MB for a call that cannot happen.
43
+ const judge = embedder.identity ? await createJudge(config.judge) : { classify: undefined };
44
+ if (judge.error) console.log(`flypi memory: ${judge.error}`);
45
+
46
+ const db = openMemoryDb(flagValue("--db") ?? paths.memoryDbPath);
47
+ const store = new MemoryStore(db, embedder, { classify: judge.classify });
48
+ // The vault lives in this same file, so this process is the one that can write it — and opening it here is
49
+ // what makes `redactSecrets` real when a memory body is stored (docs/secrets.md Phase 5.5). A memory server
50
+ // with no vault open would accept `sk-…` into a memory row and never know.
51
+ // This process owns the write handle, so the vault may record uses and changes against it.
52
+ openSecretVault(paths, { db, write: true });
53
+ const identity = store.indexIdentity();
54
+ if (identity.stale > 0 && identity.expected) {
55
+ console.log(`flypi memory: ${identity.stale} stored vectors are not from ${identity.expected?.model} — queued memory.reembed`);
56
+ }
57
+
58
+ const server = await startMemoryServer({
59
+ store,
60
+ port: flagValue("--port") ? Number(flagValue("--port")) : undefined,
61
+ token: flagValue("--token") ?? process.env.FLYPI_MEMORY_TOKEN,
62
+ principal: "mcp",
63
+ });
64
+
65
+ console.log(`flypi memory: listening ${server.url}`);
66
+ console.log(`flypi memory: token ${server.token}`);
67
+ console.log(`flypi memory: embeddings ${embedder.identity ? embedder.identity.model : "none (FTS-only)"}`);
68
+ console.log(`flypi memory: judge ${judge.classify ? config.judge.model : embedder.identity ? "none (token rule only)" : "none (no candidates without embeddings)"}`);
69
+ console.log("flypi memory: Ctrl-C to stop");
70
+
71
+ const stop = () => {
72
+ void server.close().then(() => {
73
+ db.close();
74
+ process.exit(0);
75
+ });
76
+ };
77
+ process.on("SIGINT", stop);
78
+ process.on("SIGTERM", stop);
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Start the flypi agent server (docs/protocol.md).
4
+ *
5
+ * npm run serve # ephemeral port, generated token
6
+ * npm run serve -- --port 8787 # fixed port
7
+ * FLYPI_TOKEN=… npm run serve -- --token …
8
+ */
9
+ import { resolve } from "node:path";
10
+ import { existsSync } from "node:fs";
11
+ import { flypiPaths } from "../src/agent/paths.ts";
12
+ import { memoryEndpoint } from "../src/agent/config-files.ts";
13
+ import { memoryConfig } from "../src/memory/config.ts";
14
+ import { openMemoryDbReadOnly } from "../src/memory/db.ts";
15
+ import { configuredIdentity } from "../src/memory/embed.ts";
16
+ import { MemoryStore } from "../src/memory/store.ts";
17
+ import { memoryApi } from "../src/server/memory-api.ts";
18
+ import { SessionRegistry } from "../src/server/session-registry.ts";
19
+ import { openRunStore } from "../src/server/run-store.ts";
20
+ import { secretsApi } from "../src/server/secrets-api.ts";
21
+ import { openSecretVault, type SecretStore } from "../src/secrets/vault.ts";
22
+ import { startFlypiServer } from "../src/server/index.ts";
23
+
24
+ const argv = process.argv.slice(2);
25
+ const flagValue = (name: string): string | undefined => {
26
+ const i = argv.indexOf(name);
27
+ return i === -1 ? undefined : argv[i + 1];
28
+ };
29
+
30
+ const paths = flypiPaths(resolve(flagValue("--root") ?? process.cwd()));
31
+ // The backend's own database, and this process is its only writer (docs/risks.md risk 3): it is the process
32
+ // that runs pi sessions, so it is the process that knows whether a turn was in flight. The memory store is
33
+ // not opened here at all — this half has no business with it, and the agent reaches memory over MCP.
34
+ const runs = openRunStore(paths);
35
+ // One backend at a time owns this file, like any other flypi database: `flypi start` or `npm run serve`, not
36
+ // both. Whatever is left `running` belongs to a process that is gone, so say so before serving anyone a chat.
37
+ runs.reconcileInterrupted();
38
+ // The vault is in the memory database, which this process does not own. With the file present it is opened
39
+ // read-only, so a chat run here still redacts `[secret:x]` instead of repeating a credential into the
40
+ // transcript; with no file, or one this build has not migrated, the sessions simply have no secret tools and
41
+ // the Secrets tab says so (docs/secrets.md Phase 5.5). `flypi start` is the process that can write it.
42
+ let vault: SecretStore | undefined;
43
+ // The memory browser (docs/memory.md Phase 6) reads the same file. Read-only, like the vault: the Memory tab
44
+ // lists rows, turns and wiki pages from here, and a write is refused with the sentence that names the process
45
+ // that may write. The embedder is the configured model's *identity* and nothing more, so the index state the
46
+ // panel reports is true without this process loading ~100 MB of model it will never ask a vector of.
47
+ let memory: ReturnType<typeof memoryApi> | undefined;
48
+ try {
49
+ if (existsSync(paths.memoryDbPath)) {
50
+ const memoryDb = openMemoryDbReadOnly(paths.memoryDbPath);
51
+ memory = memoryApi(new MemoryStore(memoryDb, await configuredIdentity(memoryConfig(paths).embeddings)), memoryDb, { readOnly: true });
52
+ }
53
+ } catch (error) {
54
+ console.log(`flypi: no memory store: ${(error as Error).message}`);
55
+ }
56
+ const registry = new SessionRegistry({
57
+ root: paths.root,
58
+ onRunStatus: (conversationId, status, info) => runs.setRunStatus(conversationId, status, info),
59
+ vault,
60
+ });
61
+ const server = await startFlypiServer({
62
+ registry,
63
+ port: flagValue("--port") ? Number(flagValue("--port")) : undefined,
64
+ token: flagValue("--token") ?? process.env.FLYPI_TOKEN,
65
+ serverVersion: "dev",
66
+ // An interrupted turn is a backend fact, so it is answered from the backend's store here too.
67
+ interruptedRun: (sessionFile) => runs.interruptedFor(sessionFile),
68
+ // Prod: the same server serves the built UI (docs/architecture.md: "Dev vs prod").
69
+ uiDir: resolve(process.cwd(), "dist/ui"),
70
+ secrets: vault ? secretsApi(vault, paths) : undefined,
71
+ memory,
72
+ });
73
+
74
+ console.log(`flypi: listening ${server.url}`);
75
+ console.log(`flypi: token ${server.token}`);
76
+ console.log(`flypi: connect ${server.url}/?token=${server.token}`);
77
+ // Same check the daemon makes: an entry pointing at a token this process does not have is refused by the
78
+ // store, and the sessions here would simply have no memory tools (docs/risks.md risk 8).
79
+ const memoryNote = memoryEndpoint(paths).note;
80
+ if (memoryNote) console.log(`flypi: ${memoryNote}`);
81
+ console.log("flypi: Ctrl-C to stop");
82
+
83
+ const stop = () => {
84
+ registry.dispose();
85
+ runs.close();
86
+ void server.close().then(() => process.exit(0));
87
+ };
88
+ process.on("SIGINT", stop);
89
+ process.on("SIGTERM", stop);
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Phase 3's gate in one command (docs/memory.md, docs/testing.md): the SQLite file, both halves of
3
+ * search, the status rules, and the MCP-over-HTTP surface — against the real `.flypi/memory/flypi.db`
4
+ * the plan says must stay queryable with `sqlite3`.
5
+ *
6
+ * It is a driver, not a test: the assertions live in tests/memory/*. What it adds is that the same
7
+ * file opens, migrates, and answers a client that speaks MCP.
8
+ *
9
+ * Run: npm run smoke:memory (add -- --no-embeddings to skip the model download)
10
+ */
11
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { deflateSync } from "node:zlib";
15
+ import { McpClient, StreamableHttpTransport } from "@earendil-works/pi-mcp";
16
+ import { flypiPaths } from "../src/agent/paths.ts";
17
+ import { memoryConfig } from "../src/memory/config.ts";
18
+ import { openMemoryDb } from "../src/memory/db.ts";
19
+ import { createEmbedder } from "../src/memory/embed.ts";
20
+ import { startMemoryServer } from "../src/memory/mcp.ts";
21
+ import { MemoryStore } from "../src/memory/store.ts";
22
+ import { consolidate, planSweep } from "../src/memory/consolidate.ts";
23
+ import { readWiki, renderWiki } from "../src/memory/wiki.ts";
24
+
25
+ const paths = flypiPaths(process.cwd());
26
+ const flagValue = (name) => {
27
+ const i = process.argv.indexOf(name);
28
+ return i === -1 ? undefined : process.argv[i + 1];
29
+ };
30
+ const dbFile = flagValue("--db") ?? paths.memoryDbPath;
31
+ const config = memoryConfig(paths);
32
+ if (process.argv.includes("--no-embeddings")) config.embeddings.provider = "none";
33
+
34
+ const embedder = await createEmbedder(config.embeddings);
35
+ const db = openMemoryDb(dbFile);
36
+ const store = new MemoryStore(db, embedder);
37
+
38
+ const ok = (line) => console.log(`ok: ${line}`);
39
+ const fail = (line) => {
40
+ console.error(`FAIL: ${line}`);
41
+ process.exit(1);
42
+ };
43
+
44
+ const marker = `smoke-${Date.now()}`;
45
+ const write = (body, extra = {}) =>
46
+ store.addMemory({ kind: "smoke", body, tags: ["smoke"], origin_class: "agent", session_kind: "interactive", actor: "smoke", ...extra });
47
+
48
+ // 1. Write, then find it by its own words.
49
+ const { id } = await write(`${marker}: the build command is npm run build`);
50
+ const byKeyword = await store.search({ query: marker });
51
+ if (!byKeyword.results.some((row) => row.id === id)) fail(`#${id} was not found by its own marker`);
52
+ ok(
53
+ `store + keyword search — #${id} in ${byKeyword.results.length} hit(s)` +
54
+ (byKeyword.partial ? `, keyword-only (${embedder.error ?? "embeddings off"})` : `, embeddings ${embedder.identity?.model}`),
55
+ );
56
+
57
+ // 2. The one automatic transition: active → superseded, both rows kept. The two rows must be different
58
+ // facts — the dedup gate is meant to catch the same fact worded differently (see NEAR_DUPLICATE).
59
+ const { id: replacement } = await write(`${marker}: the deploy target is 10.0.0.7`);
60
+ store.markSuperseded(id, replacement, "smoke");
61
+ const retired = store.memory(id);
62
+ if (retired.status !== "superseded" || retired.superseded_by !== replacement) fail("supersede did not leave both rows linked");
63
+ if (store.memory(replacement).status !== "active") fail("the replacement is not active");
64
+ if ((await store.search({ query: marker })).results.some((row) => row.id === id)) fail("a superseded row is still searchable");
65
+ ok(`status rules — #${id} superseded by #${replacement}, both rows still in the table`);
66
+
67
+ // 3. Nothing deletes a row except an explicit forget; a repeat write is not a second row.
68
+ const { id: throwaway } = await write(`${marker}: this one is only here to be forgotten`);
69
+ const repeated = await write(`${marker}: this one is only here to be forgotten`);
70
+ if (repeated.duplicateOf !== throwaway) fail(`a repeat of #${throwaway} wrote #${repeated.id} instead of pointing back`);
71
+ if (store.memory(id).use_count === 0) fail("the search in step 1 did not count as a use");
72
+ ok(`dedup + counters — a repeat points at #${throwaway}, retrieval counted ${store.memory(id).use_count} use(s)`);
73
+
74
+ const rowsBefore = db.prepare("SELECT count(*) AS n FROM memories").get().n;
75
+ store.forget(throwaway, "smoke");
76
+ if (store.memory(throwaway).status !== "deleted") fail("forget did not reach status=deleted");
77
+ if (db.prepare("SELECT count(*) AS n FROM memories").get().n !== rowsBefore) fail("a memory row disappeared; Phase 3 never hard-deletes");
78
+ ok(`audit + delete rules — ${db.prepare("SELECT count(*) AS n FROM memory_audit WHERE memory_id = ?").get(throwaway).n} audit rows for #${throwaway}, still ${rowsBefore} memory rows`);
79
+
80
+ // 4. The index is one vector space, or it says so.
81
+ const index = store.indexIdentity();
82
+ if (index.stale > 0 && !index.reembedQueued) fail("a stale index did not queue memory.reembed");
83
+ ok(`index identity — ${index.rows} vectors, ${index.stale} from another model, reembed queued: ${index.reembedQueued}`);
84
+
85
+ // 5. The same store over HTTP, through the MCP client pi uses.
86
+ const server = await startMemoryServer({ store, token: "smoke-token", principal: "mcp:smoke" });
87
+ const client = new McpClient({ name: "flypi-smoke", version: "0.0.0" });
88
+ await client.connect(new StreamableHttpTransport({ url: server.url, headers: { authorization: "Bearer smoke-token" } }));
89
+ const tools = await client.listTools();
90
+ // The surface the agent can reach, named rather than counted: a tool appearing or vanishing is a
91
+ // decision, and a bare number cannot say which one moved (this sat at 7 while `memory_cite` shipped).
92
+ // These are the ten the server registers today — see docs/memory.md Phase 4 on the read tools the
93
+ // plan describes and this server does not have.
94
+ const SURFACE = ["memory_capture", "memory_cite", "memory_forget", "memory_index_status", "memory_media", "memory_read", "memory_search", "memory_supersede", "memory_turn", "memory_write"];
95
+ const names = tools.map((tool) => tool.name).sort();
96
+ if (names.join(",") !== [...SURFACE].sort().join(",")) fail(`memory tools changed: expected ${SURFACE.join(", ")}, got ${names.join(", ")}`);
97
+ const overTheWire = JSON.parse((await client.callTool("memory_search", { query: marker })).content[0].text);
98
+ if (!overTheWire.results.some((row) => row.id === id || row.id === replacement)) fail("the MCP search did not see the row the store wrote");
99
+ // A turn of a real conversation reaches the store the same way: the memory server is the only writer, and
100
+ // the hit-rate metrics are what tell flypi whether recall paid for itself. Asked about this run's own
101
+ // conversation by id — the store may hold turns from earlier sessions, and `memoryHitRate()` counts them all.
102
+ await client.callTool("memory_turn", { conversation_id: "smoke-conv", user_text: "what do we use?", retrieved: [id], cited: [id] });
103
+ const turnRow = db.prepare("SELECT retrieved_json AS retrieved, cited_json AS cited FROM turns WHERE conversation_id = 'smoke-conv'").get();
104
+ if (!turnRow || turnRow.retrieved === null || turnRow.cited === null) fail("the turn log did not record what was shown and cited");
105
+ const measured = store.memoryHitRate();
106
+ if (typeof measured.rate !== "number" || measured.turns < 1) fail(`the hit-rate metric did not compute: ${JSON.stringify(measured)}`);
107
+ ok(`MCP over HTTP — ${tools.length} tools at ${server.url}, search reached #${overTheWire.results.map((row) => row.id).join(", #")}, turn ${turnRow.retrieved} retrieved / ${turnRow.cited} cited, store hit rate ${measured.rate} over ${measured.turns} turn(s)`);
108
+
109
+ // 6. A picture and a recording: a path in, a vector in the same space, the bytes back out. This is the
110
+ // mechanism with the real model — whether the *meaning* of a photo is retrieved well is what the recall
111
+ // corpus measures (docs/memory.md Phase 4), so nothing semantic is asserted here.
112
+ const dir = mkdtempSync(join(tmpdir(), "flypi-smoke-media-"));
113
+ const pictureFile = join(dir, "shape.png");
114
+ const toneFile = join(dir, "tone.wav");
115
+ const pictureBytes = png(64, 64, (x, y) => ((x - 32) ** 2 + (y - 32) ** 2 < 400 ? [0, 0, 0] : [255, 255, 255]));
116
+ const toneBytes = wav(0.6, 220);
117
+ writeFileSync(pictureFile, pictureBytes);
118
+ writeFileSync(toneFile, toneBytes);
119
+
120
+ const picture = JSON.parse((await client.callTool("memory_write", { body: `${marker}: a black circle on white`, kind: "smoke", media_path: pictureFile })).content[0].text);
121
+ const recording = JSON.parse((await client.callTool("memory_write", { body: `${marker}: a short tone`, kind: "smoke", media_path: toneFile })).content[0].text);
122
+ if (!picture.id || !recording.id) fail(`a media write was refused: ${picture.error ?? recording.error}`);
123
+ for (const [written, kind] of [[picture, "image"], [recording, "audio"]]) {
124
+ const row = store.memory(written.id);
125
+ if (row.modality !== kind) fail(`#${written.id} is ${row.modality}, not ${kind}`);
126
+ // With a model, both must be in the index; without one, both must say why they are not.
127
+ if (embedder.identity ? row.embed?.dims !== 768 : written.indexed !== false || !written.note) {
128
+ fail(`#${written.id} ${embedder.identity ? `has no ${kind} vector in the current index` : `did not report why it is keyword-only`}`);
129
+ }
130
+ }
131
+ if (embedder.identity && store.indexIdentity().stale !== 0) fail("the media vectors did not land in the current index");
132
+
133
+ const asked = JSON.parse((await client.callTool("memory_search", { query: `${marker} tone` })).content[0].text);
134
+ if (!asked.results.some((row) => row.id === recording.id && row.modality === "audio")) fail("the search did not report the recording's modality");
135
+ const back = JSON.parse((await client.callTool("memory_media", { id: picture.id })).content[0].text);
136
+ if (back.mime !== "image/png" || !Buffer.from(back.base64, "base64").equals(pictureBytes)) fail("the image bytes did not come back unchanged");
137
+ ok(`media — #${picture.id} (image) and #${recording.id} (audio) stored and found, bytes round-tripped${embedder.identity ? ", both embedded at 768d" : ", keyword-only as configured"}`);
138
+
139
+ const unsupported = await client.callTool("memory_write", { body: `${marker}: a song`, kind: "smoke", media_path: join(dir, "song.mp3") });
140
+ if (!unsupported.isError) fail("an mp3 was accepted; only wav audio is supported");
141
+ ok("an unsupported container is refused with what to do about it");
142
+
143
+ // 8. Phase 6: the sweep and the wiki. The daemon runs the sweep when the store is quiet; here it is forced,
144
+ // because the gate is that the same file answers a whole-store pass and that the wiki comes out of it readable
145
+ // with its `#id` spine. The merge is only run against a scratch database — a sweep over the real store would
146
+ // merge someone's real rows to watch a script work.
147
+ const twin = await write(`${marker}: the deploy host is the small box`);
148
+ const twinCopy = await write(`${marker}: the deploy host is the small box at home`);
149
+ const scratch = dbFile !== paths.memoryDbPath;
150
+ const rowsBeforeSweep = db.prepare("SELECT count(*) AS n FROM memories").get().n;
151
+ // Against the real store this is the plan only — `planSweep` is pure, which is the whole reason the sweep can
152
+ // be interrupted without half-applying, and the same property means a smoke can look without merging anyone's
153
+ // rows. Against a scratch file the pass actually writes.
154
+ const sweep = scratch
155
+ ? consolidate(store)
156
+ : planSweep(store.activeMemories(), store.activeVectors(), store.citationEvidence());
157
+ if (db.prepare("SELECT count(*) AS n FROM memories").get().n !== rowsBeforeSweep) fail("a sweep made a memory row disappear");
158
+ const rendered = renderWiki(store, db);
159
+ const page = readWiki(db).find((entry) => entry.slug === "smoke");
160
+ if (!page || !page.body.includes(`#${twin.id}`)) fail("the wiki did not render this run's rows with their #id citations");
161
+ ok(
162
+ `sweep + wiki — ${scratch ? `merged ${sweep.merges.length} cluster(s),` : `planned ${sweep.merges.length} cluster(s) (plan only; pass --db to write),`} ` +
163
+ `${rowsBeforeSweep} rows before and after, ${rendered.pages} page(s) with ${page.sections.length} section(s), spine cites #${twin.id}`,
164
+ );
165
+ if (scratch && (sweep.merges.length === 0 || store.memory(twinCopy.id).status !== "superseded")) {
166
+ fail(`the sweep did not collapse #${twinCopy.id} into #${twin.id}`);
167
+ }
168
+
169
+ await client.close();
170
+ await server.close();
171
+ rmSync(dir, { recursive: true, force: true });
172
+
173
+ console.log(`\nthe file: ${dbFile}`);
174
+ // The smoke's own rows stay in the table with status='deleted' — Phase 3 never hard-deletes, and
175
+ // forgetting them is what stops a later run's near-identical sentence from deduping against this one.
176
+ for (const row of [id, replacement, throwaway, picture.id, recording.id]) store.forget(row, "smoke:cleanup");
177
+ // A turn row is a measurement, not a memory, so the never-hard-delete rule does not apply to it: leaving
178
+ // this smoke's turn in a real store would quietly move its hit-rate numbers.
179
+ db.prepare("DELETE FROM turns WHERE conversation_id = 'smoke-conv'").run();
180
+ ok(`cleanup — #${id}, #${replacement}, #${throwaway} are status=deleted; ${db.prepare("SELECT count(*) AS n FROM memories WHERE status='active'").get().n} active rows left`);
181
+ console.log(` sqlite3 '${dbFile}' 'select count(*) from memories'`);
182
+ db.close();
183
+ process.exit(0);
184
+
185
+ /** A PNG the image encoder can actually see: solid shapes, no photo, so nothing semantic is implied. */
186
+ function png(width, height, pixel) {
187
+ const stride = width * 3 + 1;
188
+ const raw = Buffer.alloc(stride * height);
189
+ for (let y = 0; y < height; y += 1) {
190
+ for (let x = 0; x < width; x += 1) pixel(x, y).forEach((value, channel) => (raw[y * stride + 1 + x * 3 + channel] = value));
191
+ }
192
+ const ihdr = Buffer.alloc(13);
193
+ ihdr.writeUInt32BE(width, 0);
194
+ ihdr.writeUInt32BE(height, 4);
195
+ ihdr[8] = 8; // bit depth
196
+ ihdr[9] = 2; // truecolour
197
+ return Buffer.concat([
198
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
199
+ chunk("IHDR", ihdr),
200
+ chunk("IDAT", deflateSync(raw)),
201
+ chunk("IEND", Buffer.alloc(0)),
202
+ ]);
203
+ }
204
+
205
+ function chunk(type, body) {
206
+ const head = Buffer.alloc(8);
207
+ head.writeUInt32BE(body.length, 0);
208
+ head.write(type, 4);
209
+ const crc = Buffer.alloc(4);
210
+ let check = ~0;
211
+ for (const byte of Buffer.concat([Buffer.from(type), body])) { // the CRC covers the type and the body, not the length
212
+ check ^= byte;
213
+ for (let bit = 0; bit < 8; bit += 1) check = (check >>> 1) ^ (0xedb88320 & -(check & 1));
214
+ }
215
+ crc.writeUInt32BE(~check >>> 0, 0);
216
+ return Buffer.concat([head, body, crc]);
217
+ }
218
+
219
+ /** `seconds` of a `hertz` tone, 16 kHz mono — what a recorder would produce. */
220
+ function wav(seconds, hertz) {
221
+ const frames = Math.round(seconds * 16_000);
222
+ const data = Buffer.alloc(frames * 2);
223
+ for (let i = 0; i < frames; i += 1) data.writeInt16LE(Math.round(8000 * Math.sin((2 * Math.PI * hertz * i) / 16_000)), i * 2);
224
+ const header = Buffer.alloc(44);
225
+ header.write("RIFF", 0);
226
+ header.writeUInt32LE(36 + data.length, 4);
227
+ header.write("WAVE", 8);
228
+ header.write("fmt ", 12);
229
+ header.writeUInt32LE(16, 16);
230
+ header.writeUInt16LE(1, 20);
231
+ header.writeUInt16LE(1, 22);
232
+ header.writeUInt32LE(16_000, 24);
233
+ header.writeUInt32LE(32_000, 28);
234
+ header.writeUInt16LE(2, 32);
235
+ header.writeUInt16LE(16, 34);
236
+ header.write("data", 36);
237
+ header.writeUInt32LE(data.length, 40);
238
+ return Buffer.concat([header, data]);
239
+ }