@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,167 @@
1
+ /**
2
+ * The contradiction judge (docs/memory.md Phase 4): a small local NLI cross-encoder over the rows a
3
+ * write is already near, so "same fact, new value" and "same fact, new words" stop being a cosine
4
+ * question. Measured (`scripts/benchmark-nli.ts`, 2026-10-11): no similarity threshold separates them —
5
+ * the four real corrections sat at 0.95–0.98 while reworded copies sat at 0.96–0.97, and the
6
+ * paraphrased corrections reached only 0.83–0.91.
7
+ *
8
+ * It is deliberately not a chat model. It takes no prompt, produces no prose, needs no credential,
9
+ * cannot spend a turn's token budget, and answers in one batched pass: 87 MB of q8 weights, ~6 ms per
10
+ * pair. The reasoning for that choice, and what the model gets wrong, is in docs/decisions.md.
11
+ */
12
+ import type { JudgeConfig } from "./config.ts";
13
+ import type { ModelProgress } from "./progress.ts";
14
+ import { NEAR_DUPLICATE } from "./store.ts";
15
+ import type { ContradictionCandidate, ContradictionClassifier, ContradictionVerdict } from "./store.ts";
16
+
17
+ /** The model has 512 positions; 256 is far inside it and inside any memory body. A pair that still
18
+ * reaches the window gets no opinion — a judgment made over a truncated sentence is not a judgment. */
19
+ const MAX_PAIR_TOKENS = 256;
20
+
21
+ /**
22
+ * The judge's own blind side, measured: it answers `contradiction` at 0.92–1.00 for rows that are simply
23
+ * about different things ("the deploy port is 8080" against "the deploy host is 10.0.0.5", cosine 0.8990;
24
+ * two unrelated rows at 0.7434). NLI asks whether one sentence follows from another; "these are different
25
+ * facts" is not that question, and the token frame in store.ts is what answers it.
26
+ *
27
+ * So two guards, and the floor only sits where it does because of the second one. Measured over the same
28
+ * 21 pairs at each floor (false supersede / missed correction) — `scripts/benchmark-nli.ts --sweep`:
29
+ * 0.95 → 0/5, 0.90 → 0/4, **0.85 → 0/3**, 0.80 → 0/2, 0.70 → 1/2, where the 0.70 miss is an unrelated
30
+ * pair at 0.7434 that no guard can save because two sentences about different subjects share no frame.
31
+ * Without the frame guard, 0.85 measures 1/1: the different-key pair at 0.8990 gets retired. 0.85 is the
32
+ * lowest floor that keeps the different-key and unrelated pairs out on this set. Where it sits is a
33
+ * property of the corpus, not of the model — the paraphrase corrections this is meant to catch measure
34
+ * 0.8312–0.9114, so the band between 0.80 and 0.85 is the argument, and `--floor`/`--sweep` re-answer it
35
+ * against whatever the corpus has become rather than against this comment.
36
+ */
37
+ export const CONTRADICTION_SIMILARITY = 0.85;
38
+
39
+ /** Below this the model is not trusted to retire a fact. A missed contradiction is recoverable — the old
40
+ * row stays active and recall de-dupes at read time — while a false one silently rewrites history. */
41
+ export const CONTRADICTION_CONFIDENCE = 0.8;
42
+
43
+ export type NliLabel = "contradiction" | "entailment" | "neutral";
44
+
45
+ /** Checkpoints disagree about which index is which label, so the name is the only safe key. */
46
+ export function labelFor(id2label: Record<string, string> | undefined, index: number): NliLabel | undefined {
47
+ const name = id2label?.[String(index)];
48
+ if (!name) return undefined;
49
+ const lower = name.toLowerCase();
50
+ if (lower.includes("contra")) return "contradiction";
51
+ if (lower.includes("entail")) return "entailment";
52
+ if (lower.includes("neutral")) return "neutral";
53
+ return undefined;
54
+ }
55
+
56
+ export function softmax(logits: number[]): number[] {
57
+ const max = Math.max(...logits);
58
+ const weights = logits.map((value) => Math.exp(value - max));
59
+ const total = weights.reduce((sum, value) => sum + value, 0);
60
+ return weights.map((value) => value / total);
61
+ }
62
+
63
+ /**
64
+ * NLI labels to the store's own vocabulary — no new verdicts, because the write gate already speaks it.
65
+ * `extend` is not decoration here: it is the model saying "this is the same fact, said in more words",
66
+ * which is what vetoes the token rule for a reworded copy (the one class the rule got wrong).
67
+ */
68
+ export function verdictFor(
69
+ label: NliLabel,
70
+ confidence: number,
71
+ similarity: number,
72
+ floor: number = CONTRADICTION_SIMILARITY,
73
+ ): ContradictionVerdict {
74
+ const near = similarity >= NEAR_DUPLICATE;
75
+ if (label === "contradiction" && similarity >= floor && confidence >= CONTRADICTION_CONFIDENCE) {
76
+ return "contradict";
77
+ }
78
+ if (label === "entailment") return near ? "duplicate" : "extend";
79
+ return near ? "extend" : "new";
80
+ }
81
+
82
+ /** One answer for the whole write: a real contradiction outranks a restatement, which outranks a detail. */
83
+ export function chooseVerdict(
84
+ scored: { id: number; verdict: ContradictionVerdict }[],
85
+ ): { verdict: ContradictionVerdict; target?: number } {
86
+ const rank: Record<ContradictionVerdict, number> = { new: 0, extend: 1, duplicate: 2, contradict: 3 };
87
+ let best: { id: number; verdict: ContradictionVerdict } | undefined;
88
+ for (const row of scored) if (!best || rank[row.verdict] > rank[best.verdict]) best = row;
89
+ return best ? { verdict: best.verdict, target: best.id } : { verdict: "new" };
90
+ }
91
+
92
+ /**
93
+ * Load the judge, or say why there isn't one — the same shape as `createEmbedder`, because a store with
94
+ * no judge is a supported state and not a failure (risk 8). Nothing about it is optional at the call
95
+ * site: the row is written whatever the judge does.
96
+ */
97
+ export async function createJudge(config: JudgeConfig, onProgress?: (progress: ModelProgress) => void): Promise<{ classify?: ContradictionClassifier; error?: string }> {
98
+ if (config.provider === "none") return {};
99
+ try {
100
+ // Lazy, like the embedder: this is ~300 MB of runtime, not something a keyword-only store needs.
101
+ const { AutoModelForSequenceClassification, AutoTokenizer } = await import("@huggingface/transformers");
102
+ const tokenizer = await AutoTokenizer.from_pretrained(config.model, { cache_dir: config.cacheDir, ...(onProgress ? { progress_callback: onProgress } : {}) });
103
+ // q8, measured: fp16 loads for this checkpoint (finite logits) but is 60% larger and twice the
104
+ // latency, so there is no reason to ask for it.
105
+ const model = await AutoModelForSequenceClassification.from_pretrained(config.model, {
106
+ cache_dir: config.cacheDir,
107
+ dtype: config.quantized ? "q8" : "fp32",
108
+ ...(onProgress ? { progress_callback: onProgress } : {}),
109
+ });
110
+ const labels = (model.config as { id2label?: Record<string, string> } | undefined)?.id2label;
111
+ if (!labels) return { error: `${config.model} declares no labels — no contradiction judgment` };
112
+ const classify = await nliClassifier(tokenizer, model, labels);
113
+ return { classify };
114
+ } catch (error) {
115
+ return { error: `no contradiction judgment: ${(error as Error).message}` };
116
+ }
117
+ }
118
+
119
+ /** The adapter boundary. transformers.js v4 hands back a `BatchEncoding`, whose shape is its own; only
120
+ * the two fields this code reads are named, and everything else is passed through untouched. */
121
+ interface Encoding {
122
+ attention_mask?: { tolist(): unknown };
123
+ [key: string]: unknown;
124
+ }
125
+ type Tokenizer = (
126
+ texts: string[],
127
+ options: { text_pair: string[]; padding: boolean; truncation: boolean; max_length: number; return_token_type_ids?: boolean },
128
+ ) => Encoding | Promise<Encoding>;
129
+ type Classifier = (inputs: Encoding) => Promise<{ logits: { tolist(): unknown } }>;
130
+
131
+ /** Exported for the test: this is the part that talks to a model, and it is testable without one. */
132
+ export async function nliClassifier(
133
+ tokenizer: Tokenizer,
134
+ model: Classifier,
135
+ labels: Record<string, string>,
136
+ ): Promise<ContradictionClassifier> {
137
+ return async (body: string, candidates: ContradictionCandidate[]) => {
138
+ // The stored row is the premise: the question is whether the new sentence contradicts what is
139
+ // already true, not the other way round. One batched pass, one pass per write (never recursive).
140
+ const premises = candidates.map((candidate) => candidate.body);
141
+ const inputs = await tokenizer(premises, {
142
+ text_pair: candidates.map(() => body),
143
+ padding: true,
144
+ truncation: true,
145
+ max_length: MAX_PAIR_TOKENS,
146
+ return_token_type_ids: true,
147
+ });
148
+ const masks = (inputs.attention_mask?.tolist() ?? []) as unknown[][];
149
+ // int64 tensors come back as BigInt in onnxruntime-web, and `0 + 1n` throws — which would look
150
+ // exactly like a judge that never answered.
151
+ const truncated = masks.map((row) => row.reduce<number>((sum, value) => sum + Number(value), 0) >= MAX_PAIR_TOKENS);
152
+ const output = await model(inputs);
153
+ const logits = (output.logits.tolist() as unknown[][]).map((row) => row.map(Number));
154
+ const scored: { id: number; verdict: ContradictionVerdict }[] = [];
155
+ logits.forEach((row, index) => {
156
+ const candidate = candidates[index];
157
+ if (!candidate || truncated[index]) return; // no opinion about a pair we could not read whole
158
+ const probabilities = softmax(row);
159
+ if (probabilities.some((value) => !Number.isFinite(value))) return; // a dtype that returns NaN is not an answer
160
+ const top = probabilities.indexOf(Math.max(...probabilities));
161
+ const label = labelFor(labels, top);
162
+ if (!label) return;
163
+ scored.push({ id: candidate.id, verdict: verdictFor(label, probabilities[top] ?? 0, candidate.score) });
164
+ });
165
+ return chooseVerdict(scored);
166
+ };
167
+ }
@@ -0,0 +1,496 @@
1
+ /**
2
+ * The memory store as an MCP server over HTTP (Streamable HTTP), so anything that speaks MCP —
3
+ * pi's own `mcp.json` in Phase 5, another agent, a script — reaches one shared store instead of
4
+ * keeping a private copy of the facts.
5
+ *
6
+ * Deliberately stateless per request: no session id, no server→client stream. flypi's own agent
7
+ * wiring stays in-process and shares the SQLite handle (docs/memory.md Phase 4, risk 3); this is the
8
+ * surface for everyone else, so it must not become a second writer.
9
+ *
10
+ * Provenance is not a tool argument. `origin_class`/`session_kind` are set by the mount (the
11
+ * principal the caller authenticated as), because "the user said so" inside a body a model wrote
12
+ * carries no authority (docs/memory.md Phase 4).
13
+ */
14
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
15
+ import { randomUUID } from "node:crypto";
16
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
17
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
18
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
19
+ import { Type, type Static } from "typebox";
20
+ import { Check, Errors } from "typebox/value";
21
+ import { resolve } from "node:path";
22
+ import { readMedia } from "./media.ts";
23
+ import { containsSecret, redactSecrets } from "../secrets/redact.ts";
24
+ import type { OriginClass, MemoryStore, SessionKind } from "./store.ts";
25
+
26
+ const SERVER_INFO = { name: "flypi-memory", version: "0.1.0" } as const;
27
+
28
+ const Body = Type.String({ minLength: 1, maxLength: 8000 });
29
+ const Kind = Type.String({ minLength: 1, maxLength: 60 });
30
+ const Id = Type.Integer({ minimum: 1 });
31
+ const Tags = Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 60 }), { maxItems: 20 }));
32
+ const Reason = Type.Optional(Type.String({ maxLength: 500 }));
33
+ /** A file the *server* can read: this is a loopback daemon on the same machine, so a path beats
34
+ * megabytes of base64 in a tool argument. `memory_media` is how the bytes come back. */
35
+ const MediaPath = Type.Optional(Type.String({ minLength: 1, maxLength: 4096 }));
36
+ const Ids = Type.Array(Id, { minItems: 1, maxItems: 20 });
37
+ /** A turn may cite nothing; an empty list is the answer, not a missing argument. */
38
+ const IdList = Type.Optional(Type.Array(Id, { maxItems: 20 }));
39
+ /**
40
+ * What the provider reported for the turn — input, cache reads and writes, summed across its model
41
+ * calls by flypi. A model is never asked for these: it was never told them, so anything it produced
42
+ * would be invented (docs/prompt-cache.md Phase 4.6 A1).
43
+ */
44
+ const TurnUsage = Type.Optional(
45
+ Type.Object({
46
+ input: Type.Number({ minimum: 0 }),
47
+ output: Type.Number({ minimum: 0 }),
48
+ cacheRead: Type.Number({ minimum: 0 }),
49
+ cacheWrite: Type.Number({ minimum: 0 }),
50
+ totalTokens: Type.Number({ minimum: 0 }),
51
+ cost: Type.Number({ minimum: 0 }),
52
+ }),
53
+ );
54
+
55
+ const TOOL_MODELS = {
56
+ memory_search: Type.Object({
57
+ query: Type.String({ minLength: 1, maxLength: 500 }),
58
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 20, default: 5 })),
59
+ /** Hard cap on the text returned; a memory is dropped whole, never cut mid-sentence. */
60
+ max_tokens: Type.Optional(Type.Integer({ minimum: 64, maximum: 8000 })),
61
+ /**
62
+ * Only what automatic injection may use (facts the user stated or this agent wrote). flypi's own
63
+ * per-turn recall sets it — a fact from an untrusted source must never reach a prompt nobody asked
64
+ * for. Left out, the search covers everything, including what flypi would not inject on its own.
65
+ */
66
+ injectable_only: Type.Optional(Type.Boolean()),
67
+ }),
68
+ memory_write: Type.Object({ body: Body, kind: Kind, tags: Tags, reason: Reason, media_path: MediaPath }),
69
+ /**
70
+ * flypi's own heuristic capture (docs/memory.md Phase 4). A separate tool rather than `memory_write`
71
+ * because the text came from the user, not from the model: this is the one place a caller's words carry
72
+ * `owner` authority, and it is the mount that says so, not an argument.
73
+ */
74
+ memory_capture: Type.Object({ body: Body, kind: Kind, tags: Tags, reason: Reason }),
75
+ /**
76
+ * The turn just run: what was injected and what the reply cited. flypi calls this after a turn — the
77
+ * memory server is the only writer, so this is how a turn of a conversation reaches SQLite (Phase 4).
78
+ */
79
+ memory_turn: Type.Object({
80
+ conversation_id: Type.String({ minLength: 1, maxLength: 64 }),
81
+ cwd: Type.Optional(Type.String({ minLength: 1, maxLength: 4096 })),
82
+ user_text: Body,
83
+ assistant_text: Type.Optional(Body),
84
+ retrieved: IdList,
85
+ cited: IdList,
86
+ usage: TurnUsage,
87
+ }),
88
+ memory_read: Type.Object({ id: Id }),
89
+ /**
90
+ * Which rows a turn actually used. flypi calls this after a turn, with the `#id`s its own reply
91
+ * contained — the model is not asked to grade itself, so a citation cannot be self-reported.
92
+ */
93
+ memory_cite: Type.Object({ ids: Ids }),
94
+ /** Insert the replacement and retire the old row in one call — a replacement is never silent. */
95
+ memory_supersede: Type.Object({ id: Id, body: Body, kind: Kind, tags: Tags, reason: Reason, media_path: MediaPath }),
96
+ memory_forget: Type.Object({ id: Id, reason: Reason }),
97
+ /** The image or recording of a memory, as base64, for whoever has to show or play it. */
98
+ memory_media: Type.Object({ id: Id }),
99
+ memory_index_status: Type.Object({}),
100
+ } as const;
101
+
102
+ const TOOL_NAMES = Object.keys(TOOL_MODELS) as (keyof typeof TOOL_MODELS)[];
103
+
104
+ type SearchArgs = Static<(typeof TOOL_MODELS)["memory_search"]>;
105
+ type WriteArgs = Static<(typeof TOOL_MODELS)["memory_write"]>;
106
+ type IdArgs = Static<(typeof TOOL_MODELS)["memory_read"]>;
107
+ type CiteArgs = Static<(typeof TOOL_MODELS)["memory_cite"]>;
108
+ type CaptureArgs = Static<(typeof TOOL_MODELS)["memory_capture"]>;
109
+ type TurnArgs = Static<(typeof TOOL_MODELS)["memory_turn"]>;
110
+ type SupersedeArgs = Static<(typeof TOOL_MODELS)["memory_supersede"]>;
111
+ type ForgetArgs = Static<(typeof TOOL_MODELS)["memory_forget"]>;
112
+ type MediaArgs = Static<(typeof TOOL_MODELS)["memory_media"]>;
113
+
114
+ /** read-only/destructive hints are set for the Phase 5 permission gate to reuse, not for show. */
115
+ const TOOL_META: Record<(keyof typeof TOOL_MODELS), { description: string; annotations: { readOnlyHint: boolean; destructiveHint: boolean } }> = {
116
+ memory_search: {
117
+ description:
118
+ "Search stored memories by keyword and (when embeddings are configured) by meaning. Text, images and audio share one embedding space, so a typed query can find a recording or a picture. Returns #id, body, kind, tags, modality and the row's origin_class — where the fact came from, which is set by code and not by the text itself. Every row is searchable here; flypi's own automatic recall asks with injectable_only, so pass it when you want only what flypi trusts enough to put in front of a model by itself.",
119
+ annotations: { readOnlyHint: true, destructiveHint: false },
120
+ },
121
+ memory_write: {
122
+ description:
123
+ "Store one durable fact. Write what outlives this conversation; quote it as #id when you use it. A fact that contradicts one already stored replaces it, and the answer names the row it retired. For an image or a recording, pass media_path (a file this server can read: png/jpeg/webp/gif, or wav) and write one line describing it — that line is what keyword search sees.",
124
+ annotations: { readOnlyHint: false, destructiveHint: false },
125
+ },
126
+ memory_capture: {
127
+ description:
128
+ "Store a durable fact taken from the user's own words in this turn. flypi's automatic capture calls this itself after a turn — you never need to, and using it for something you inferred yourself would claim an authority the sentence does not have. For your own inference, use memory_write.",
129
+ annotations: { readOnlyHint: false, destructiveHint: false },
130
+ },
131
+ memory_turn: {
132
+ description:
133
+ "Record one turn of a conversation: what was retrieved for it and what its reply cited by #id, and what the provider reported it cost. flypi calls this itself after every turn; a model that reports its own hits is grading its own homework.",
134
+ annotations: { readOnlyHint: false, destructiveHint: false },
135
+ },
136
+ memory_read: {
137
+ description: "Read one memory in full, with its audit history (who wrote it, what replaced it).",
138
+ annotations: { readOnlyHint: true, destructiveHint: false },
139
+ },
140
+ memory_cite: {
141
+ description:
142
+ "Record which memories a turn cited by #id, so the store learns which facts earned their place. flypi calls this itself after a turn — you never need to, and calling it for a fact you did not use would make the count mean nothing.",
143
+ annotations: { readOnlyHint: false, destructiveHint: false },
144
+ },
145
+ memory_supersede: {
146
+ description: "Replace a memory that is no longer true: the old row stays as superseded, the new one takes its place.",
147
+ annotations: { readOnlyHint: false, destructiveHint: true },
148
+ },
149
+ memory_forget: {
150
+ description: "Delete a memory. Only when the user asked for it — a fact that is merely unused is not wrong.",
151
+ annotations: { readOnlyHint: false, destructiveHint: true },
152
+ },
153
+ memory_media: {
154
+ description: "Read the bytes of a memory's image or recording (base64, with its mime type).",
155
+ annotations: { readOnlyHint: true, destructiveHint: false },
156
+ },
157
+ memory_index_status: {
158
+ description: "Report whether the stored vectors all belong to the configured embedding model (a mismatch queues a re-embed).",
159
+ annotations: { readOnlyHint: true, destructiveHint: false },
160
+ },
161
+ };
162
+
163
+ export interface MemoryMcpOptions {
164
+ principal?: string;
165
+ origin_class?: OriginClass;
166
+ session_kind?: SessionKind;
167
+ }
168
+
169
+ /**
170
+ * What an unattended writer may not do (docs/memory.md Phase 4.8, and the one `if` docs/testing.md names as a
171
+ * 100%-coverage choke point). A review fork runs with nobody reading it, so it may *add* a fact and nothing
172
+ * else: `memory_supersede` retires a row a human may be relying on, `memory_forget` deletes one, and
173
+ * `memory_capture` mints `owner` — the authority only the user's own words carry, which is why it is not a
174
+ * tool argument anywhere. Nothing auto-deletes is the project invariant; this is that invariant applied to the
175
+ * one writer nobody is watching.
176
+ */
177
+ const UNATTENDED_FORBIDDEN: readonly string[] = ["memory_capture", "memory_supersede", "memory_forget"];
178
+
179
+ /**
180
+ * The mounts one memory server answers. Provenance is set by which mount a caller reached, never by what the
181
+ * caller's body claims (the header comment): a fork's session is pointed at `/mcp/background` by flypi's own
182
+ * code, so `session_kind='background'` cannot be claimed — or lost — by a model editing its arguments.
183
+ */
184
+ const MOUNTS: Record<string, MemoryMcpOptions> = {
185
+ "/mcp": {},
186
+ "/mcp/background": { session_kind: "background", principal: "fork" },
187
+ };
188
+
189
+ /** The MCP half of the surface: tool definitions plus their calls, with no transport in sight. */
190
+ export function createMemoryMcpServer(store: MemoryStore, options: MemoryMcpOptions = {}): Server {
191
+ const actor = options.principal ?? "mcp";
192
+ const originClass = options.origin_class ?? "agent";
193
+ const sessionKind = options.session_kind ?? "interactive";
194
+
195
+ const server = new Server(SERVER_INFO, { capabilities: { tools: {} } });
196
+
197
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
198
+ tools: TOOL_NAMES.map((name) => ({
199
+ name,
200
+ description: TOOL_META[name].description,
201
+ inputSchema: TOOL_MODELS[name] as unknown as Record<string, unknown>,
202
+ annotations: TOOL_META[name].annotations,
203
+ })),
204
+ }));
205
+
206
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
207
+ const name = request.params.name as keyof typeof TOOL_MODELS;
208
+ const model = TOOL_MODELS[name];
209
+ if (!model) return text(`unknown tool: ${request.params.name}`, true);
210
+ if (sessionKind === "background" && UNATTENDED_FORBIDDEN.includes(name)) {
211
+ return text(
212
+ {
213
+ error:
214
+ `${name} is not available to a background writer: an unattended run may add a memory with memory_write, and may not supersede, forget or claim the user's own words for one. ` +
215
+ "Say what should change instead, and let the user or the agent in a live conversation do it.",
216
+ },
217
+ true,
218
+ );
219
+ }
220
+ const params = (request.params.arguments ?? {}) as Record<string, unknown>;
221
+ if (!Check(model, params)) {
222
+ const why = [...Errors(model, params)].map((error) => `${error.instancePath || "/"}: ${error.message}`);
223
+ return text({ error: `invalid arguments: ${why.join("; ")}` }, true);
224
+ }
225
+ try {
226
+ switch (name) {
227
+ case "memory_search": {
228
+ const args = params as SearchArgs;
229
+ const result = await store.search({
230
+ query: args.query,
231
+ limit: args.limit ?? 5,
232
+ ...(args.max_tokens === undefined ? {} : { maxTokens: args.max_tokens }),
233
+ ...(args.injectable_only ? { injectableOnly: true } : {}),
234
+ });
235
+ // `partial` is in the payload, not hidden: keyword-only results must say so (risk 8).
236
+ return text({
237
+ ...(result.partial ? { partial: true, ...(result.note ? { note: result.note } : {}) } : {}),
238
+ results: result.results.map((row) => ({
239
+ id: row.id,
240
+ kind: row.kind,
241
+ body: row.body,
242
+ tags: row.tags,
243
+ modality: row.modality,
244
+ ...(row.media ? { media: { kind: row.media.kind, mime: row.media.mime, bytes: row.media.bytes_size } } : {}),
245
+ score: Number(row.score.toFixed(6)),
246
+ matched_by: row.matched_by,
247
+ // Provenance travels with the row: authority is code's, so a caller has to be able to
248
+ // see it rather than believe what the body claims about itself.
249
+ origin_class: row.origin_class,
250
+ use_count: row.use_count,
251
+ created_at: row.created_at,
252
+ })),
253
+ });
254
+ }
255
+ case "memory_write": {
256
+ const args = params as WriteArgs;
257
+ const leaked = secretInBody(args.body);
258
+ if (leaked) return leaked;
259
+ const written = await store.addMemory(await provenance(args, actor, originClass, sessionKind));
260
+ // A repeat write says so instead of quietly adding a second copy of the fact, and a row
261
+ // the embedder refused says why instead of pretending it is searchable by meaning.
262
+ return text({
263
+ id: written.id,
264
+ duplicate_of: written.duplicateOf,
265
+ // Naming what it replaced is the only way the model can say "I changed the earlier fact".
266
+ ...(written.contradicted ? { contradicted: written.contradicted } : {}),
267
+ ...(written.notIndexed ? { indexed: false, note: written.notIndexed } : {}),
268
+ // A write that nothing judged is stated the same way: the fact landed, and the reader
269
+ // knows the contradiction question went unanswered.
270
+ ...(written.notJudged ? { judged: false, judgment_note: written.notJudged } : {}),
271
+ });
272
+ }
273
+ case "memory_cite": {
274
+ const args = params as CiteArgs;
275
+ // An id that does not exist is not a citation, and the answer says which ones counted: the
276
+ // caller is reporting what a model wrote, and models invent numbers.
277
+ return text({ cited: args.ids.filter((id) => store.markCited(id)) });
278
+ }
279
+ case "memory_capture": {
280
+ const args = params as CaptureArgs;
281
+ const leaked = secretInBody(args.body);
282
+ if (leaked) return leaked;
283
+ // The name in the audit row says where the write came from, because "who wrote this fact" is
284
+ // the question an audit row exists to answer.
285
+ const written = await store.addMemory({
286
+ body: args.body,
287
+ kind: args.kind,
288
+ tags: args.tags ?? [],
289
+ reason: args.reason,
290
+ actor: `${actor}:capture`,
291
+ origin_class: "owner",
292
+ session_kind: sessionKind,
293
+ });
294
+ return text({
295
+ id: written.id,
296
+ duplicate_of: written.duplicateOf,
297
+ ...(written.contradicted ? { contradicted: written.contradicted } : {}),
298
+ ...(written.notIndexed ? { indexed: false, note: written.notIndexed } : {}),
299
+ });
300
+ }
301
+ case "memory_turn": {
302
+ const args = params as TurnArgs;
303
+ // A turn is the first thing the memory server ever hears about a conversation, so the row it
304
+ // points at is created here — idempotently, because the next turn points at the same one.
305
+ store.ensureConversation({ id: args.conversation_id, ...(args.cwd ? { cwd: args.cwd } : {}) });
306
+ const turn = store.recordTurn(args.conversation_id, {
307
+ userText: args.user_text,
308
+ ...(args.assistant_text === undefined ? {} : { assistantText: args.assistant_text }),
309
+ ...(args.retrieved ? { retrieved: args.retrieved } : {}),
310
+ ...(args.cited ? { cited: args.cited } : {}),
311
+ ...(args.usage ? { usage: args.usage } : {}),
312
+ });
313
+ // The counts come back, not the row: the caller reported them, and saying what landed is what
314
+ // makes a silent no-op impossible.
315
+ return text({ turn, retrieved: args.retrieved?.length ?? 0, cited: args.cited?.length ?? 0 });
316
+ }
317
+ case "memory_read": {
318
+ const args = params as IdArgs;
319
+ const memory = store.memory(args.id);
320
+ if (!memory) return text(`no memory #${args.id}`, true);
321
+ return text({ memory, history: store.history(args.id) });
322
+ }
323
+ case "memory_supersede": {
324
+ const args = params as SupersedeArgs;
325
+ const leaked = secretInBody(args.body);
326
+ if (leaked) return leaked;
327
+ const previous = store.memory(args.id);
328
+ if (!previous) return text(`no memory #${args.id}`, true);
329
+ if (previous.status !== "active") return text(`memory #${args.id} is ${previous.status}; only an active row can be superseded`, true);
330
+ const replacement = (await store.addMemory({
331
+ ...(await provenance(args, actor, originClass, sessionKind)),
332
+ supersedes: args.id,
333
+ strength: previous.strength,
334
+ reason: args.reason,
335
+ })).id;
336
+ store.markSuperseded(args.id, replacement, actor, args.reason);
337
+ return text({ superseded: args.id, replacement });
338
+ }
339
+ case "memory_forget": {
340
+ const args = params as ForgetArgs;
341
+ const existing = store.memory(args.id);
342
+ if (!existing) return text(`no memory #${args.id}`, true);
343
+ store.forget(args.id, actor, args.reason);
344
+ return text({ id: args.id, status: store.memory(args.id)!.status });
345
+ }
346
+ case "memory_media": {
347
+ const args = params as MediaArgs;
348
+ const media = store.mediaBytes(args.id);
349
+ if (!media) return text(`memory #${args.id} has no image or recording`, true);
350
+ return text({
351
+ id: args.id,
352
+ kind: media.kind,
353
+ mime: media.mime,
354
+ path: media.path,
355
+ base64: Buffer.from(media.bytes).toString("base64"),
356
+ });
357
+ }
358
+ case "memory_index_status":
359
+ return text(store.indexIdentity());
360
+ default:
361
+ return text(`unknown tool: ${request.params.name}`, true);
362
+ }
363
+ } catch (error) {
364
+ return text({ error: (error as Error).message }, true);
365
+ }
366
+ });
367
+
368
+ return server;
369
+ }
370
+
371
+ async function provenance(
372
+ args: Static<typeof TOOL_MODELS.memory_write> | Static<typeof TOOL_MODELS.memory_supersede>,
373
+ actor: string,
374
+ origin_class: OriginClass,
375
+ session_kind: SessionKind,
376
+ ) {
377
+ return {
378
+ body: args.body,
379
+ kind: args.kind,
380
+ tags: args.tags ?? [],
381
+ reason: args.reason,
382
+ actor,
383
+ origin_class,
384
+ session_kind,
385
+ // Relative paths are read from the server's working directory — it runs on this machine.
386
+ ...(args.media_path ? { media: await readMedia(resolve(args.media_path)) } : {}),
387
+ };
388
+ }
389
+
390
+ /**
391
+ * The write gate (docs/secrets.md): a memory keeps the *pointer*, never the value. A body that quotes a
392
+ * stored credential is refused rather than rewritten — the model can still say "authenticates with secret
393
+ * `mats_ssh`", and a fact with a password welded into it would be pasted into every prompt it ever reaches.
394
+ * The store's own `redactSecrets` stays as the backstop for writers that are not a model.
395
+ */
396
+ function secretInBody(body: string): ReturnType<typeof text> | undefined {
397
+ if (!containsSecret(body)) return undefined;
398
+ const names = [...new Set([...redactSecrets(body).matchAll(/\[secret:([^\]]+)\]/g)].map((match) => match[1]!))];
399
+ return text(
400
+ {
401
+ error: `the body quotes ${names.map((name) => `[secret:${name}]`).join(", ")} — a memory keeps the pointer, not the value. Write it as "…authenticates with secret ${names[0]}…" and use the secret tools to use the value.`,
402
+ secrets: names,
403
+ },
404
+ true,
405
+ );
406
+ }
407
+
408
+ const text = (payload: unknown, isError = false) => ({
409
+ content: [{ type: "text" as const, text: typeof payload === "string" ? payload : JSON.stringify(payload, null, 1) }],
410
+ ...(isError ? { isError: true } : {}),
411
+ });
412
+
413
+ export interface MemoryServerOptions extends MemoryMcpOptions {
414
+ store: MemoryStore;
415
+ /** Omit to generate one per run; the token is what keeps this off the LAN as an open door. */
416
+ token?: string;
417
+ port?: number;
418
+ host?: string;
419
+ }
420
+
421
+ export interface MemoryServerHandle {
422
+ port: number;
423
+ host: string;
424
+ token: string;
425
+ url: string;
426
+ close(): Promise<void>;
427
+ }
428
+
429
+ /**
430
+ * Loopback HTTP, one endpoint: `POST /mcp`. `?token=` or `Authorization: Bearer` both work, since
431
+ * `mcp.json` carries headers while a browser-style client more easily carries a query parameter.
432
+ */
433
+ export async function startMemoryServer(options: MemoryServerOptions): Promise<MemoryServerHandle> {
434
+ const host = options.host ?? "127.0.0.1";
435
+ const token = options.token ?? randomUUID();
436
+
437
+ const httpServer = createServer((request, response) => {
438
+ void handle(request, response);
439
+ });
440
+
441
+ const handle = async (request: IncomingMessage, response: ServerResponse) => {
442
+ const url = new URL(request.url ?? "/", `http://${host}`);
443
+ const supplied = url.searchParams.get("token") ?? request.headers.authorization?.replace(/^Bearer\s+/i, "");
444
+ const mount = MOUNTS[url.pathname];
445
+ if (!mount) return send(response, 404, "not found\n");
446
+ if (supplied !== token) return send(response, 401, "unauthorized\n");
447
+
448
+ // A fresh server+transport per request is the stateless Streamable HTTP pattern. One store behind both
449
+ // mounts, so the background mount is not a second writer (risk 3) — only a different principal.
450
+ const server = createMemoryMcpServer(options.store, { ...options, ...mount });
451
+ const transport = new StreamableHTTPServerTransport({
452
+ sessionIdGenerator: undefined,
453
+ enableJsonResponse: true,
454
+ });
455
+ request.on("aborted", () => void transport.close());
456
+ try {
457
+ await server.connect(transport);
458
+ await transport.handleRequest(request, response);
459
+ // `enableJsonResponse` means the response is complete when handleRequest resolves; closing
460
+ // here is what keeps one request from holding a transport open. (Not req.on("close"): for a
461
+ // POST whose body is already read, that fires while the request is still being handled.)
462
+ await transport.close();
463
+ await server.close();
464
+ } catch (error) {
465
+ console.error(`flypi memory: ${(error as Error).message}`);
466
+ if (!response.headersSent) send(response, 500, "internal error\n");
467
+ }
468
+ };
469
+
470
+ const port = await new Promise<number>((resolvePort, reject) => {
471
+ httpServer.once("error", reject);
472
+ httpServer.listen(options.port ?? 0, host, () => {
473
+ const address = httpServer.address();
474
+ if (address && typeof address === "object") resolvePort(address.port);
475
+ else reject(new Error("could not bind the memory server socket"));
476
+ });
477
+ });
478
+
479
+ return {
480
+ port,
481
+ host,
482
+ token,
483
+ url: `http://${host}:${port}/mcp`,
484
+ close: () =>
485
+ new Promise<void>((resolveClose, rejectClose) => {
486
+ httpServer.close((error) => (error ? rejectClose(error) : resolveClose()));
487
+ // A client that never closed its connection must not keep shutdown waiting.
488
+ httpServer.closeAllConnections();
489
+ }),
490
+ };
491
+ }
492
+
493
+ function send(response: ServerResponse, status: number, body: string): void {
494
+ response.writeHead(status, { "content-type": "text/plain; charset=utf-8" });
495
+ response.end(body);
496
+ }