@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,17 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>flypi</title>
7
+ <!-- The tab icon follows the OS palette, not the page toggle (ui/lib/theme.ts) — the browser picks
8
+ one of the two files and there is no script in this document to do anything else. -->
9
+ <link rel="icon" href="/flypi-logo-light.jpeg" media="(prefers-color-scheme: light)" />
10
+ <link rel="icon" href="/flypi-logo-dark.jpeg" media="(prefers-color-scheme: dark)" />
11
+ <script type="module" crossorigin src="/assets/index-CG1CuGxu.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-qaKjH5fH.css">
13
+ </head>
14
+ <body>
15
+ <div id="app"></div>
16
+ </body>
17
+ </html>
package/package.json CHANGED
@@ -1,6 +1,72 @@
1
1
  {
2
- "name": "@makerstufferupper/flypi",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@makerstufferupper/flypi",
3
+ "version": "0.1.2",
4
+ "type": "module",
5
+ "description": "Local multi-model agent tool with a browser UI and a personal knowledge wiki, built on the pi SDK.",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/makerstufferupper/flypi.git"
10
+ },
11
+ "bin": {
12
+ "flypi": "bin/flypi.mjs"
13
+ },
14
+ "files": [
15
+ "bin",
16
+ "src",
17
+ "scripts",
18
+ "dist/ui",
19
+ "assets/screenshot.png",
20
+ "README.md"
21
+ ],
22
+ "engines": {
23
+ "node": ">=22.19.0"
24
+ },
25
+ "scripts": {
26
+ "probe": "tsx scripts/probe.ts",
27
+ "probe:turn": "tsx scripts/probe-turn-boundary.ts",
28
+ "probe:boundary": "tsx scripts/turn-boundary.ts",
29
+ "start": "node scripts/start.mjs",
30
+ "dev": "vite",
31
+ "models:warm": "tsx scripts/warm-models.ts",
32
+ "build": "vite build",
33
+ "prepublishOnly": "npm run typecheck && npm test && npm run build",
34
+ "typecheck": "tsc --noEmit",
35
+ "test": "vitest run",
36
+ "record": "tsx scripts/record-transcript.ts",
37
+ "smoke": "tsx scripts/smoke.mjs",
38
+ "smoke:memory": "tsx scripts/smoke-memory.mjs",
39
+ "smoke:secrets": "tsx scripts/smoke-secrets.mjs",
40
+ "secret": "tsx scripts/secret.ts",
41
+ "reembed:memory": "tsx scripts/reembed-memory.ts",
42
+ "memory": "tsx scripts/serve-memory.ts",
43
+ "serve": "tsx scripts/serve.ts",
44
+ "daemon": "tsx scripts/serve-daemon.ts",
45
+ "flypi": "node scripts/flypi.mjs",
46
+ "release": "node scripts/release.mjs"
47
+ },
48
+ "dependencies": {
49
+ "@earendil-works/pi-coding-agent": "^1.0.4",
50
+ "@huggingface/transformers": "^4.3.1",
51
+ "@modelcontextprotocol/sdk": "^1.32.1",
52
+ "tsx": "^4.23.15",
53
+ "marked": "18.0.11",
54
+ "typebox": "^1.3.27",
55
+ "ws": "^8.22.0",
56
+ "yaml": "^2.9.0"
57
+ },
58
+ "devDependencies": {
59
+ "@earendil-works/pi-ai": "^1.0.4",
60
+ "@modelcontextprotocol/server-filesystem": "^1.0.0",
61
+ "@sveltejs/vite-plugin-svelte": "^7.3.1",
62
+ "@testing-library/svelte": "^5.4.2",
63
+ "@types/node": "^26.6.4",
64
+ "@types/ws": "^8.18.2",
65
+ "@vitest/coverage-v8": "^5.0.3",
66
+ "happy-dom": "^20.14.5",
67
+ "svelte": "^5.57.2",
68
+ "typescript": "^7.0.2",
69
+ "vite": "^8.3.3",
70
+ "vitest": "^5.0.3"
71
+ }
72
+ }
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Measure a contradiction judge before deciding to run one (docs/memory.md Phase 4, "The judge for
3
+ * paraphrase"). Three questions, in the order they would be asked: what it costs to load and hold
4
+ * (especially next to a resident EG2), how long a ≤5-pair batch takes on the write path, and what it
5
+ * actually decides on a small labelled pair set — against the token rule that already ships.
6
+ *
7
+ * Run: `npx tsx scripts/benchmark-nli.ts [--model <id>] [--dtype q8|fp32] [--no-eg2]`
8
+ *
9
+ * This is a measurement, not a feature: nothing here is imported by src/**, and no verdict in the
10
+ * store changes because of it. The pairs are the seed of the corpus the plan asks for; they are
11
+ * written by hand, so they show what the rule and the model do, not what they score in production.
12
+ */
13
+ import { readdirSync, statSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { AutoModelForSequenceClassification, AutoTokenizer } from "@huggingface/transformers";
16
+ import { flypiPaths } from "../src/agent/paths.ts";
17
+ import { cosine, createEmbedder, type Embedder } from "../src/memory/embed.ts";
18
+ import { EMBED_MODEL, JUDGE_MODEL } from "../src/memory/config.ts";
19
+ import { CONTRADICTION_SIMILARITY, chooseVerdict, verdictFor } from "../src/memory/judge.ts";
20
+ import { differentKey, valueSwap } from "../src/memory/store.ts";
21
+
22
+ const args = process.argv.slice(2);
23
+ const flag = (name: string, fallback: string) => {
24
+ const i = args.indexOf(`--${name}`);
25
+ return i >= 0 ? (args[i + 1] ?? fallback) : fallback;
26
+ };
27
+ const absent = (name: string) => !args.includes(`--${name}`);
28
+ const MODEL = flag("model", JUDGE_MODEL);
29
+ const DTYPE = flag("dtype", "q8") as "q8" | "fp32";
30
+ const MAX_LENGTH = Number(flag("max-length", "256"));
31
+ /** Where a model may retire a fact. Shipped as `CONTRADICTION_SIMILARITY`; overridable here because the
32
+ * right value is a property of the corpus, not of the model, and the corpus is meant to grow. */
33
+ const FLOOR = Number(flag("floor", String(CONTRADICTION_SIMILARITY)));
34
+ const cacheDir = flypiPaths(process.cwd()).modelsDir;
35
+
36
+ /** What the store would do with this pair, in the order it does it. */
37
+ type Decision = "contradict" | "duplicate" | "extend" | "new";
38
+ interface Pair {
39
+ stored: string;
40
+ incoming: string;
41
+ expect: Decision;
42
+ why: string;
43
+ }
44
+
45
+ /**
46
+ * Hand-written, one per class the write gate has to tell apart. The `…value` rows are the class the
47
+ * token rule already decides; the `…paraphrase` rows are why a model is being considered at all — the
48
+ * rule declines every one of them (asserted in tests/memory/store.test.ts).
49
+ */
50
+ const PAIRS: Pair[] = [
51
+ { stored: "the deploy port is 8080", incoming: "the deploy port is 9090", expect: "contradict", why: "port value" },
52
+ { stored: "the api url is https://api.internal/v1", incoming: "the api url is https://api.internal/v2", expect: "contradict", why: "version value" },
53
+ { stored: "the vault key lives in .flypi/secret.key", incoming: "the vault key lives in ~/.config/flypi/key", expect: "contradict", why: "path value" },
54
+ { stored: "the nightly build runs at 02:00 UTC", incoming: "the nightly build runs at 04:00 UTC", expect: "contradict", why: "time value" },
55
+ { stored: "the retention window is 30 days", incoming: "the retention window is 90 days", expect: "contradict", why: "number value" },
56
+ { stored: "the ci runner is ubuntu-latest", incoming: "the ci runner is macos-latest", expect: "contradict", why: "name value" },
57
+ { stored: "we use pnpm, never npm", incoming: "we do not use pnpm", expect: "contradict", why: "polarity flip" },
58
+ { stored: "prefer bun over pnpm in this repo", incoming: "we use pnpm, never npm", expect: "contradict", why: "paraphrase" },
59
+ { stored: "we use pnpm, never npm", incoming: "pnpm is not allowed in this repo", expect: "contradict", why: "paraphrase" },
60
+ { stored: "the deploy port is 8080", incoming: "the service listens on port 9090", expect: "contradict", why: "paraphrase" },
61
+ { stored: "the vault key lives in .flypi/secret.key", incoming: "the vault key path moved to the config dir", expect: "contradict", why: "paraphrase" },
62
+ { stored: "the deploy host is 10.0.0.5", incoming: "the deploy host is the bastion, not an ip", expect: "contradict", why: "paraphrase" },
63
+ { stored: "we use pnpm, never npm", incoming: "the project uses pnpm and not npm", expect: "duplicate", why: "reworded copy" },
64
+ { stored: "the deploy target is the small box", incoming: "deployments go to the small box", expect: "duplicate", why: "reworded copy" },
65
+ { stored: "the staging database is postgres 15", incoming: "staging runs postgres version 15", expect: "duplicate", why: "reworded copy" },
66
+ { stored: "the deploy target is the small box", incoming: "the deploy target is the small box running ubuntu 24.04", expect: "extend", why: "adds detail" },
67
+ { stored: "we use pnpm, never npm", incoming: "we use pnpm, never npm; commit the lockfile", expect: "extend", why: "adds detail" },
68
+ { stored: "the build runs npm test", incoming: "the build runs npm test, then lint, on ubuntu-latest", expect: "extend", why: "adds detail" },
69
+ { stored: "the deploy port is 8080", incoming: "the deploy host is 10.0.0.5", expect: "new", why: "different key" },
70
+ { stored: "the deploy port is 8080", incoming: "the wiki lives at wiki/ops", expect: "new", why: "unrelated" },
71
+ { stored: "the api base url is https://api.internal/v1", incoming: "the grafana dashboard is at grafana/internal/api", expect: "new", why: "shares words, not facts" },
72
+ ];
73
+
74
+ const onnxFiles = (dir: string) => {
75
+ const out: { file: string; bytes: number }[] = [];
76
+ const walk = (d: string) => {
77
+ for (const entry of readdirSync(d, { withFileTypes: true })) {
78
+ const path = join(d, entry.name);
79
+ if (entry.isDirectory()) walk(path);
80
+ else if (entry.name.endsWith(".onnx")) out.push({ file: entry.name, bytes: statSync(path).size });
81
+ }
82
+ };
83
+ try {
84
+ walk(dir);
85
+ } catch {
86
+ /* not downloaded */
87
+ }
88
+ return out.sort((a, b) => b.bytes - a.bytes);
89
+ };
90
+
91
+ const mb = (bytes: number) => `${(bytes / 1e6).toFixed(1)} MB`;
92
+ const rss = () => process.memoryUsage().rss;
93
+ const log = console.log;
94
+ /** A refused embedding prints as — in the table rather than pretending to be a number. */
95
+ const vectorOf = async (embedder: Embedder, text: string) => {
96
+ const result = await embedder.embedDocument(text);
97
+ return result.ok ? result.vector : null;
98
+ };
99
+
100
+ async function main() {
101
+ const baseline = rss();
102
+ const modelDir = join(cacheDir, ...MODEL.split("/"));
103
+ const before = onnxFiles(modelDir);
104
+ const t0 = Date.now();
105
+ const tokenizer = await AutoTokenizer.from_pretrained(MODEL, { cache_dir: cacheDir });
106
+ const model = await AutoModelForSequenceClassification.from_pretrained(MODEL, { cache_dir: cacheDir, dtype: DTYPE });
107
+ const loadMs = Date.now() - t0;
108
+ const after = onnxFiles(modelDir);
109
+ const downloaded = after.filter((f) => !before.some((b) => b.file === f.file)).reduce((sum, f) => sum + f.bytes, 0);
110
+ const labels: Record<string, string> = ((model.config ?? {}) as unknown as { id2label?: Record<string, string> }).id2label ?? {};
111
+
112
+ log(`model ${MODEL} (dtype ${DTYPE}, max_length ${MAX_LENGTH})`);
113
+ log(`rss ${(baseline / 1e6).toFixed(0)} MB before any weights (runtime + ort only)`);
114
+ log(`load ${loadMs} ms (tokenizer + weights); rss after load ${(rss() / 1e6).toFixed(0)} MB`);
115
+ log(`weights ${downloaded ? `downloaded ${mb(downloaded)} now` : "already in the cache"}; on disk ${mb(after.reduce((s, f) => s + f.bytes, 0))} in ${after.length} onnx files`);
116
+ log(`labels ${JSON.stringify(labels)}`);
117
+ if (!Object.keys(labels).length) {
118
+ log("FAILED the model carries no id2label — a verdict would be a guess about index order. Stop.");
119
+ return;
120
+ }
121
+
122
+ /** Model labels are not spelled consistently across checkpoints; map on the word, not the index. */
123
+ const classOf = (label: string) => {
124
+ const text = label.toLowerCase();
125
+ if (text.includes("contra")) return "contradiction";
126
+ if (text.includes(" entail")) return "entailment";
127
+ if (text.includes("neutral")) return "neutral";
128
+ return label;
129
+ };
130
+ const judge = async (pairs: [string, string][]) => {
131
+ // A cross-encoder reads (premise, hypothesis): the stored row is what we are checking the new
132
+ // sentence against. v4 takes the second sequence as an option, and `padding` lines the batch up.
133
+ const inputs = await tokenizer(pairs.map(([premise]) => premise), {
134
+ text_pair: pairs.map(([, hypothesis]) => hypothesis),
135
+ padding: true,
136
+ truncation: true,
137
+ max_length: MAX_LENGTH,
138
+ return_token_type_ids: true,
139
+ });
140
+ const output = await model(inputs);
141
+ const rows = (output.logits.tolist() as number[][]).map((logits) => {
142
+ const max = Math.max(...logits);
143
+ const weights = logits.map((value) => Math.exp(value - max));
144
+ const total = weights.reduce((sum, value) => sum + value, 0);
145
+ return weights.map((value) => value / total);
146
+ });
147
+ // The EG2 lesson: a wrong dtype can return NaN and still look like an answer.
148
+ if (rows.some((probs) => probs.some((value) => !Number.isFinite(value)))) log("WARN non-finite logits — this dtype is unusable");
149
+ return rows.map((probs) => {
150
+ const index = probs.indexOf(Math.max(...probs));
151
+ return { nli: classOf(labels[String(index)] ?? String(index)), confidence: probs[index] ?? 0, probs };
152
+ });
153
+ };
154
+
155
+ // Latency the way the write path would pay it: one pair, then a full batch of five candidates.
156
+ const one = await judge([[PAIRS[0]!.stored, PAIRS[0]!.incoming]]);
157
+ const singleMs = Date.now() - t0;
158
+ const tBatch = Date.now();
159
+ const batch = await judge(PAIRS.slice(0, 5).map((pair) => [pair.stored, pair.incoming] as [string, string]));
160
+ const batchMs = Date.now() - tBatch;
161
+ log(`latency one pair ~${Math.round((singleMs - loadMs) / one.length)} ms; five pairs in one pass ${batchMs} ms (${batch.length} rows) — the first call includes warm-up`);
162
+
163
+ // The whole point of the plan's risk 3: this would live next to a resident EG2, not alone.
164
+ let embed: Awaited<ReturnType<typeof createEmbedder>> | null = null;
165
+ if (absent("no-eg2")) {
166
+ const beforeEg2 = rss();
167
+ embed = await createEmbedder({ provider: "transformers", model: EMBED_MODEL, quantized: true, cacheDir });
168
+ log(`rss with EG2 loading on top: ${(beforeEg2 / 1e6).toFixed(0)} MB → ${(rss() / 1e6).toFixed(0)} MB (${embed.identity ? "loaded" : `refused: ${embed.error}`})`);
169
+ }
170
+
171
+ const scores: Record<Decision, { right: number; wrong: number }> = { contradict: { right: 0, wrong: 0 }, duplicate: { right: 0, wrong: 0 }, extend: { right: 0, wrong: 0 }, new: { right: 0, wrong: 0 } };
172
+ log(`\nexpected → what the store would do (rule first, then the model) [C=conf]\n`);
173
+ const measured: { pair: Pair; cos: number | null; swap: boolean; key: boolean; was: number; now: number; nli: string; conf: number }[] = [];
174
+ for (const pair of PAIRS) {
175
+ const verdict = (await judge([[pair.stored, pair.incoming]]))[0]!;
176
+ const swap = valueSwap(pair.incoming, pair.stored);
177
+ const [storedVector, incomingVector] = embed?.identity
178
+ ? await Promise.all([vectorOf(embed, pair.stored), vectorOf(embed, pair.incoming)])
179
+ : [null, null];
180
+ const similarity = storedVector && incomingVector ? cosine(storedVector, incomingVector) : null;
181
+ measured.push({ pair, cos: similarity, swap: swap !== undefined, key: differentKey(pair.incoming, pair.stored), was: swap?.was.length ?? 0, now: swap?.now.length ?? 0, nli: verdict.nli, conf: verdict.confidence });
182
+ const decided: Decision = swap
183
+ ? "contradict"
184
+ : verdict.nli === "contradiction"
185
+ ? "contradict"
186
+ : verdict.nli === "entailment"
187
+ ? (similarity ?? 0) >= 0.95
188
+ ? "duplicate"
189
+ : "extend"
190
+ : (similarity ?? 0) >= 0.95
191
+ ? "extend"
192
+ : "new";
193
+ const ok = decided === pair.expect;
194
+ scores[pair.expect][ok ? "right" : "wrong"] += 1;
195
+ log(
196
+ `${ok ? "ok " : "MISS"} ${pair.why.padEnd(24)} cos ${similarity === null ? " — " : similarity.toFixed(4)} rule ${swap ? `${swap.was.length}/${swap.now.length} tokens` : "none "} nli ${verdict.nli.padEnd(13)} [${verdict.confidence.toFixed(2)}] → ${decided.padEnd(10)} expected ${pair.expect}`,
197
+ );
198
+ }
199
+
200
+ log("\nby class:");
201
+ for (const [decision, score] of Object.entries(scores)) log(` ${decision.padEnd(11)} ${score.right} right, ${score.wrong} wrong`);
202
+
203
+ /**
204
+ * The number that decides anything is not accuracy: it is how many true facts get retired that should
205
+ * not be (a silent rewrite of history) against how many corrections get missed (recoverable — the old
206
+ * row stays active and recall de-dupes at read time). `extend` and `new` are the same action, so they
207
+ * are scored as one: keep both rows.
208
+ */
209
+ const actionOf = (row: (typeof measured)[number], policy: (row: (typeof measured)[number]) => Decision): "supersede" | "refuse" | "keep" => {
210
+ const decided = policy(row);
211
+ return decided === "contradict" ? "supersede" : decided === "duplicate" ? "refuse" : "keep";
212
+ };
213
+ const wanted = (pair: Pair) => (pair.expect === "contradict" ? "supersede" : pair.expect === "duplicate" ? "refuse" : "keep");
214
+ /** One answer for one pair, exactly as the store would order it, at a given floor. */
215
+ function shippedPolicy(floor: number): (row: (typeof measured)[number]) => Decision {
216
+ return (row) => {
217
+ if ((row.cos ?? 0) >= 0.95 && !row.swap) return "duplicate"; // the write gate, no model asked
218
+ const label = row.nli as "contradiction" | "entailment" | "neutral";
219
+ const verdict = chooseVerdict([{ id: 1, verdict: verdictFor(label, row.conf, row.cos ?? 0, floor) }]).verdict;
220
+ if (verdict === "duplicate") return "duplicate";
221
+ if (verdict === "extend") return "new"; // the model read it as the same fact in more words
222
+ if (verdict === "contradict" && !row.key) return "contradict"; // the frame test vetoes a different-key pair
223
+ return row.swap ? "contradict" : "new";
224
+ };
225
+ }
226
+
227
+ const policies: Record<string, (row: (typeof measured)[number]) => Decision> = {
228
+ /** What `src/memory/judge.ts` + `addMemory` do now, in code rather than by hand. */
229
+ "shipped policy": shippedPolicy(FLOOR),
230
+ "no judge configured": (row) => (row.swap ? "contradict" : (row.cos ?? 0) >= 0.95 ? "duplicate" : "new"),
231
+ /** The same policy with the deterministic frame veto removed, so a re-run shows what that guard buys. */
232
+ "the judge without the frame veto": (row) => {
233
+ if ((row.cos ?? 0) >= 0.95 && !row.swap) return "duplicate";
234
+ const label = row.nli as "contradiction" | "entailment" | "neutral";
235
+ const verdict = chooseVerdict([{ id: 1, verdict: verdictFor(label, row.conf, row.cos ?? 0, FLOOR) }]).verdict;
236
+ if (verdict === "duplicate" || verdict === "contradict") return verdict;
237
+ return row.swap && verdict !== "extend" ? "contradict" : "new";
238
+ },
239
+ };
240
+ log(`\npolicy comparison over the same pairs, similarity floor ${FLOOR.toFixed(2)} (false supersede = a true fact retired for no reason):\n`);
241
+ const tally = (policy: (row: (typeof measured)[number]) => Decision) => {
242
+ const wrong = { superseded: 0, missed: 0, refused: 0, missedCopy: 0 };
243
+ const misses: string[] = [];
244
+ for (const row of measured) {
245
+ const got = actionOf(row, policy);
246
+ const want = wanted(row.pair);
247
+ if (got === want) continue;
248
+ if (got === "supersede") wrong.superseded += 1; // retired something that should have stayed (a copy or a different fact)
249
+ if (want === "supersede") wrong.missed += 1;
250
+ if (want === "refuse" && got === "keep") wrong.missedCopy += 1;
251
+ if (got === "supersede" || want === "supersede") misses.push(` ${row.pair.why} (cos ${row.cos === null ? "—" : row.cos.toFixed(4)}, ${row.nli} ${row.conf.toFixed(2)})`);
252
+ }
253
+ return { ...wrong, misses };
254
+ };
255
+ for (const [name, policy] of Object.entries(policies)) {
256
+ const wrong = tally(policy);
257
+ log(` ${name}`);
258
+ log(` ${wrong.superseded} false supersede, ${wrong.missed} missed correction, ${wrong.missedCopy} copy written as a new row`);
259
+ if (wrong.misses.length) log(wrong.misses.join("\n"));
260
+ }
261
+ // The floor is the open question, so it is measured rather than argued: `--sweep` re-runs the shipped
262
+ // policy at each value over the same pairs, with no model asked twice.
263
+ if (args.includes("--sweep")) {
264
+ log("\nthe shipped policy, floor by floor:");
265
+ for (const floor of (flag("sweep", "0.95,0.9,0.85,0.8,0.7")).split(",").map(Number)) {
266
+ const wrong = tally(shippedPolicy(floor));
267
+ log(` floor ${floor.toFixed(2)}: ${wrong.superseded} false supersede, ${wrong.missed} missed correction, ${wrong.missedCopy} copy written as a new row`);
268
+ }
269
+ }
270
+ log(`\npeak rss ${(rss() / 1e6).toFixed(0)} MB`);
271
+ }
272
+
273
+ await main();