@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.
- package/LICENSE +21 -0
- package/README.md +44 -2
- package/assets/screenshot.png +0 -0
- package/bin/flypi.mjs +28 -0
- package/dist/ui/assets/index-CG1CuGxu.js +105 -0
- package/dist/ui/assets/index-qaKjH5fH.css +1 -0
- package/dist/ui/flypi-logo-dark.jpeg +0 -0
- package/dist/ui/flypi-logo-light.jpeg +0 -0
- package/dist/ui/index.html +17 -0
- package/package.json +71 -5
- package/scripts/benchmark-nli.ts +273 -0
- package/scripts/flypi.mjs +303 -0
- package/scripts/probe-turn-boundary.ts +97 -0
- package/scripts/probe.ts +119 -0
- package/scripts/record-transcript.ts +63 -0
- package/scripts/reembed-memory.ts +41 -0
- package/scripts/release.mjs +35 -0
- package/scripts/secret.ts +24 -0
- package/scripts/serve-daemon.ts +101 -0
- package/scripts/serve-memory.ts +78 -0
- package/scripts/serve.ts +89 -0
- package/scripts/smoke-memory.mjs +239 -0
- package/scripts/smoke-secrets.mjs +153 -0
- package/scripts/smoke.mjs +59 -0
- package/scripts/start.mjs +294 -0
- package/scripts/turn-boundary.ts +52 -0
- package/scripts/update.ts +92 -0
- package/scripts/warm-models.ts +41 -0
- package/src/agent/config-files.ts +529 -0
- package/src/agent/mcp-signin.ts +137 -0
- package/src/agent/mcp-status.ts +168 -0
- package/src/agent/onboarding.ts +74 -0
- package/src/agent/packages.ts +150 -0
- package/src/agent/paths.ts +80 -0
- package/src/agent/permission-gate.ts +70 -0
- package/src/agent/pi-mcp.ts +97 -0
- package/src/agent/runtime.ts +347 -0
- package/src/daemon/index.ts +442 -0
- package/src/daemon/jobs.ts +202 -0
- package/src/daemon/update.ts +248 -0
- package/src/memory/capture.ts +109 -0
- package/src/memory/config.ts +90 -0
- package/src/memory/consolidate.ts +218 -0
- package/src/memory/db.ts +393 -0
- package/src/memory/embed.ts +154 -0
- package/src/memory/judge.ts +167 -0
- package/src/memory/mcp.ts +496 -0
- package/src/memory/media.ts +135 -0
- package/src/memory/progress.ts +39 -0
- package/src/memory/recall.ts +528 -0
- package/src/memory/review.ts +86 -0
- package/src/memory/store.ts +1034 -0
- package/src/memory/wiki.ts +140 -0
- package/src/secrets/cli.ts +248 -0
- package/src/secrets/config.ts +133 -0
- package/src/secrets/crypto.ts +180 -0
- package/src/secrets/extension.ts +253 -0
- package/src/secrets/redact.ts +132 -0
- package/src/secrets/vault.ts +396 -0
- package/src/server/event-log.ts +49 -0
- package/src/server/extension-ui.ts +118 -0
- package/src/server/index.ts +398 -0
- package/src/server/memory-api.ts +158 -0
- package/src/server/protocol.ts +947 -0
- package/src/server/run-store.ts +136 -0
- package/src/server/secrets-api.ts +88 -0
- package/src/server/session-registry.ts +593 -0
- package/src/shared/attachments.ts +40 -0
- package/src/shared/cache-probe.ts +47 -0
- package/src/shared/markdown.ts +17 -0
- package/src/shared/metrics.ts +212 -0
- package/src/shared/sqlite.ts +74 -0
- package/src/shared/transcript.ts +404 -0
- package/src/shared/turn-boundary.ts +98 -0
- package/src/shared/views.ts +31 -0
|
@@ -0,0 +1,1034 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MemoryStore` — the one writer over the memory database (docs/memory.md Phase 3).
|
|
3
|
+
*
|
|
4
|
+
* Everything that reaches this class is SQL with bound parameters, never concatenated text. The two
|
|
5
|
+
* rules the rest of the design leans on live here and nowhere else:
|
|
6
|
+
* - `active → superseded` is the only automatic transition; `deleted` needs an explicit call
|
|
7
|
+
* (docs/memory.md "Status rules"), and every automated mutation writes its pre-image first.
|
|
8
|
+
* - A vector is only comparable to another vector from the same model and quantization, so
|
|
9
|
+
* `vectorSearch` filters on the index identity rather than trusting the config (risk 7).
|
|
10
|
+
*/
|
|
11
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
12
|
+
import { cosine, type Embedder, type EmbedIdentity, type EmbedResult, noneEmbedder } from "./embed.ts";
|
|
13
|
+
import type { MediaKind, MemoryMedia } from "./media.ts";
|
|
14
|
+
import { redactSecrets } from "../secrets/redact.ts";
|
|
15
|
+
|
|
16
|
+
export type OriginClass = "owner" | "agent" | "untrusted" | "system";
|
|
17
|
+
export type SessionKind = "interactive" | "background" | "subagent" | "unknown";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The only rows automatic injection may use: a fact the user stated, or one this agent wrote. Authority
|
|
21
|
+
* is set by code at write time, never by prose, so a body saying "the user said so" does not earn a place
|
|
22
|
+
* in a prompt nobody asked for — `untrusted` (a web page, another agent's transcript) and `system` rows
|
|
23
|
+
* reach the model only through a search someone explicitly made (docs/memory.md Phase 4).
|
|
24
|
+
* shortcut: a fixed store policy, not a per-query list; a browser that filters by origin is Phase 6's.
|
|
25
|
+
*/
|
|
26
|
+
export const INJECTABLE_ORIGINS: readonly OriginClass[] = ["owner", "agent"];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* How close two vectors have to be to be called the same fact. Measured against EG2 (q8) on 2026-10-11:
|
|
30
|
+
* the same fact worded differently sits at 0.96–0.97 and genuinely different facts at 0.74. The 0.9 this
|
|
31
|
+
* used — tuned on bge-small — swallowed “the deploy target is 10.0.0.7” when a fuller version of it was
|
|
32
|
+
* written (0.9023), and a swallowed fact is invisible: the write returns the *other* row's id. A
|
|
33
|
+
* duplicate that survives is the cheaper mistake, so the gate sits above the reworded pair.
|
|
34
|
+
* What it cannot do: tell a copy from a correction. Measured the same way, a value swap sits *above*
|
|
35
|
+
* this threshold (“the deploy port is 9090” next to “…is 8080” is 0.9510, “…/v2” vs “…/v1” is 0.9793,
|
|
36
|
+
* “we do not use pnpm” vs “we use pnpm” is 0.9835) — being that similar is what a changed value *is*.
|
|
37
|
+
* That is what `valueSwap` below decides; cosine alone would drop the correction and hand back the id
|
|
38
|
+
* of the row it replaced.
|
|
39
|
+
* shortcut: measured on a handful of pairs, not a corpus — Phase 4's recall corpus settles it.
|
|
40
|
+
*/
|
|
41
|
+
export const NEAR_DUPLICATE = 0.95;
|
|
42
|
+
/** The contradiction micro-call sees at most this many rows, in one pass, never recursively. */
|
|
43
|
+
const CONTRADICTION_CANDIDATES = 5;
|
|
44
|
+
|
|
45
|
+
/** What a contradiction classifier is shown: the nearest active rows, with their bodies. */
|
|
46
|
+
export interface ContradictionCandidate {
|
|
47
|
+
id: number;
|
|
48
|
+
body: string;
|
|
49
|
+
score: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type ContradictionVerdict = "new" | "extend" | "duplicate" | "contradict";
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The classifier seam (docs/memory.md). It is asked flypi's own model, never as a user turn, and never
|
|
56
|
+
* a second time for the same write: a check that triggered another check could chain until the turn's
|
|
57
|
+
* budget was gone. Naming a row it was not shown is not a verdict.
|
|
58
|
+
*/
|
|
59
|
+
export type ContradictionClassifier = (
|
|
60
|
+
body: string,
|
|
61
|
+
candidates: ContradictionCandidate[],
|
|
62
|
+
) => Promise<{ verdict: ContradictionVerdict; target?: number }>;
|
|
63
|
+
|
|
64
|
+
export interface MemoryRow {
|
|
65
|
+
id: number;
|
|
66
|
+
kind: string;
|
|
67
|
+
body: string;
|
|
68
|
+
tags: string[];
|
|
69
|
+
status: "active" | "superseded" | "deleted";
|
|
70
|
+
origin_class: OriginClass;
|
|
71
|
+
session_kind: SessionKind;
|
|
72
|
+
supersedes: number | null;
|
|
73
|
+
superseded_by: number | null;
|
|
74
|
+
merged_from: number[];
|
|
75
|
+
strength: number;
|
|
76
|
+
use_count: number;
|
|
77
|
+
cited_count: number;
|
|
78
|
+
created_at: string;
|
|
79
|
+
last_verified_at: string | null;
|
|
80
|
+
session_id: string | null;
|
|
81
|
+
turn_id: number | null;
|
|
82
|
+
/** The index this row's vector belongs to, or null when it has none. */
|
|
83
|
+
embed: EmbedIdentity & { dims: number } | null;
|
|
84
|
+
/** What was embedded: the text, or the bytes of an image or recording. */
|
|
85
|
+
modality: "text" | MediaKind;
|
|
86
|
+
media: { id: number; kind: MediaKind; mime: string; path: string; bytes_size: number } | null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface NewMemory {
|
|
90
|
+
kind: string;
|
|
91
|
+
body: string;
|
|
92
|
+
tags?: string[];
|
|
93
|
+
origin_class: OriginClass;
|
|
94
|
+
session_kind: SessionKind;
|
|
95
|
+
strength?: number;
|
|
96
|
+
session_id?: string;
|
|
97
|
+
turn_id?: number;
|
|
98
|
+
supersedes?: number;
|
|
99
|
+
actor: string;
|
|
100
|
+
reason?: string;
|
|
101
|
+
/** An image or recording to remember; `body` stays its caption, and keyword search reads that. */
|
|
102
|
+
media?: MemoryMedia;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface ScoredMemory extends MemoryRow {
|
|
106
|
+
score: number;
|
|
107
|
+
matched_by: "fts" | "vector" | "both";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type AuditAction = "create" | "edit" | "supersede" | "forget" | "verify" | "reembed";
|
|
111
|
+
|
|
112
|
+
export interface AuditEntry {
|
|
113
|
+
/** The audit row's own id — what the browser's undo names, so an undo says which change it reverses. */
|
|
114
|
+
id: number;
|
|
115
|
+
action: AuditAction;
|
|
116
|
+
actor: string;
|
|
117
|
+
pre_image: unknown;
|
|
118
|
+
reason: string | null;
|
|
119
|
+
created_at: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface SearchOptions {
|
|
123
|
+
query: string;
|
|
124
|
+
limit?: number;
|
|
125
|
+
/** Retrieval counts: `use_count` moves when a row is retrieved (docs/memory.md Phase 4). */
|
|
126
|
+
count?: boolean;
|
|
127
|
+
/** Hard cap on injected text; a block is dropped whole, never split mid-sentence. */
|
|
128
|
+
maxTokens?: number;
|
|
129
|
+
/**
|
|
130
|
+
* Only `INJECTABLE_ORIGINS` rows are eligible — what flypi's per-turn recall asks for. Left out, every
|
|
131
|
+
* origin is searchable, which is right for a caller that can see the provenance and judge it itself.
|
|
132
|
+
*/
|
|
133
|
+
injectableOnly?: boolean;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface SearchResult {
|
|
137
|
+
results: ScoredMemory[];
|
|
138
|
+
/** True when the semantic half did not run — never a silent downgrade (risk 8). */
|
|
139
|
+
partial: boolean;
|
|
140
|
+
note?: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Rough token count for a block of text. Good enough for a budget that must not be exceeded. */
|
|
144
|
+
export const estimateTokens = (text: string): number => Math.ceil(text.length / 4);
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* How a row's worth decays with time, in days from its last verification: a row this old ranks half as
|
|
148
|
+
* high and is still returned (staleness lowers rank, it never removes a memory).
|
|
149
|
+
* shortcut: a constant, not the `memory_config` row the design names — that table has no reader yet, and
|
|
150
|
+
* a setting nobody can set is a second mechanism. Read it from settings when Phase 8 does.
|
|
151
|
+
*/
|
|
152
|
+
const DECAY_HALF_LIFE_DAYS = 120;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Freshness bands, per `kind` (docs/memory.md Phase 6): a convention or a preference does not go stale by
|
|
156
|
+
* sitting in the store, a situational fact does. `Infinity` means age is not a factor for that kind —
|
|
157
|
+
* `0.5 ** (age / Infinity)` is 1, so the row stops decaying rather than being removed.
|
|
158
|
+
* shortcut: `kind` is free-form text (the model and the review fork choose it), so this is a keyword match
|
|
159
|
+
* on the kind rather than a schema. A kind named with none of these words gets the default band, which is
|
|
160
|
+
* the one every kind had before this existed.
|
|
161
|
+
*/
|
|
162
|
+
const EVERGREEN_KIND = /convention|preference|rule|style|choice|identity|location|who|name/;
|
|
163
|
+
const halfLifeDaysFor = (kind: string): number => (EVERGREEN_KIND.test(kind) ? Number.POSITIVE_INFINITY : DECAY_HALF_LIFE_DAYS);
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* What a row that clears the promotion gates is raised to (docs/memory.md Phase 6). Promotion is a rank
|
|
167
|
+
* change and nothing else: it writes no new fact and removes none, so a promoted row keeps the id a
|
|
168
|
+
* conversation cited it by.
|
|
169
|
+
*/
|
|
170
|
+
export const PROMOTED_STRENGTH = 1.5;
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Relevance comes from the merge; these decide worth *among* what the merge found. They multiply rather
|
|
174
|
+
* than add so none of them can rescue a row the search did not match, and at the defaults (strength 1.0,
|
|
175
|
+
* verified now, never cited) the product is exactly 1.0 — the ranking moves only once the store has
|
|
176
|
+
* actually learned something about a row. Citations use `log` because the hundredth one says very little
|
|
177
|
+
* more than the tenth, and a count the model could inflate would rank politeness.
|
|
178
|
+
*/
|
|
179
|
+
function rankFactors(row: MemoryRow, halfLifeDays: number): number {
|
|
180
|
+
const verified = Date.parse(row.last_verified_at ?? row.created_at);
|
|
181
|
+
const ageDays = Number.isNaN(verified) ? 0 : Math.max(0, (Date.now() - verified) / 86_400_000);
|
|
182
|
+
return row.strength * 0.5 ** (ageDays / halfLifeDays) * (1 + Math.log1p(row.cited_count) / 3);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Two rows that state the same fact cost the model the same tokens twice. Token containment, not a model
|
|
187
|
+
* call — this runs on every turn — and `min` in the denominator is deliberate: "we use pnpm" inside "we
|
|
188
|
+
* use pnpm, never npm" is one fact stated twice, and the higher-ranked copy is the one that stays.
|
|
189
|
+
* The write gate already refuses near-identical bodies; this catches the paraphrases it missed and the
|
|
190
|
+
* rows a consolidation pass has not merged yet. The cutoff is high on purpose: two rows that differ in
|
|
191
|
+
* one value ("the api url is X" / "... is Y") are a correction, not a duplicate, and hiding one of them
|
|
192
|
+
* from the model would lose the fact.
|
|
193
|
+
*/
|
|
194
|
+
const SAME_FACT = 0.9;
|
|
195
|
+
/** Exported for the consolidation sweep: the rule that hides a duplicate at read time is what it merges. */
|
|
196
|
+
export function sameFact(a: string, b: string): boolean {
|
|
197
|
+
const tokens = (text: string) => new Set(text.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean));
|
|
198
|
+
const x = tokens(a);
|
|
199
|
+
const y = tokens(b);
|
|
200
|
+
if (x.size === 0 || y.size === 0) return false;
|
|
201
|
+
let shared = 0;
|
|
202
|
+
for (const token of x) if (y.has(token)) shared += 1;
|
|
203
|
+
return shared / Math.min(x.size, y.size) >= SAME_FACT;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Reciprocal-rank fusion: rank-based, so BM25's unbounded scores and cosine's need not agree. */
|
|
207
|
+
export function fuseRrf(lists: number[][], k = 60): { id: number; score: number }[] {
|
|
208
|
+
const scores = new Map<number, number>();
|
|
209
|
+
for (const ranked of lists) {
|
|
210
|
+
ranked.forEach((id, index) => {
|
|
211
|
+
scores.set(id, (scores.get(id) ?? 0) + 1 / (k + index + 1));
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
// Score desc, then id desc: without the second key a tie would order by insertion history.
|
|
215
|
+
return [...scores.entries()]
|
|
216
|
+
.sort((a, b) => b[1] - a[1] || b[0] - a[0])
|
|
217
|
+
.map(([id, score]) => ({ id, score }));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Drop the lowest-ranked blocks until the text fits, never splitting a block. */
|
|
221
|
+
export function applyTokenBudget<T extends { body: string }>(ranked: T[], maxTokens: number): T[] {
|
|
222
|
+
let used = 0;
|
|
223
|
+
const kept: T[] = [];
|
|
224
|
+
for (const item of ranked) {
|
|
225
|
+
used += estimateTokens(item.body);
|
|
226
|
+
if (used > maxTokens) break;
|
|
227
|
+
kept.push(item);
|
|
228
|
+
}
|
|
229
|
+
return kept;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Words, lower-cased: FTS tokens and frame tokens are the same thing, and case is not a value. */
|
|
233
|
+
function wordTokens(text: string): string[] {
|
|
234
|
+
return (text.toLowerCase().match(/[\p{L}\p{N}_]+/gu) ?? []).filter((word) => word.length > 0);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** FTS5 query syntax is not user text: quote each word so a `"` or `:` cannot break the match. */
|
|
238
|
+
function ftsQuery(query: string): string {
|
|
239
|
+
return wordTokens(query).map((term) => `"${term}"`).join(" OR ");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The origin rule as SQL, so both halves of a search ask the same question. Bound parameters, because the
|
|
244
|
+
* values are the four `origin_class` names the schema's CHECK constraint already allows.
|
|
245
|
+
*/
|
|
246
|
+
function originFilter(origins?: readonly OriginClass[]): string {
|
|
247
|
+
return origins?.length ? ` AND memories.origin_class IN (${origins.map(() => "?").join(", ")})` : "";
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** What differs between two rows that otherwise say the same thing: `8080` was, `9090` is now. */
|
|
251
|
+
export interface ValueSwap {
|
|
252
|
+
/** Only in the row already stored. */
|
|
253
|
+
was: string[];
|
|
254
|
+
/** Only in the write that replaces it. */
|
|
255
|
+
now: string[];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Shared tokens below this are not the same fact about the same thing, just similar wording. */
|
|
259
|
+
const FRAME_TOKENS = 3;
|
|
260
|
+
/** A longer difference than this is a different fact, not the value of the same one. Measured
|
|
261
|
+
* (`scripts/benchmark-nli.ts`, 2026-10-11): every real value swap in the pair set differs by 1–2 tokens
|
|
262
|
+
* (port, version, path, time, count, host name, polarity); 3-token spans were reworded copies —
|
|
263
|
+
* "deployments go to the small box" against "the deploy target is the small box" — which the rule used to
|
|
264
|
+
* retire as if the value had changed. */
|
|
265
|
+
const VALUE_TOKENS = 2;
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The one contradiction that needs no model: two rows that agree on every token except a short value
|
|
269
|
+
* span are the same fact with a new value. This is a token-frame test, not a meaning test — it decides
|
|
270
|
+
* the class that cosine cannot (a value swap measures 0.95–0.98, above `NEAR_DUPLICATE`) and nothing
|
|
271
|
+
* else: “prefer bun over pnpm” against “we use pnpm, never npm” shares no frame and is the judge's
|
|
272
|
+
* work (docs/memory.md Phase 4). The shared-frame guard is what keeps “the deploy port is 8080” and
|
|
273
|
+
* "the deploy host is 10.0.0.5" — same shape, different key — from looking like a changed value.
|
|
274
|
+
* shortcut: token-level, so paraphrase and negation-with-reordering are out of reach by construction —
|
|
275
|
+
* the NLI judge behind the `classify` seam owns those, not a wider threshold here.
|
|
276
|
+
*/
|
|
277
|
+
export function valueSwap(body: string, other: string): ValueSwap | undefined {
|
|
278
|
+
const diff = frameDiff(body, other);
|
|
279
|
+
return diff?.swap ? { was: diff.was, now: diff.now } : undefined;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The token frame: the words two rows share are the fact's frame, the words they do not are its value.
|
|
284
|
+
* `swap` asks whether that difference is small enough to be one changed value. One side empty means one
|
|
285
|
+
* row contains all the other's tokens — an extension, not a conflict — and no shared frame means these
|
|
286
|
+
* are not the same fact at all, so there is nothing to compare.
|
|
287
|
+
*/
|
|
288
|
+
function frameDiff(body: string, other: string): { was: string[]; now: string[]; swap: boolean } | undefined {
|
|
289
|
+
const words = new Set(wordTokens(body));
|
|
290
|
+
const theirs = new Set(wordTokens(other));
|
|
291
|
+
if ([...words].filter((word) => theirs.has(word)).length < FRAME_TOKENS) return undefined;
|
|
292
|
+
const now = [...words].filter((word) => !theirs.has(word));
|
|
293
|
+
const was = [...theirs].filter((word) => !words.has(word));
|
|
294
|
+
if (now.length === 0 || was.length === 0) return undefined;
|
|
295
|
+
return { was, now, swap: now.length <= VALUE_TOKENS && was.length <= VALUE_TOKENS };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Same frame, and the difference is bigger than one value can be: these are two facts about different
|
|
300
|
+
* keys — "the deploy port is 8080" against "the deploy host is 10.0.0.5". The NLI judge calls that pair
|
|
301
|
+
* a contradiction at 1.00 confidence (measured, `scripts/benchmark-nli.ts`), because "does B follow from
|
|
302
|
+
* A" is not the question "are these different facts". This is what answers that, and it is the reason a
|
|
303
|
+
* judge may be allowed near the supersede path at all.
|
|
304
|
+
*/
|
|
305
|
+
export function differentKey(body: string, other: string): boolean {
|
|
306
|
+
const diff = frameDiff(body, other);
|
|
307
|
+
return diff !== undefined && !diff.swap;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const now = () => new Date().toISOString();
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Actors that are not a human in a live conversation: `fork` is the review fork's mount principal
|
|
314
|
+
* (src/memory/mcp.ts) and `sweep` is the consolidation pass's. Neither may tombstone a row.
|
|
315
|
+
*/
|
|
316
|
+
const UNATTENDED_ACTORS: readonly string[] = ["sweep", "fork"];
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* How long a write waits for the judge. A fact must not be held hostage by a model: 2 s is ~300 pairs at
|
|
320
|
+
* measured latency, so it only fires when the model is broken or the process is starved. The row is then
|
|
321
|
+
* written with no judgment, and `notJudged` says so (docs/memory.md, "the row is written whatever the
|
|
322
|
+
* judge does"). Injectable only so a test can prove the bound without waiting two seconds.
|
|
323
|
+
*/
|
|
324
|
+
const JUDGE_BUDGET_MS = 2000;
|
|
325
|
+
|
|
326
|
+
export class MemoryStore {
|
|
327
|
+
private readonly embedder: Embedder;
|
|
328
|
+
|
|
329
|
+
constructor(
|
|
330
|
+
private readonly db: DatabaseSync,
|
|
331
|
+
embedder: Embedder = noneEmbedder(),
|
|
332
|
+
options: { classify?: ContradictionClassifier; judgeBudgetMs?: number } = {},
|
|
333
|
+
) {
|
|
334
|
+
this.embedder = embedder;
|
|
335
|
+
this.classify = options.classify;
|
|
336
|
+
this.judgeBudgetMs = options.judgeBudgetMs ?? JUDGE_BUDGET_MS;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private readonly classify?: ContradictionClassifier;
|
|
340
|
+
private readonly judgeBudgetMs: number;
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* One pass over the nearest rows, one classifier call, no recursion. A classifier that throws, or one
|
|
344
|
+
* that does not answer inside its budget, is a store with no opinion: the write still happens and the
|
|
345
|
+
* result says why nothing was judged, because losing a fact is worse than missing a contradiction
|
|
346
|
+
* (docs/memory.md, "a daemon crash must never lose a fact").
|
|
347
|
+
*/
|
|
348
|
+
private async judgeContradiction(
|
|
349
|
+
body: string,
|
|
350
|
+
candidates: { id: number; score: number }[],
|
|
351
|
+
): Promise<{ verdict?: { verdict: ContradictionVerdict; target: number }; reason?: string }> {
|
|
352
|
+
if (!this.classify || candidates.length === 0) return {};
|
|
353
|
+
const shown = candidates.map((candidate) => ({ ...candidate, body: this.memory(candidate.id)?.body ?? "" }));
|
|
354
|
+
const pending = this.classify(body, shown);
|
|
355
|
+
// Abandoning the call must not leave an unhandled rejection behind.
|
|
356
|
+
pending.catch(() => undefined);
|
|
357
|
+
let budget: ReturnType<typeof setTimeout> | undefined;
|
|
358
|
+
try {
|
|
359
|
+
const verdict = await Promise.race([
|
|
360
|
+
pending,
|
|
361
|
+
new Promise<undefined>((resolve) => {
|
|
362
|
+
budget = setTimeout(() => resolve(undefined), this.judgeBudgetMs);
|
|
363
|
+
budget.unref?.();
|
|
364
|
+
}),
|
|
365
|
+
]);
|
|
366
|
+
if (!verdict) return { reason: `no contradiction judgment: the judge did not answer in ${this.judgeBudgetMs} ms` };
|
|
367
|
+
const target = shown.find((candidate) => candidate.id === verdict.target);
|
|
368
|
+
if (!target) return {};
|
|
369
|
+
// The frame test outranks the model on one question it was not trained for: two rows about
|
|
370
|
+
// different keys are not a contradiction, however confidently the judge says so.
|
|
371
|
+
if (verdict.verdict === "contradict" && differentKey(body, target.body)) return {};
|
|
372
|
+
return { verdict: { verdict: verdict.verdict, target: target.id } };
|
|
373
|
+
} catch (error) {
|
|
374
|
+
return { reason: `no contradiction judgment: ${(error as Error).message}` };
|
|
375
|
+
} finally {
|
|
376
|
+
if (budget) clearTimeout(budget);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
createConversation(conversation: { id: string; cwd: string; title?: string; sessionFile?: string }): void {
|
|
381
|
+
this.db
|
|
382
|
+
.prepare("INSERT INTO conversations (id, cwd, title, pi_session_file, created_at) VALUES (?, ?, ?, ?, ?)")
|
|
383
|
+
.run(conversation.id, conversation.cwd, conversation.title ?? null, conversation.sessionFile ?? null, now());
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* The conversation row a turn refers to, created only if it is not there. This is the first thing the
|
|
388
|
+
* memory server ever hears about a flypi conversation, so it has to be able to name one — and a turn
|
|
389
|
+
* after a restart must not fail on the row the first turn already wrote.
|
|
390
|
+
*/
|
|
391
|
+
ensureConversation(conversation: { id: string; cwd?: string; sessionFile?: string }): void {
|
|
392
|
+
this.db
|
|
393
|
+
.prepare(
|
|
394
|
+
`INSERT INTO conversations (id, cwd, title, pi_session_file, created_at) VALUES (?, ?, NULL, ?, ?)
|
|
395
|
+
ON CONFLICT(id) DO UPDATE SET pi_session_file = COALESCE(conversations.pi_session_file, excluded.pi_session_file)`,
|
|
396
|
+
)
|
|
397
|
+
.run(conversation.id, conversation.cwd ?? process.cwd(), conversation.sessionFile ?? null, now());
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* One turn of one conversation, redacted on the way in (src/secrets/redact.ts — Phase 5.5 made that
|
|
402
|
+
* boundary real, and the plan asks for `tool_calls_json` to be redacted too: the result of a tool call is
|
|
403
|
+
* where a credential is most likely to appear). `retrieved`/`cited` are
|
|
404
|
+
* the ids the turn was shown and the ids its reply actually cited — kept apart because retrieval is not
|
|
405
|
+
* citation, and the gap between them is the metric (see `memoryHitRate`).
|
|
406
|
+
*/
|
|
407
|
+
recordTurn(
|
|
408
|
+
conversationId: string,
|
|
409
|
+
turn: {
|
|
410
|
+
userText: string;
|
|
411
|
+
assistantText?: string;
|
|
412
|
+
toolCalls?: unknown;
|
|
413
|
+
usage?: unknown;
|
|
414
|
+
retrieved?: number[];
|
|
415
|
+
cited?: number[];
|
|
416
|
+
},
|
|
417
|
+
): number {
|
|
418
|
+
const result = this.db
|
|
419
|
+
.prepare(
|
|
420
|
+
`INSERT INTO turns (conversation_id, user_text, assistant_text, tool_calls_json, usage_json, retrieved_json, cited_json, created_at)
|
|
421
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
422
|
+
)
|
|
423
|
+
.run(
|
|
424
|
+
conversationId,
|
|
425
|
+
redactSecrets(turn.userText),
|
|
426
|
+
turn.assistantText === undefined ? null : redactSecrets(turn.assistantText),
|
|
427
|
+
turn.toolCalls === undefined ? null : redactSecrets(JSON.stringify(turn.toolCalls)),
|
|
428
|
+
turn.usage === undefined ? null : JSON.stringify(turn.usage),
|
|
429
|
+
turn.retrieved === undefined ? null : JSON.stringify(turn.retrieved),
|
|
430
|
+
turn.cited === undefined ? null : JSON.stringify(turn.cited),
|
|
431
|
+
now(),
|
|
432
|
+
);
|
|
433
|
+
return Number(result.lastInsertRowid);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Automatic recall, measured: of the turns that were shown memories, how many cited one. A fact that
|
|
438
|
+
* was injected and never used cost prompt tokens and earned nothing, which is the question the
|
|
439
|
+
* hit-rate metrics ask (docs/memory.md Phase 4) and the reason both lists are logged separately.
|
|
440
|
+
*/
|
|
441
|
+
memoryHitRate(): {
|
|
442
|
+
turns: number;
|
|
443
|
+
turns_shown_memories: number;
|
|
444
|
+
turns_citing: number;
|
|
445
|
+
memories_retrieved: number;
|
|
446
|
+
memories_cited: number;
|
|
447
|
+
rate: number;
|
|
448
|
+
} {
|
|
449
|
+
const row = this.db
|
|
450
|
+
.prepare(
|
|
451
|
+
`SELECT count(*) AS turns,
|
|
452
|
+
coalesce(sum(retrieved_json IS NOT NULL AND retrieved_json <> '[]'), 0) AS shown,
|
|
453
|
+
coalesce(sum(cited_json IS NOT NULL AND cited_json <> '[]'), 0) AS citing,
|
|
454
|
+
coalesce(sum(json_array_length(retrieved_json)), 0) AS retrieved,
|
|
455
|
+
coalesce(sum(json_array_length(cited_json)), 0) AS cited
|
|
456
|
+
FROM turns`,
|
|
457
|
+
)
|
|
458
|
+
.get() as { turns: number; shown: number; citing: number; retrieved: number; cited: number };
|
|
459
|
+
return {
|
|
460
|
+
turns: row.turns,
|
|
461
|
+
turns_shown_memories: row.shown,
|
|
462
|
+
turns_citing: row.citing,
|
|
463
|
+
memories_retrieved: row.retrieved,
|
|
464
|
+
memories_cited: row.cited,
|
|
465
|
+
rate: row.shown === 0 ? 0 : row.citing / row.shown,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Store a memory — or point at the one already there. The write-time dedup gate (docs/memory.md):
|
|
471
|
+
* the same body, or a vector this close (`NEAR_DUPLICATE`) to an active one, is the same fact, so a retried
|
|
472
|
+
* write is safe. A write that *supersedes* is never swallowed: it is deliberately a near copy.
|
|
473
|
+
*
|
|
474
|
+
* The vector is written only when the current index identity produced it, so a row can never carry
|
|
475
|
+
* a vector from a model the search will not filter for. When the embedder refuses an input — it is
|
|
476
|
+
* longer than the model's window, the audio does not decode — the row is still stored without a
|
|
477
|
+
* vector and `notIndexed` says why: keyword search reaches it, and nothing is half-read in silence.
|
|
478
|
+
*/
|
|
479
|
+
async addMemory(memory: NewMemory): Promise<{
|
|
480
|
+
id: number;
|
|
481
|
+
duplicateOf: number | null;
|
|
482
|
+
/** Set when this write replaced the active row it contradicts — whether the token frame said so or a judge did. */
|
|
483
|
+
contradicted?: { id: number; body: string };
|
|
484
|
+
notIndexed?: string;
|
|
485
|
+
/** Set when a judge was configured and answered nothing — it threw, timed out, or named no row. */
|
|
486
|
+
notJudged?: string;
|
|
487
|
+
}> {
|
|
488
|
+
const body = redactSecrets(memory.body);
|
|
489
|
+
const exact = this.db.prepare("SELECT id FROM memories WHERE status = 'active' AND body = ?").get(body) as { id: number } | undefined;
|
|
490
|
+
if (exact) return { id: exact.id, duplicateOf: exact.id };
|
|
491
|
+
|
|
492
|
+
const embedded: EmbedResult = memory.media
|
|
493
|
+
? await this.embedder.embedMedia(memory.media)
|
|
494
|
+
: await this.embedder.embedDocument(body);
|
|
495
|
+
const vector = embedded.ok ? embedded.vector : null;
|
|
496
|
+
const identity = vector ? this.embedder.identity : null;
|
|
497
|
+
// The near-duplicate gate is text-only: the threshold is tuned on text, and two different files
|
|
498
|
+
// that happen to embed alike are not the same memory. One candidate fetch answers both write-time
|
|
499
|
+
// questions — which of these rows is a copy of this write, and which of them this write corrects.
|
|
500
|
+
// No origin filter here on purpose: a copy is a copy whoever wrote it.
|
|
501
|
+
const candidates =
|
|
502
|
+
vector && memory.supersedes === undefined && !memory.media ? this.vectorSearch(vector, CONTRADICTION_CANDIDATES) : [];
|
|
503
|
+
// A row `valueSwap` matches is never the first: a correction reads as a near-duplicate to the
|
|
504
|
+
// vectors, and refusing it would lose the fact. Bodies are re-read by id (≤ 5, the cap above).
|
|
505
|
+
const conflicts = candidates
|
|
506
|
+
.map((candidate) => ({ id: candidate.id, swap: valueSwap(body, this.memory(candidate.id)?.body ?? "") }))
|
|
507
|
+
.filter((candidate): candidate is { id: number; swap: ValueSwap } => candidate.swap !== undefined);
|
|
508
|
+
const near = candidates.find((hit) => hit.score >= NEAR_DUPLICATE && !conflicts.some((conflict) => conflict.id === hit.id));
|
|
509
|
+
if (near) return { id: near.id, duplicateOf: near.id };
|
|
510
|
+
// The judge is asked even when the token rule already matched: the model reading the pair as the
|
|
511
|
+
// same fact in other words is the one thing that can veto the rule, and at ~6 ms a pair the cost
|
|
512
|
+
// is not the reason to skip it. It sees the same ≤ 5 candidates, once, never recursively.
|
|
513
|
+
const judged = await this.judgeContradiction(body, candidates);
|
|
514
|
+
const verdict = judged.verdict;
|
|
515
|
+
if (verdict?.verdict === "duplicate") {
|
|
516
|
+
return { id: verdict.target, duplicateOf: verdict.target, ...(judged.reason ? { notJudged: judged.reason } : {}) };
|
|
517
|
+
}
|
|
518
|
+
// `extend` is the model saying this is the same fact, told in more words — measured, the class the
|
|
519
|
+
// token rule gets wrong. A deterministic value swap still wins over a model that saw nothing.
|
|
520
|
+
const restatement = verdict?.verdict === "extend";
|
|
521
|
+
const supersedes =
|
|
522
|
+
memory.supersedes ??
|
|
523
|
+
(restatement ? undefined : conflicts[0]?.id) ??
|
|
524
|
+
(verdict?.verdict === "contradict" ? verdict.target : undefined);
|
|
525
|
+
const result = this.db
|
|
526
|
+
.prepare(
|
|
527
|
+
`INSERT INTO memories
|
|
528
|
+
(kind, body, tags, embedding, embed_model, dims, embed_quantized, embedded_at, modality,
|
|
529
|
+
origin_class, session_kind, strength, supersedes, created_at, session_id, turn_id)
|
|
530
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
531
|
+
)
|
|
532
|
+
.run(
|
|
533
|
+
memory.kind,
|
|
534
|
+
body,
|
|
535
|
+
JSON.stringify(memory.tags ?? []),
|
|
536
|
+
vector ? Buffer.from(vector.buffer) : null,
|
|
537
|
+
identity?.model ?? null,
|
|
538
|
+
vector?.length ?? null,
|
|
539
|
+
identity ? (identity.quantized ? 1 : 0) : null,
|
|
540
|
+
vector ? now() : null,
|
|
541
|
+
memory.media?.kind ?? "text",
|
|
542
|
+
memory.origin_class,
|
|
543
|
+
memory.session_kind,
|
|
544
|
+
memory.strength ?? 1.0,
|
|
545
|
+
supersedes ?? null,
|
|
546
|
+
now(),
|
|
547
|
+
memory.session_id ?? null,
|
|
548
|
+
memory.turn_id ?? null,
|
|
549
|
+
);
|
|
550
|
+
const id = Number(result.lastInsertRowid);
|
|
551
|
+
if (memory.media) {
|
|
552
|
+
this.db
|
|
553
|
+
.prepare("INSERT INTO media (memory_id, kind, mime, path, bytes, created_at) VALUES (?, ?, ?, ?, ?, ?)")
|
|
554
|
+
.run(id, memory.media.kind, memory.media.mime, memory.media.path, Buffer.from(memory.media.bytes), now());
|
|
555
|
+
}
|
|
556
|
+
this.audit(id, "create", memory.actor, {}, memory.reason);
|
|
557
|
+
// A contradiction is recorded on both sides: the new row points at what it replaces, and the old
|
|
558
|
+
// row keeps its pre-image in the audit, so "what did we believe before" stays answerable.
|
|
559
|
+
if (supersedes !== undefined && (conflicts.length || verdict?.verdict === "contradict")) {
|
|
560
|
+
const conflict = conflicts[0];
|
|
561
|
+
this.markSuperseded(
|
|
562
|
+
supersedes,
|
|
563
|
+
id,
|
|
564
|
+
memory.actor,
|
|
565
|
+
memory.reason ??
|
|
566
|
+
(conflict
|
|
567
|
+
? `same fact, new value: ${conflict.swap.was.join(" ")} → ${conflict.swap.now.join(" ")}`
|
|
568
|
+
: "contradiction micro-call"),
|
|
569
|
+
);
|
|
570
|
+
return {
|
|
571
|
+
id,
|
|
572
|
+
duplicateOf: null,
|
|
573
|
+
contradicted: { id: supersedes, body: this.memory(supersedes)?.body ?? "" },
|
|
574
|
+
...(embedded.ok ? {} : { notIndexed: embedded.error }),
|
|
575
|
+
...(judged.reason ? { notJudged: judged.reason } : {}),
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
return {
|
|
579
|
+
id,
|
|
580
|
+
duplicateOf: null,
|
|
581
|
+
...(embedded.ok ? {} : { notIndexed: embedded.error }),
|
|
582
|
+
...(judged.reason ? { notJudged: judged.reason } : {}),
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
memory(id: number): MemoryRow | undefined {
|
|
587
|
+
const row = this.db.prepare("SELECT * FROM memories WHERE id = ?").get(id);
|
|
588
|
+
return row ? this.mapRow(row as Record<string, unknown>) : undefined;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Text search (FTS5) fused with the semantic search, in one rank order. */
|
|
592
|
+
async search(options: SearchOptions): Promise<SearchResult> {
|
|
593
|
+
const limit = Math.max(1, Math.min(options.limit ?? 5, 50));
|
|
594
|
+
const candidates = limit * 2;
|
|
595
|
+
const origins = options.injectableOnly ? INJECTABLE_ORIGINS : undefined;
|
|
596
|
+
const lists: number[][] = [this.ftsSearch(options.query, candidates, origins)];
|
|
597
|
+
|
|
598
|
+
let partial = this.embedder.identity === null;
|
|
599
|
+
let note = this.embedder.error;
|
|
600
|
+
if (!partial) {
|
|
601
|
+
const query = await this.embedder.embedQuery(options.query);
|
|
602
|
+
if (query.ok) lists.push(this.vectorSearch(query.vector, candidates, origins).map((hit) => hit.id));
|
|
603
|
+
else {
|
|
604
|
+
partial = true;
|
|
605
|
+
note = `the query could not be embedded (${query.error}); results are keyword-only`;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const ranked = fuseRrf(lists);
|
|
610
|
+
const byId = new Map(ranked.map((hit) => [hit.id, hit.score]));
|
|
611
|
+
const ordered = ranked
|
|
612
|
+
.map((hit) => this.memory(hit.id))
|
|
613
|
+
.filter((row): row is MemoryRow => row !== undefined && row.status === "active" && (!origins || origins.includes(row.origin_class)))
|
|
614
|
+
.map((row) => ({ row, score: (byId.get(row.id) ?? 0) * rankFactors(row, halfLifeDaysFor(row.kind)) }))
|
|
615
|
+
// Same-fact pairs keep their better-ranked half.
|
|
616
|
+
.filter((entry, index, all) => !all.slice(0, index).some((earlier) => sameFact(earlier.row.body, entry.row.body)))
|
|
617
|
+
// RRF ties order by id desc; a tie after the factors is still that tie, so keep the same key.
|
|
618
|
+
.sort((a, b) => b.score - a.score || b.row.id - a.row.id)
|
|
619
|
+
.slice(0, limit);
|
|
620
|
+
const scoreOf = new Map(ordered.map((entry) => [entry.row.id, entry.score]));
|
|
621
|
+
const ftsHits = new Set(lists[0]);
|
|
622
|
+
const vectorHits = new Set(lists.length > 1 ? lists[1] : []);
|
|
623
|
+
const results: ScoredMemory[] = applyTokenBudget(ordered.map((entry) => entry.row), options.maxTokens ?? Infinity).map((row) => ({
|
|
624
|
+
...row,
|
|
625
|
+
score: scoreOf.get(row.id) ?? 0,
|
|
626
|
+
matched_by: ftsHits.has(row.id) && vectorHits.has(row.id) ? "both" : ftsHits.has(row.id) ? "fts" : "vector",
|
|
627
|
+
}));
|
|
628
|
+
if (options.count !== false && results.length > 0) this.bumpUseCount(results.map((row) => row.id));
|
|
629
|
+
return { results, partial, ...(note ? { note } : {}) };
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Keyword half. `status='active'` and the origin filter live in the SQL for both halves, so a row that
|
|
634
|
+
* is not eligible cannot come back through the door the other half opened — and cannot spend the token
|
|
635
|
+
* budget, the candidate slots, or a retrieval count that a filtered row never earned.
|
|
636
|
+
*/
|
|
637
|
+
private ftsSearch(query: string, limit: number, origins?: readonly OriginClass[]): number[] {
|
|
638
|
+
const match = ftsQuery(query);
|
|
639
|
+
if (!match) return [];
|
|
640
|
+
const rows = this.db
|
|
641
|
+
.prepare(
|
|
642
|
+
`SELECT memories_fts.rowid AS id
|
|
643
|
+
FROM memories_fts
|
|
644
|
+
JOIN memories ON memories.id = memories_fts.rowid
|
|
645
|
+
WHERE memories_fts MATCH ? AND memories.status = 'active'${originFilter(origins)}
|
|
646
|
+
ORDER BY bm25(memories_fts) ASC, id DESC
|
|
647
|
+
LIMIT ?`,
|
|
648
|
+
)
|
|
649
|
+
.all(match, ...(origins ?? []), limit) as { id: number }[];
|
|
650
|
+
return rows.map((row) => row.id);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Semantic half: one scan of the vectors that belong to *this* index.
|
|
655
|
+
*
|
|
656
|
+
* shortcut: full scan + cosine in JS, which is fine up to a few tens of thousands of rows;
|
|
657
|
+
* when it is not, the `sqlite-vec` path plugs in here (its filters stay the same SQL ones).
|
|
658
|
+
*/
|
|
659
|
+
vectorSearch(queryVector: Float32Array, limit = 20, origins?: readonly OriginClass[]): { id: number; score: number }[] {
|
|
660
|
+
const identity = this.embedder.identity;
|
|
661
|
+
if (!identity) return [];
|
|
662
|
+
const rows = this.db
|
|
663
|
+
.prepare(
|
|
664
|
+
`SELECT id, embedding FROM memories
|
|
665
|
+
WHERE status = 'active' AND embedding IS NOT NULL
|
|
666
|
+
AND embed_model = ? AND embed_quantized = ? AND dims = ?${originFilter(origins)}`,
|
|
667
|
+
)
|
|
668
|
+
.all(identity.model, identity.quantized ? 1 : 0, queryVector.length, ...(origins ?? [])) as { id: number; embedding: Uint8Array }[];
|
|
669
|
+
return rows
|
|
670
|
+
.map((row) => ({ id: row.id, score: cosine(queryVector, new Float32Array(row.embedding.buffer, row.embedding.byteOffset, row.embedding.byteLength / 4)) }))
|
|
671
|
+
// A vector at right angles to the query is not a near miss, it is unrelated; returning it
|
|
672
|
+
// would spend the token budget on noise.
|
|
673
|
+
.filter((hit) => hit.score > 0)
|
|
674
|
+
.sort((a, b) => b.score - a.score || b.id - a.id)
|
|
675
|
+
.slice(0, limit);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** The bytes of a memory's image or recording, for whoever has to show or play it. */
|
|
679
|
+
mediaBytes(memoryId: number): { kind: MediaKind; mime: string; path: string; bytes: Uint8Array } | undefined {
|
|
680
|
+
return this.db
|
|
681
|
+
.prepare("SELECT kind, mime, path, bytes FROM media WHERE memory_id = ? ORDER BY id DESC")
|
|
682
|
+
.get(memoryId) as { kind: MediaKind; mime: string; path: string; bytes: Uint8Array } | undefined;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** The only automatic transition. Both rows stay; the pre-image is written first. */
|
|
686
|
+
markSuperseded(id: number, byId: number, actor: string, reason?: string): void {
|
|
687
|
+
const row = this.memory(id);
|
|
688
|
+
if (!row) throw new Error(`memory ${id} does not exist`);
|
|
689
|
+
if (row.status !== "active") throw new Error(`memory ${id} is ${row.status}, only an active memory can be superseded`);
|
|
690
|
+
this.db
|
|
691
|
+
.prepare("UPDATE memories SET status = 'superseded', superseded_by = ? WHERE id = ?")
|
|
692
|
+
.run(byId, id);
|
|
693
|
+
this.db.prepare("UPDATE memories SET supersedes = ? WHERE id = ?").run(id, byId);
|
|
694
|
+
this.audit(id, "supersede", actor, row, reason);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** Reachable only from an explicit call (the browser, or `memory_forget` when the user asked). */
|
|
698
|
+
forget(id: number, actor: string, reason?: string): void {
|
|
699
|
+
// The one `if` that carries the whole unattended-writer story (docs/memory.md Phase 6): a background
|
|
700
|
+
// pass may add and supersede, and may not make a row disappear. The MCP background mount already
|
|
701
|
+
// refuses the *tool* (src/memory/mcp.ts); this is the same rule at the store, which is where a sweep,
|
|
702
|
+
// a job handler or a script arrives with an actor name of its own instead of a tool call.
|
|
703
|
+
if (UNATTENDED_ACTORS.includes(actor)) {
|
|
704
|
+
throw new Error(`memory ${id} cannot be forgotten by ${actor}: an unattended pass may supersede a row, never delete one`);
|
|
705
|
+
}
|
|
706
|
+
const row = this.memory(id);
|
|
707
|
+
if (!row) throw new Error(`memory ${id} does not exist`);
|
|
708
|
+
if (row.status === "deleted") return;
|
|
709
|
+
this.db.prepare("UPDATE memories SET status = 'deleted' WHERE id = ?").run(id);
|
|
710
|
+
this.audit(id, "forget", actor, row, reason);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/** `cited_count` moves only when the model actually cited `#id` — retrieval is not citation. */
|
|
714
|
+
/** Returns whether a row was actually bumped — an id that does not exist is not a citation. */
|
|
715
|
+
markCited(id: number): boolean {
|
|
716
|
+
return this.db.prepare("UPDATE memories SET cited_count = cited_count + 1, last_verified_at = ? WHERE id = ?").run(now(), id).changes > 0;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
private bumpUseCount(ids: number[]): void {
|
|
720
|
+
const statement = this.db.prepare("UPDATE memories SET use_count = use_count + 1 WHERE id = ?");
|
|
721
|
+
for (const id of ids) statement.run(id);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Does the stored index match the embeddings we can produce now? A mismatch is reported and
|
|
726
|
+
* queued, never papered over by searching two vector spaces together (risk 7).
|
|
727
|
+
*
|
|
728
|
+
* Only active rows count as the index: `vectorSearch` reads nothing else, so a stale vector on a
|
|
729
|
+
* superseded or deleted row is not something anyone can get a wrong answer from, and counting it
|
|
730
|
+
* would keep a re-embed job open forever.
|
|
731
|
+
*/
|
|
732
|
+
indexIdentity(): { expected: EmbedIdentity | null; rows: number; stale: number; reembedQueued: boolean } {
|
|
733
|
+
const { expected, rows, stale } = this.indexSummary();
|
|
734
|
+
return {
|
|
735
|
+
expected,
|
|
736
|
+
rows,
|
|
737
|
+
stale,
|
|
738
|
+
reembedQueued: stale > 0 && this.queueReembed(`${stale} of ${rows} vectors are from another model or quantization`).length > 0,
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* The same reading without the queue: opening the Memory tab must not put a job in the daemon's queue,
|
|
744
|
+
* and a process holding the store read-only could not write one. `indexIdentity` is this plus the queue.
|
|
745
|
+
*/
|
|
746
|
+
indexSummary(): { expected: EmbedIdentity | null; rows: number; stale: number } {
|
|
747
|
+
const expected = this.embedder.identity;
|
|
748
|
+
const all = this.db
|
|
749
|
+
.prepare("SELECT embed_model AS model, embed_quantized AS quantized, count(*) AS n FROM memories WHERE embedding IS NOT NULL AND status = 'active' GROUP BY embed_model, embed_quantized")
|
|
750
|
+
.all() as { model: string; quantized: number; n: number }[];
|
|
751
|
+
const rows = all.reduce((total, group) => total + group.n, 0);
|
|
752
|
+
const stale = all
|
|
753
|
+
.filter((group) => group.model !== expected?.model || Boolean(group.quantized) !== expected?.quantized)
|
|
754
|
+
.reduce((total, group) => total + group.n, 0);
|
|
755
|
+
return { expected, rows, stale };
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** Queue `memory.reembed`. An already-open job is not queued twice, but it is made due again. */
|
|
759
|
+
queueReembed(reason: string): number[] {
|
|
760
|
+
return this.queueJob("memory.reembed", { payload: { reason } });
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Re-embed what the current index cannot represent — which is exactly what switching embedding models
|
|
765
|
+
* leaves behind (every bge-small vector, and anything written before embeddings were on). Media is
|
|
766
|
+
* re-embedded from the bytes in the database, so a file that has since moved does not lose its memory.
|
|
767
|
+
*
|
|
768
|
+
* The report is the point: “semantic search now covers 8 of your 21 memories” has to be sayable before
|
|
769
|
+
* the switch rather than discovered a week later. Rows the model refuses stay unindexed and say why.
|
|
770
|
+
* Only `active` rows are touched — the same set `vectorSearch` reads, which is what lets the report
|
|
771
|
+
* above reach zero.
|
|
772
|
+
*/
|
|
773
|
+
async reembed(actor = "reembed"): Promise<{ embedded: number[]; skipped: { id: number; why: string }[] }> {
|
|
774
|
+
const identity = this.embedder.identity;
|
|
775
|
+
if (!identity) return { embedded: [], skipped: [{ id: 0, why: this.embedder.error ?? "embeddings are off" }] };
|
|
776
|
+
const stale = this.db
|
|
777
|
+
.prepare(
|
|
778
|
+
`SELECT id, body, modality FROM memories
|
|
779
|
+
WHERE status = 'active' AND (embedding IS NULL OR embed_model IS NOT ? OR embed_quantized IS NOT ?)
|
|
780
|
+
ORDER BY id`,
|
|
781
|
+
)
|
|
782
|
+
.all(identity.model, identity.quantized ? 1 : 0) as { id: number; body: string; modality: string }[];
|
|
783
|
+
const embedded: number[] = [];
|
|
784
|
+
const skipped: { id: number; why: string }[] = [];
|
|
785
|
+
for (const row of stale) {
|
|
786
|
+
let result: EmbedResult;
|
|
787
|
+
if (row.modality === "text") {
|
|
788
|
+
result = await this.embedder.embedDocument(row.body);
|
|
789
|
+
} else {
|
|
790
|
+
const media = this.mediaBytes(row.id);
|
|
791
|
+
if (!media) {
|
|
792
|
+
skipped.push({ id: row.id, why: "no media row is attached to it" });
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
try {
|
|
796
|
+
result = await this.embedder.embedMedia(media);
|
|
797
|
+
} catch (error) {
|
|
798
|
+
skipped.push({ id: row.id, why: `its stored bytes could not be decoded: ${(error as Error).message}` });
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
if (!result.ok) {
|
|
803
|
+
skipped.push({ id: row.id, why: result.error });
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
this.audit(row.id, "reembed", actor, this.memory(row.id), `re-embedded with ${identity.model}`);
|
|
807
|
+
this.db
|
|
808
|
+
.prepare("UPDATE memories SET embedding = ?, embed_model = ?, embed_quantized = ?, dims = ?, embedded_at = ? WHERE id = ?")
|
|
809
|
+
.run(Buffer.from(result.vector.buffer), identity.model, identity.quantized ? 1 : 0, result.vector.length, now(), row.id);
|
|
810
|
+
embedded.push(row.id);
|
|
811
|
+
}
|
|
812
|
+
// The job closes when nothing is left for it to fix — not merely because this pass wrote something.
|
|
813
|
+
// A row the model refuses has no vector and is not a stale index (the write path reports it), so it
|
|
814
|
+
// does not hold the job open.
|
|
815
|
+
const left = this.db
|
|
816
|
+
.prepare("SELECT count(*) AS n FROM memories WHERE status = 'active' AND embedding IS NOT NULL AND (embed_model IS NOT ? OR embed_quantized IS NOT ?)")
|
|
817
|
+
.get(identity.model, identity.quantized ? 1 : 0) as { n: number };
|
|
818
|
+
if (left.n === 0) this.db.prepare("DELETE FROM jobs WHERE kind = 'memory.reembed' AND locked_by IS NULL").run();
|
|
819
|
+
return { embedded, skipped };
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Every active row, oldest first — what a consolidation pass clusters over. It reads every status the
|
|
824
|
+
* search filters out of, because the sweep's whole job is the rows nobody queries.
|
|
825
|
+
*/
|
|
826
|
+
activeMemories(): MemoryRow[] {
|
|
827
|
+
return (this.db.prepare("SELECT * FROM memories WHERE status = 'active' ORDER BY id").all() as unknown as Record<string, unknown>[]).map((row) =>
|
|
828
|
+
this.mapRow(row),
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* The vectors of the active rows in the **current** index, keyed by id — what the sweep clusters with.
|
|
834
|
+
* A row with no vector, or one from another model or quantization, is absent rather than guessed at: a
|
|
835
|
+
* vector is only comparable inside one index (risk 7), and those rows are what `memory.reembed` fixes.
|
|
836
|
+
* shortcut: one scan holding every vector, like `vectorSearch`'s full scan; the same place `sqlite-vec`
|
|
837
|
+
* would plug in when the store outgrows it.
|
|
838
|
+
*/
|
|
839
|
+
activeVectors(): Map<number, Float32Array> {
|
|
840
|
+
const identity = this.embedder.identity;
|
|
841
|
+
if (!identity) return new Map();
|
|
842
|
+
const rows = this.db
|
|
843
|
+
.prepare(
|
|
844
|
+
`SELECT id, embedding FROM memories
|
|
845
|
+
WHERE status = 'active' AND embedding IS NOT NULL AND embed_model = ? AND embed_quantized = ?`,
|
|
846
|
+
)
|
|
847
|
+
.all(identity.model, identity.quantized ? 1 : 0) as { id: number; embedding: Uint8Array }[];
|
|
848
|
+
return new Map(
|
|
849
|
+
rows.map((row) => [row.id, new Float32Array(row.embedding.buffer, row.embedding.byteOffset, row.embedding.byteLength / 4)]),
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Every turn's question, what it was shown and what it cited — the promotion gates' evidence, straight out
|
|
855
|
+
* of `turns.retrieved_json`/`cited_json`. Immutable history: a merge follows these citations at read time
|
|
856
|
+
* (src/memory/consolidate.ts) rather than rewriting what a turn actually wrote.
|
|
857
|
+
*/
|
|
858
|
+
citationEvidence(): { userText: string; retrieved: number[]; cited: number[] }[] {
|
|
859
|
+
return (this.db.prepare("SELECT user_text, retrieved_json, cited_json FROM turns ORDER BY id").all() as unknown as { user_text: string; retrieved_json: string | null; cited_json: string | null }[]).map(
|
|
860
|
+
(turn) => ({
|
|
861
|
+
userText: turn.user_text,
|
|
862
|
+
retrieved: turn.retrieved_json ? (JSON.parse(turn.retrieved_json) as number[]) : [],
|
|
863
|
+
cited: turn.cited_json ? (JSON.parse(turn.cited_json) as number[]) : [],
|
|
864
|
+
}),
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/** How many memory rows exist, in any status. "No automatic process makes a row disappear" reads this. */
|
|
869
|
+
memoryRowCount(): number {
|
|
870
|
+
return Number((this.db.prepare("SELECT count(*) AS n FROM memories").get() as unknown as { n: number }).n);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* What the sweep's trigger reads: rows written since the last sweep that *ran*, and when that was. The
|
|
875
|
+
* last sweep is the last successful `memory.consolidate` attempt in `job_runs`, so the queue's own record
|
|
876
|
+
* is the clock and there is no second piece of state to keep in step.
|
|
877
|
+
*/
|
|
878
|
+
sweepState(): { newRows: number; lastSweepAt: string | null } {
|
|
879
|
+
const row = this.db
|
|
880
|
+
.prepare(
|
|
881
|
+
`SELECT (SELECT count(*) FROM memories WHERE created_at > coalesce(
|
|
882
|
+
(SELECT max(finished_at) FROM job_runs WHERE kind = 'memory.consolidate' AND status = 'ok'), '')) AS new_rows,
|
|
883
|
+
(SELECT max(finished_at) FROM job_runs WHERE kind = 'memory.consolidate' AND status = 'ok') AS last_sweep`,
|
|
884
|
+
)
|
|
885
|
+
.get() as unknown as { new_rows: number; last_sweep: string | null };
|
|
886
|
+
return { newRows: row.new_rows, lastSweepAt: row.last_sweep };
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* The merge, exactly as the plan requires it: N active rows collapse to one survivor, the N go
|
|
891
|
+
* `superseded` (never deleted, each with its pre-image in `memory_audit`), `merged_from` records them on
|
|
892
|
+
* the survivor, and their counters move with them — which is what stops two copies of a fact from being
|
|
893
|
+
* permanently one criterion short of a promotion gate each.
|
|
894
|
+
*/
|
|
895
|
+
applyMerge(merge: { survivor: number; merged: number[] }, actor: string): void {
|
|
896
|
+
const survivor = this.memory(merge.survivor);
|
|
897
|
+
if (!survivor) throw new Error(`memory ${merge.survivor} does not exist`);
|
|
898
|
+
// The pre-image of the row that is about to absorb the others, taken before anything moves.
|
|
899
|
+
this.audit(survivor.id, "edit", actor, survivor, `merged ${merge.merged.map((id) => `#${id}`).join(", ")} into it`);
|
|
900
|
+
let cited = survivor.cited_count;
|
|
901
|
+
let used = survivor.use_count;
|
|
902
|
+
for (const id of merge.merged) {
|
|
903
|
+
const row = this.memory(id);
|
|
904
|
+
if (!row) continue;
|
|
905
|
+
cited += row.cited_count;
|
|
906
|
+
used += row.use_count;
|
|
907
|
+
this.markSuperseded(id, survivor.id, actor, `consolidation: the same fact as #${survivor.id}`);
|
|
908
|
+
}
|
|
909
|
+
this.db
|
|
910
|
+
.prepare("UPDATE memories SET merged_from = ?, cited_count = ?, use_count = ? WHERE id = ?")
|
|
911
|
+
.run(JSON.stringify([...survivor.merged_from, ...merge.merged]), cited, used, survivor.id);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* Promotion is a rank change and nothing else: `strength` up, `last_verified_at` now, and a `verify`
|
|
916
|
+
* audit row saying the gates it cleared. It never writes a fact and never removes one.
|
|
917
|
+
*/
|
|
918
|
+
applyPromotion(id: number, actor: string): void {
|
|
919
|
+
const row = this.memory(id);
|
|
920
|
+
if (!row) throw new Error(`memory ${id} does not exist`);
|
|
921
|
+
this.audit(id, "verify", actor, row, "promotion gates: cited by three turns across three questions, and used when it was fetched");
|
|
922
|
+
this.db.prepare("UPDATE memories SET strength = ?, last_verified_at = ? WHERE id = ?").run(Math.max(row.strength, PROMOTED_STRENGTH), now(), id);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* The browser's edit of one row's text, kind or tags. Provenance is not editable — `origin_class` and
|
|
927
|
+
* `session_kind` are code's, never an argument (docs/memory.md Phase 4) — and the body goes through the
|
|
928
|
+
* same redaction as a write, so editing cannot put a credential into a row a write would have refused.
|
|
929
|
+
*/
|
|
930
|
+
editMemory(id: number, patch: { body?: string; kind?: string; tags?: string[] }, actor: string, reason?: string): MemoryRow {
|
|
931
|
+
const row = this.memory(id);
|
|
932
|
+
if (!row) throw new Error(`memory ${id} does not exist`);
|
|
933
|
+
const body = patch.body === undefined ? row.body : redactSecrets(patch.body);
|
|
934
|
+
const kind = patch.kind ?? row.kind;
|
|
935
|
+
const tags = patch.tags ?? row.tags;
|
|
936
|
+
if (body === row.body && kind === row.kind && tags.join("\u0000") === row.tags.join("\u0000")) return row;
|
|
937
|
+
this.audit(id, "edit", actor, row, reason ?? "edited in the memory browser");
|
|
938
|
+
this.db.prepare("UPDATE memories SET body = ?, kind = ?, tags = ? WHERE id = ?").run(body, kind, JSON.stringify(tags), id);
|
|
939
|
+
return this.memory(id)!;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* Undo one audited change by putting its pre-image back (docs/memory.md Phase 6: "undo for anything a
|
|
944
|
+
* sweep did — with … undo restores a pre-image and changes nothing else"). Only the columns a pre-image
|
|
945
|
+
* carries are restored: the vector, the media bytes and the row's provenance did not change, so they stay
|
|
946
|
+
* exactly as they were. A `create` entry has nothing before it — undoing a write would be deleting a
|
|
947
|
+
* fact, and that is `forget`'s job, which a human asks for.
|
|
948
|
+
*/
|
|
949
|
+
undo(memoryId: number, auditId: number, actor: string): MemoryRow {
|
|
950
|
+
const entry = this.db.prepare("SELECT id, action, pre_image FROM memory_audit WHERE id = ? AND memory_id = ?").get(auditId, memoryId) as
|
|
951
|
+
| { id: number; action: AuditAction; pre_image: string }
|
|
952
|
+
| undefined;
|
|
953
|
+
if (!entry) throw new Error(`no audit entry #${auditId} on memory #${memoryId}`);
|
|
954
|
+
const before = JSON.parse(entry.pre_image) as Partial<MemoryRow>;
|
|
955
|
+
if (entry.action === "create" || Object.keys(before).length === 0) {
|
|
956
|
+
throw new Error(`audit #${auditId} wrote memory #${memoryId}: undo puts back what was there before a change, it does not un-write a fact`);
|
|
957
|
+
}
|
|
958
|
+
const current = this.memory(memoryId);
|
|
959
|
+
if (!current) throw new Error(`memory ${memoryId} does not exist`);
|
|
960
|
+
// The column list is a fixed whitelist, not text from the pre-image: an audit row is data, and data
|
|
961
|
+
// does not get to name columns.
|
|
962
|
+
const set: string[] = [];
|
|
963
|
+
const values: (string | number | null)[] = [];
|
|
964
|
+
const put = (column: string, value: string | number | null): void => {
|
|
965
|
+
set.push(`${column} = ?`);
|
|
966
|
+
values.push(value);
|
|
967
|
+
};
|
|
968
|
+
if (typeof before.body === "string") put("body", redactSecrets(before.body));
|
|
969
|
+
if (typeof before.kind === "string") put("kind", before.kind);
|
|
970
|
+
if (before.tags) put("tags", JSON.stringify(before.tags));
|
|
971
|
+
if (before.status) put("status", before.status);
|
|
972
|
+
if ("supersedes" in before) put("supersedes", before.supersedes ?? null);
|
|
973
|
+
if ("superseded_by" in before) put("superseded_by", before.superseded_by ?? null);
|
|
974
|
+
if (before.merged_from) put("merged_from", JSON.stringify(before.merged_from));
|
|
975
|
+
if (typeof before.strength === "number") put("strength", before.strength);
|
|
976
|
+
if (typeof before.use_count === "number") put("use_count", before.use_count);
|
|
977
|
+
if (typeof before.cited_count === "number") put("cited_count", before.cited_count);
|
|
978
|
+
if ("last_verified_at" in before) put("last_verified_at", before.last_verified_at ?? null);
|
|
979
|
+
this.audit(memoryId, "edit", actor, current, `undo of ${entry.action} (audit #${entry.id})`);
|
|
980
|
+
this.db.prepare(`UPDATE memories SET ${set.join(", ")} WHERE id = ?`).run(...values, memoryId);
|
|
981
|
+
return this.memory(memoryId)!;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* Queue a job. Jobs are the only path a background writer takes. One row per open kind: asking again
|
|
986
|
+
* brings the existing row forward rather than adding a second copy of the same work.
|
|
987
|
+
*/
|
|
988
|
+
queueJob(kind: string, { payload = {}, runAt }: { payload?: unknown; runAt?: string } = {}): number[] {
|
|
989
|
+
const at = runAt ?? now();
|
|
990
|
+
const existing = this.db.prepare("SELECT id FROM jobs WHERE kind = ? AND locked_by IS NULL").all(kind) as { id: number }[];
|
|
991
|
+
if (existing.length > 0) {
|
|
992
|
+
// The row is there, but this call has new work for it, so it is due now: a pass that ran left it
|
|
993
|
+
// an hour out (src/daemon/jobs.ts), and a stale index must not wait that long.
|
|
994
|
+
this.db.prepare("UPDATE jobs SET next_run_at = ? WHERE kind = ? AND locked_by IS NULL").run(at, kind);
|
|
995
|
+
return existing.map((row) => row.id);
|
|
996
|
+
}
|
|
997
|
+
const result = this.db.prepare("INSERT INTO jobs (kind, next_run_at, payload) VALUES (?, ?, ?)").run(kind, at, JSON.stringify(payload));
|
|
998
|
+
return [Number(result.lastInsertRowid)];
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
audit(memoryId: number, action: AuditAction, actor: string, preImage: unknown, reason?: string): void {
|
|
1002
|
+
this.db
|
|
1003
|
+
.prepare("INSERT INTO memory_audit (memory_id, action, actor, pre_image, reason, created_at) VALUES (?, ?, ?, ?, ?, ?)")
|
|
1004
|
+
.run(memoryId, action, actor, JSON.stringify(preImage ?? {}), reason ?? null, now());
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/** What the audit says a memory went through, oldest first — the browser's "why" view reads this. */
|
|
1008
|
+
history(memoryId: number): AuditEntry[] {
|
|
1009
|
+
return (this.db.prepare("SELECT id, action, actor, pre_image, reason, created_at FROM memory_audit WHERE memory_id = ? ORDER BY id ASC").all(memoryId) as unknown as Record<string, string>[]).map(
|
|
1010
|
+
(row) => ({
|
|
1011
|
+
id: Number(row.id),
|
|
1012
|
+
action: row.action as AuditEntry["action"],
|
|
1013
|
+
actor: row.actor ?? "",
|
|
1014
|
+
pre_image: row.pre_image ? JSON.parse(row.pre_image) : null,
|
|
1015
|
+
reason: row.reason ?? null,
|
|
1016
|
+
created_at: row.created_at ?? "",
|
|
1017
|
+
}),
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
private mapRow(row: Record<string, unknown>): MemoryRow {
|
|
1022
|
+
const { embedding: _embedding, embed_model: model, dims, embed_quantized: quantized, tags, merged_from, ...rest } = row;
|
|
1023
|
+
const media = rest.modality === "text" ? undefined : this.mediaBytes(Number(row.id));
|
|
1024
|
+
return {
|
|
1025
|
+
...rest,
|
|
1026
|
+
tags: JSON.parse((tags as string) || "[]"),
|
|
1027
|
+
merged_from: JSON.parse((merged_from as string) || "[]") as number[],
|
|
1028
|
+
embed: model ? { model: model as string, dims: dims as number, quantized: Boolean(quantized) } : null,
|
|
1029
|
+
media: media
|
|
1030
|
+
? { id: Number(row.id), kind: media.kind, mime: media.mime, path: media.path, bytes_size: media.bytes.byteLength }
|
|
1031
|
+
: null,
|
|
1032
|
+
} as MemoryRow;
|
|
1033
|
+
}
|
|
1034
|
+
}
|