@makerstufferupper/flypi 0.0.0-stage → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +64 -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 +310 -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 +38 -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,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeping flypi up to date (docs/architecture.md Phase 7): is a newer flypi published, is it downloaded
|
|
3
|
+
* already, and when may it be installed?
|
|
4
|
+
*
|
|
5
|
+
* `npm run release` is the only thing that publishes, and package.json is the version it bumps, the version
|
|
6
|
+
* `/healthz` reports and the version `flypi status` prints — so the registry's `latest` is the single answer
|
|
7
|
+
* to "is there a new package", with no second version file to keep in step.
|
|
8
|
+
*
|
|
9
|
+
* The daemon **downloads** the new package (once a day at most, ~0.4 MB, verified against the checksum the
|
|
10
|
+
* registry published with it) and **installs it only while the daemon is idle** — no page attached and no
|
|
11
|
+
* turn in flight, held for a stretch, the same idle the memory sweep uses. Installing replaces the files the
|
|
12
|
+
* daemon is running from, so it is followed by a restart through the CLI; a restart while someone is reading
|
|
13
|
+
* an answer would be the one thing this must not do.
|
|
14
|
+
*
|
|
15
|
+
* **One version at a time.** The version to move to is the next one published above the running one — never
|
|
16
|
+
* the newest. flypi's own databases (the memory store, the run record) carry a schema, and a schema upgrade
|
|
17
|
+
* is code that runs when the version that needs it starts: 0.1.0 → 0.3.0 would run 0.3.0's migration against
|
|
18
|
+
* 0.1.0's schema and skip whatever 0.2.0 would have done to it. So the ladder is walked one published release
|
|
19
|
+
* per install, each step starting on its own and migrating what it finds, and the stamp is cleared after a
|
|
20
|
+
* step so the next one is taken from a fresh look rather than from the same answer. When a package has
|
|
21
|
+
* skipped numbers, the next published release is the step — that is the ladder npm still has.
|
|
22
|
+
*
|
|
23
|
+
* A checkout is not an installed package: it has `.git` next to the code, it updates with git, and the
|
|
24
|
+
* registry has nothing to compare it against. Every entry point here says so rather than checking anyway.
|
|
25
|
+
*/
|
|
26
|
+
import { createHash } from "node:crypto";
|
|
27
|
+
import { spawnSync } from "node:child_process";
|
|
28
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
29
|
+
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
30
|
+
|
|
31
|
+
/** How long an answer stays good — the same day-long rhythm the memory sweep keeps. */
|
|
32
|
+
export const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
33
|
+
/** How long the daemon must have been idle before a downloaded update installs. The job loop passes once a
|
|
34
|
+
* minute, so this is measured in passes; a lull shorter than this is not a restart. */
|
|
35
|
+
export const IDLE_BEFORE_INSTALL_MS = 10 * 60_000;
|
|
36
|
+
/** The answer, kept next to the pid file so `flypi status` can report it without a network round trip. */
|
|
37
|
+
export const UPDATE_CHECK_FILE = join(".flypi", "update-check.json");
|
|
38
|
+
/** Where a downloaded package waits. One version at a time — `downloadUpdate` clears the rest. */
|
|
39
|
+
export const UPDATE_DIR = join(".flypi", "updates");
|
|
40
|
+
|
|
41
|
+
export interface UpdateDist {
|
|
42
|
+
tarball: string;
|
|
43
|
+
/** `sha512-<base64>`, what npm publishes; `shasum` is the older sha1 hex form it also publishes. */
|
|
44
|
+
integrity?: string;
|
|
45
|
+
shasum?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface UpdateCheck {
|
|
49
|
+
checkedAt: string;
|
|
50
|
+
/** The version installed when the answer was written. `current` in an old stamp is not today's version. */
|
|
51
|
+
current: string;
|
|
52
|
+
/** The newest published version — information only; nothing here installs it. */
|
|
53
|
+
latest: string | null;
|
|
54
|
+
/** The version to move to: the next one published above `current`. Null when there is none. */
|
|
55
|
+
next: string | null;
|
|
56
|
+
error?: string;
|
|
57
|
+
/** The package for `next` — what gets downloaded and installed. */
|
|
58
|
+
dist?: UpdateDist;
|
|
59
|
+
/** The package already downloaded and verified, waiting for an idle moment to install. */
|
|
60
|
+
download?: { version: string; file: string; at: string };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The directory the running code ships in — not `paths.root`, which is the state directory. */
|
|
64
|
+
export function packageDir(): string {
|
|
65
|
+
return resolve(import.meta.dirname, "..", "..");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** What the installed package is called, and which version of it this is. */
|
|
69
|
+
export function packageInfo(dir = packageDir()): { name: string; version: string; checkout: boolean } {
|
|
70
|
+
const pkg = JSON.parse(readFileSync(resolve(dir, "package.json"), "utf8")) as { name?: string; version?: string };
|
|
71
|
+
// A checkout updates with `git pull`; the registry only knows what `npm publish` put there.
|
|
72
|
+
return { name: pkg.name ?? "", version: pkg.version ?? "dev", checkout: existsSync(resolve(dir, ".git")) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Version order, by number: 0.10.0 is newer than 0.9.0, so the parts are not text. A prerelease suffix sorts
|
|
77
|
+
* as its base version — npm keeps those under the `next` tag, and `published()` filters them out anyway.
|
|
78
|
+
*/
|
|
79
|
+
export function compareVersions(a: string, b: string): number {
|
|
80
|
+
const parts = (v: string) => (v.split("-")[0] ?? v).split(".").map((p) => Number(p) || 0);
|
|
81
|
+
const [left, right] = [parts(a), parts(b)];
|
|
82
|
+
for (let i = 0; i < Math.max(left.length, right.length); i++) {
|
|
83
|
+
const diff = (left[i] ?? 0) - (right[i] ?? 0);
|
|
84
|
+
if (diff !== 0) return diff;
|
|
85
|
+
}
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Newest first, by number. */
|
|
90
|
+
export function isNewer(installed: string, latest: string): boolean {
|
|
91
|
+
return compareVersions(latest, installed) > 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The published versions of a package, released ones only: a prerelease is not a rung anyone should be
|
|
96
|
+
* stepped up through, and npm does not put one in `dist-tags.latest` either.
|
|
97
|
+
*/
|
|
98
|
+
export function published(versions: Record<string, unknown>): string[] {
|
|
99
|
+
return Object.keys(versions).filter((v) => !v.includes("-")).sort(compareVersions);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The one step above `current` — the whole point of the ladder (see the note at the top of this file). Null
|
|
104
|
+
* when nothing newer has been published, and when the registry gave no version list to build a ladder from:
|
|
105
|
+
* guessing a jump is exactly what this rule exists to prevent.
|
|
106
|
+
*/
|
|
107
|
+
export function nextVersion(versions: Record<string, unknown>, current: string): string | null {
|
|
108
|
+
return published(versions).find((v) => isNewer(current, v)) ?? null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The step to take, from a stamp written by any install: null when there is nothing newer to move to. A stamp
|
|
113
|
+
* written by an older install names a step that has since been taken, and `next` is re-checked against the
|
|
114
|
+
* version running now for that reason.
|
|
115
|
+
*/
|
|
116
|
+
export function updateAvailable(check: UpdateCheck | null, current: string): string | null {
|
|
117
|
+
return check?.next && isNewer(current, check.next) ? check.next : null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The version sitting on disk ready to install — only when it is newer than what is running. A stamp left by
|
|
122
|
+
* an older install still names a tarball that was already applied, and installing it again would be a
|
|
123
|
+
* restart for nothing.
|
|
124
|
+
*/
|
|
125
|
+
export function stagedUpdate(check: UpdateCheck | null, current: string): string | null {
|
|
126
|
+
return check?.download?.version && isNewer(current, check.download.version) ? check.download.version : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Is the answer too old to trust (or absent)? */
|
|
130
|
+
export function dueForCheck(check: UpdateCheck | null, now = Date.now()): boolean {
|
|
131
|
+
const at = Date.parse(check?.checkedAt ?? "");
|
|
132
|
+
return Number.isNaN(at) || now - at >= CHECK_INTERVAL_MS;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Ask the registry for the package document — `dist-tags.latest` and every published version with its
|
|
137
|
+
* `dist` — in one request, because the ladder needs the version list, not just the top of it.
|
|
138
|
+
*
|
|
139
|
+
* `fetchImpl` is injectable because a test must not reach the network, and a registry that is down, offline
|
|
140
|
+
* or has never heard of this package (404 — where this one is until `npm run release` publishes it) is an
|
|
141
|
+
* answer, not a crash.
|
|
142
|
+
*/
|
|
143
|
+
export async function checkForUpdate(options: { current: string; name: string; fetchImpl?: typeof fetch; timeoutMs?: number }): Promise<UpdateCheck> {
|
|
144
|
+
const at = new Date().toISOString();
|
|
145
|
+
const fail = (error: string): UpdateCheck => ({ checkedAt: at, current: options.current, latest: null, next: null, error });
|
|
146
|
+
const url = `https://registry.npmjs.org/${encodeURIComponent(options.name)}`;
|
|
147
|
+
try {
|
|
148
|
+
const response = await (options.fetchImpl ?? fetch)(url, { signal: AbortSignal.timeout(options.timeoutMs ?? 5_000), headers: { accept: "application/json" } });
|
|
149
|
+
if (!response.ok) return fail(`the registry answered ${response.status} for ${options.name}`);
|
|
150
|
+
const doc = (await response.json()) as { "dist-tags"?: { latest?: unknown }; versions?: Record<string, { dist?: UpdateDist }> };
|
|
151
|
+
const versions = doc.versions ?? {};
|
|
152
|
+
if (Object.keys(versions).length === 0) return fail(`the registry named no published versions of ${options.name} to step through`);
|
|
153
|
+
const latest = typeof doc["dist-tags"]?.latest === "string" ? doc["dist-tags"].latest : published(versions).at(-1) ?? null;
|
|
154
|
+
const next = nextVersion(versions, options.current);
|
|
155
|
+
return {
|
|
156
|
+
checkedAt: at,
|
|
157
|
+
current: options.current,
|
|
158
|
+
latest,
|
|
159
|
+
next,
|
|
160
|
+
...(next && versions[next]?.dist?.tarball ? { dist: versions[next].dist } : {}),
|
|
161
|
+
};
|
|
162
|
+
} catch (error) {
|
|
163
|
+
return fail(`the registry could not be reached: ${(error as Error).message}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Fetch the package and keep it for the idle moment. The checksum the registry published with the manifest
|
|
169
|
+
* is the only proof the bytes are the ones it described, and these bytes become code that runs with the
|
|
170
|
+
* user's home directory in front of it — so a mismatch is deleted rather than staged, and a manifest with no
|
|
171
|
+
* checksum to verify against is refused rather than trusted.
|
|
172
|
+
*
|
|
173
|
+
* The tarball is flypi's own code (~0.4 MB); npm still resolves its dependencies at install time.
|
|
174
|
+
*/
|
|
175
|
+
export async function downloadUpdate(options: { root: string; version: string; dist: UpdateDist; fetchImpl?: typeof fetch; timeoutMs?: number }): Promise<{ file?: string; error?: string }> {
|
|
176
|
+
if (!options.dist.integrity?.startsWith("sha512-") && !options.dist.shasum) return { error: "the registry published no checksum to verify the download against" };
|
|
177
|
+
let bytes: Buffer;
|
|
178
|
+
try {
|
|
179
|
+
const response = await (options.fetchImpl ?? fetch)(options.dist.tarball, { signal: AbortSignal.timeout(options.timeoutMs ?? 120_000) });
|
|
180
|
+
if (!response.ok) return { error: `the registry answered ${response.status} for the package` };
|
|
181
|
+
bytes = Buffer.from(await response.arrayBuffer());
|
|
182
|
+
} catch (error) {
|
|
183
|
+
return { error: `the package could not be downloaded: ${(error as Error).message}` };
|
|
184
|
+
}
|
|
185
|
+
if (options.dist.integrity?.startsWith("sha512-")) {
|
|
186
|
+
const got = createHash("sha512").update(bytes).digest("base64");
|
|
187
|
+
if (got !== options.dist.integrity.slice("sha512-".length)) return { error: "the downloaded package does not match the registry's sha512" };
|
|
188
|
+
} else if (createHash("sha1").update(bytes).digest("hex") !== options.dist.shasum) {
|
|
189
|
+
return { error: "the downloaded package does not match the registry's sha1" };
|
|
190
|
+
}
|
|
191
|
+
const dir = resolve(options.root, UPDATE_DIR);
|
|
192
|
+
mkdirSync(dir, { recursive: true });
|
|
193
|
+
// One version waiting at a time: the old tarball is code that is no longer going to run.
|
|
194
|
+
for (const old of readdirSync(dir)) if (!old.includes(options.version)) rmSync(join(dir, old), { force: true });
|
|
195
|
+
const file = join(dir, basename(options.dist.tarball));
|
|
196
|
+
writeFileSync(file, bytes);
|
|
197
|
+
return { file };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function readUpdateCheck(root: string): UpdateCheck | null {
|
|
201
|
+
try {
|
|
202
|
+
return JSON.parse(readFileSync(resolve(root, UPDATE_CHECK_FILE), "utf8")) as UpdateCheck;
|
|
203
|
+
} catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function writeUpdateCheck(root: string, check: UpdateCheck): void {
|
|
209
|
+
// The `.flypi/` directory is created here, not assumed: `flypi update --check` works on a root that has
|
|
210
|
+
// never run a daemon, where neither the stamp nor the directory exists yet.
|
|
211
|
+
const file = resolve(root, UPDATE_CHECK_FILE);
|
|
212
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
213
|
+
writeFileSync(file, `${JSON.stringify(check, null, 2)}\n`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** What installs the downloaded tarball. Pinned to the file that was just verified, so what installs is
|
|
217
|
+
* what was checked; `--no-audit --no-fund` because this runs in front of a person, not in CI. */
|
|
218
|
+
export function installArgs(file: string): string[] {
|
|
219
|
+
return ["install", "-g", "--no-audit", "--no-fund", file];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Is the running code the global package `npm install -g` would replace? flypi installed through pnpm, bun,
|
|
224
|
+
* npx or a second prefix would leave the install landing somewhere else, and the restart would come back on
|
|
225
|
+
* the old code while the log said it had updated.
|
|
226
|
+
*/
|
|
227
|
+
export function isGlobalInstall(dir = packageDir()): boolean {
|
|
228
|
+
const out = spawnSync("npm", ["root", "-g"], { encoding: "utf8" });
|
|
229
|
+
if (out.status !== 0) return false;
|
|
230
|
+
const root = out.stdout.trim();
|
|
231
|
+
if (!root) return false;
|
|
232
|
+
const here = resolve(dir);
|
|
233
|
+
return here === root || here.startsWith(root + sep);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** A download is only worth keeping when it is still the step this version should take. */
|
|
237
|
+
export function keepDownload(check: UpdateCheck, previous: UpdateCheck | null): UpdateCheck["download"] {
|
|
238
|
+
return previous?.download && previous.download.version === check.next ? previous.download : undefined;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Forget the answer after a step has been taken. The next version looks again — a fresh ladder from the
|
|
243
|
+
* version that is now running, and the next single step — rather than installing the rest of what an older
|
|
244
|
+
* answer had already picked out.
|
|
245
|
+
*/
|
|
246
|
+
export function clearUpdateCheck(root: string): void {
|
|
247
|
+
rmSync(resolve(root, UPDATE_CHECK_FILE), { force: true });
|
|
248
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-capture v1 (docs/memory.md Phase 4: "heuristic capture on `agent_end` … cheap rules, no extra
|
|
3
|
+
* model call"). Two kinds of durable fact are recoverable without understanding anything: a sentence the
|
|
4
|
+
* user wrote *as* an instruction ("remember …", "we never …"), and a command the agent watched fail and
|
|
5
|
+
* then work on the second try.
|
|
6
|
+
*
|
|
7
|
+
* The body of a captured fact stays the user's own words. Rewriting it would put flypi's paraphrase in
|
|
8
|
+
* front of a model later and lose what was actually said — and the write gate (`NEAR_DUPLICATE`,
|
|
9
|
+
* `valueSwap` in store.ts) is what stops a capture from becoming a second copy of a fact the model
|
|
10
|
+
* already stored. Provenance is code's as everywhere else: the user's words are `owner`, what the agent
|
|
11
|
+
* watched happen is `agent`, and neither is an argument a caller can pass.
|
|
12
|
+
*/
|
|
13
|
+
import type { OriginClass } from "./store.ts";
|
|
14
|
+
|
|
15
|
+
export interface CapturedFact {
|
|
16
|
+
body: string;
|
|
17
|
+
kind: string;
|
|
18
|
+
origin: OriginClass;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** What one turn's tools did, in order — the parts of pi's `tool_result` event a rule can use. */
|
|
22
|
+
export interface ToolOutcome {
|
|
23
|
+
name: string;
|
|
24
|
+
/** A shell command, when the tool ran one. That is the only outcome worth comparing. */
|
|
25
|
+
command?: string;
|
|
26
|
+
isError: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** One turn is not a place to write an essay about. */
|
|
30
|
+
const MAX_FACTS = 3;
|
|
31
|
+
/** "not anymore" is a fragment; a fact needs a subject and something said about it. */
|
|
32
|
+
const MIN_WORDS = 4;
|
|
33
|
+
/** The MCP surface caps a body at 8000; a captured sentence never needs near that much. */
|
|
34
|
+
const MAX_CHARS = 500;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The user writing something down on purpose. Global because one prompt can state two facts. It stops at a
|
|
38
|
+
* sentence end — a period followed by space or the end — so one statement cannot swallow the next one,
|
|
39
|
+
* while a dotted path inside the fact stays part of it.
|
|
40
|
+
*/
|
|
41
|
+
const STATED = /\b(?:remember|note that|note this|keep in mind|from now on|going forward)\b[:,]?\s*(?:that\s+)?((?:[^.!?]|\.(?!\s|$))+)/gi;
|
|
42
|
+
/** The marker on its own, for the sentence-level test below (a global regex is stateful in `.test`). */
|
|
43
|
+
const STATED_MARK = /\b(?:remember|note that|note this|keep in mind|from now on|going forward)\b/i;
|
|
44
|
+
/** A standing instruction or a correction — the "user corrections" half, in the words people use for it. */
|
|
45
|
+
const STANDING = /\b(never|always|no longer|not anymore|anymore|instead of|prefer|only use|do not|don't|does not|doesn't|must not|use instead)\b/i;
|
|
46
|
+
// shortcut: a word list, so it reads "I never said anything about it" as an instruction and misses a
|
|
47
|
+
// correction phrased unusually. If that noise shows up in the store, the upgrade is the NLI judge already
|
|
48
|
+
// sitting behind the write gate (src/memory/judge.ts) judging these too, not a longer list here.
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* What this turn is worth remembering, in the order it was said. Deliberately conservative: a capture
|
|
52
|
+
* that writes junk costs a row and a retrieval slot forever, while a fact it misses is one the model can
|
|
53
|
+
* still be asked to store — so every rule here has to be something a sentence states outright.
|
|
54
|
+
*/
|
|
55
|
+
export function captureFromTurn(prompt: string, tools: ToolOutcome[] = []): CapturedFact[] {
|
|
56
|
+
const facts: CapturedFact[] = [];
|
|
57
|
+
const add = (fact: CapturedFact | undefined) => {
|
|
58
|
+
if (fact && !facts.some((existing) => existing.body === fact.body)) facts.push(fact);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
for (const match of prompt.matchAll(STATED)) add(factOf(match[1], "fact", "owner"));
|
|
62
|
+
for (const sentence of sentences(prompt)) {
|
|
63
|
+
// Already handled as a stated fact: capturing it twice, once with the "remember" prefix and once
|
|
64
|
+
// without, is two rows for one instruction.
|
|
65
|
+
if (STATED_MARK.test(sentence)) continue;
|
|
66
|
+
if (STANDING.test(sentence)) add(factOf(sentence, "preference", "owner"));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Failed-then-fixed: the second command working after the first failed is a fact the agent earned, so it
|
|
70
|
+
// is `agent`-origin even though nobody typed it. Matched on the attempt — the same arguments, whatever
|
|
71
|
+
// runs them — because `npm run build` then `pnpm run build` is one fix, while `ls` then `cat` are two
|
|
72
|
+
// unrelated commands and calling the second one a fix would invent a fact.
|
|
73
|
+
const commands = tools.flatMap((tool) => (tool.command ? [{ command: tool.command, isError: tool.isError }] : []));
|
|
74
|
+
for (const failed of commands.filter((tool) => tool.isError)) {
|
|
75
|
+
const fix = commands.find((tool) => !tool.isError && tool.command !== failed.command && sameAttempt(failed.command, tool.command));
|
|
76
|
+
if (fix) add(factOf(`\`${failed.command}\` failed; \`${fix.command}\` worked`, "fact", "agent"));
|
|
77
|
+
}
|
|
78
|
+
return facts.slice(0, MAX_FACTS);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Sentence boundaries, kept simple: a captured fact must not run across two instructions. */
|
|
82
|
+
function sentences(prompt: string): string[] {
|
|
83
|
+
return prompt
|
|
84
|
+
.split(/(?<=[.!?])\s+|\n+/)
|
|
85
|
+
.map((sentence) => sentence.trim())
|
|
86
|
+
.filter(Boolean);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function factOf(text: string | undefined, kind: string, origin: OriginClass): CapturedFact | undefined {
|
|
90
|
+
const raw = (text ?? "").replace(/\s+/g, " ").trim().slice(0, MAX_CHARS);
|
|
91
|
+
// A question is someone asking, not someone telling: "don't we use pnpm?" states no preference.
|
|
92
|
+
if (!raw || raw.endsWith("?") || wordCount(raw) < MIN_WORDS) return undefined;
|
|
93
|
+
// One shape for every rule: some patterns match a sentence with its period, some stop before it, and a
|
|
94
|
+
// store where "use pnpm" and "use pnpm." are two different strings invites the reader to dedupe them.
|
|
95
|
+
const body = raw.replace(/[.!?]+$/, "").trim();
|
|
96
|
+
return { body, kind, origin };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const wordCount = (text: string): number => (text.match(/[\p{L}\p{N}]+/gu) ?? []).length;
|
|
100
|
+
/** A command without its runner or its flags: what makes two of them the same attempt. */
|
|
101
|
+
const shape = (command: string): string[] => command.trim().split(/\s+/).slice(1).filter((token) => !token.startsWith("-"));
|
|
102
|
+
/**
|
|
103
|
+
* Two commands are the same attempt when the only difference is what runs them. Both empty is *not* a match:
|
|
104
|
+
* `false` then `true` is two commands with nothing in common, and calling that a fix would invent a fact.
|
|
105
|
+
*/
|
|
106
|
+
const sameAttempt = (failed: string, fix: string): boolean => {
|
|
107
|
+
const tried = new Set(shape(failed));
|
|
108
|
+
return shape(fix).some((token) => tried.has(token));
|
|
109
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `memory` in `.flypi/agent/settings.json` (docs/memory.md Phase 3). Defaults are chosen to work on
|
|
3
|
+
* a machine with nothing installed, which is why `provider: "none"` is an escape hatch you can take
|
|
4
|
+
* and not a failure state: search then runs FTS-only and says so.
|
|
5
|
+
*
|
|
6
|
+
* `consolidation` and `wiki` are Phase 6 and absent here on purpose.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import type { FlypiPaths } from "../agent/paths.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* CHOSEN (2026-10-11, docs/decisions.md): EmbeddingGemma 2 — text, images and audio in one vector
|
|
13
|
+
* space, 8,192-token window. The `onnx-community` mirror, not `google/`: the upstream repo ships
|
|
14
|
+
* safetensors only and could not be loaded here. Non-English is covered (100+ languages).
|
|
15
|
+
*/
|
|
16
|
+
export const EMBED_MODEL = "onnx-community/embeddinggemma-2-ONNX";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* CHOSEN (2026-10-11, docs/decisions.md): the contradiction judge is `nli-deberta-v3-xsmall` — an NLI
|
|
20
|
+
* cross-encoder, not a chat model. Chosen on measurement (`scripts/benchmark-nli.ts`): the smallest
|
|
21
|
+
* checkpoint that read every contradiction in the pair set correctly, 87 MB at q8, ~6 ms a pair.
|
|
22
|
+
*/
|
|
23
|
+
export const JUDGE_MODEL = "Xenova/nli-deberta-v3-xsmall";
|
|
24
|
+
|
|
25
|
+
export interface EmbeddingsConfig {
|
|
26
|
+
provider: "transformers" | "none";
|
|
27
|
+
model: string;
|
|
28
|
+
/** Part of the index identity key: two quantizations are two vector spaces (risk 7). */
|
|
29
|
+
quantized: boolean;
|
|
30
|
+
cacheDir: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The contradiction judge (docs/memory.md Phase 4). On by default: switched off, the store can only
|
|
35
|
+
* judge what tokens can judge, and the measured cost is 87 MB of weights and ~6 ms per pair. It shares
|
|
36
|
+
* the cache directory with the embeddings because it shares the runtime that reads them.
|
|
37
|
+
*/
|
|
38
|
+
export interface JudgeConfig {
|
|
39
|
+
provider: "transformers" | "none";
|
|
40
|
+
model: string;
|
|
41
|
+
quantized: boolean;
|
|
42
|
+
cacheDir: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface MemoryConfig {
|
|
46
|
+
embeddings: EmbeddingsConfig;
|
|
47
|
+
judge: JudgeConfig;
|
|
48
|
+
/**
|
|
49
|
+
* The review fork (docs/memory.md Phase 4.8): how many delivered answers between forks, `0` = off. It is
|
|
50
|
+
* a config key with an off switch for the same reason the judge is — a fork is a whole model run over the
|
|
51
|
+
* conversation, ~30K tokens on a route that does not serve it from cache, with no human in the loop.
|
|
52
|
+
*/
|
|
53
|
+
review: { interval: number };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function memoryConfig(paths: FlypiPaths): MemoryConfig {
|
|
57
|
+
let settings: unknown;
|
|
58
|
+
try {
|
|
59
|
+
settings = JSON.parse(readFileSync(paths.settingsPath, "utf8"));
|
|
60
|
+
} catch {
|
|
61
|
+
settings = {}; // no settings file yet: the defaults have to work
|
|
62
|
+
}
|
|
63
|
+
return parseMemoryConfig(settings, paths);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function parseMemoryConfig(settings: unknown, paths: FlypiPaths): MemoryConfig {
|
|
67
|
+
const memory = (settings as { memory?: Record<string, unknown> } | undefined)?.memory;
|
|
68
|
+
const raw = memory?.embeddings as Record<string, unknown> | undefined;
|
|
69
|
+
const judge = memory?.judge as Record<string, unknown> | undefined;
|
|
70
|
+
const provider = (value: unknown): "transformers" | "none" => (value === "none" || value === false ? "none" : "transformers");
|
|
71
|
+
const model = (value: unknown, fallback: string) => (typeof value === "string" && value.length > 0 ? value : fallback);
|
|
72
|
+
// A number of turns, so a non-number is the default rather than a fork every turn: `true` in the file must
|
|
73
|
+
// not become `1`, and a negative one must not become "fire on every turn" through a modulo.
|
|
74
|
+
const interval = (value: unknown, fallback: number) => (typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : fallback);
|
|
75
|
+
return {
|
|
76
|
+
embeddings: {
|
|
77
|
+
provider: provider(raw?.provider),
|
|
78
|
+
model: model(raw?.model, EMBED_MODEL),
|
|
79
|
+
quantized: raw?.quantized !== false,
|
|
80
|
+
cacheDir: model(raw?.cacheDir, paths.modelsDir),
|
|
81
|
+
},
|
|
82
|
+
judge: {
|
|
83
|
+
provider: provider(judge?.provider),
|
|
84
|
+
model: model(judge?.model, JUDGE_MODEL),
|
|
85
|
+
quantized: judge?.quantized !== false,
|
|
86
|
+
cacheDir: model(judge?.cacheDir, paths.modelsDir),
|
|
87
|
+
},
|
|
88
|
+
review: { interval: interval(memory?.reviewInterval, 10) },
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The consolidation sweep (docs/memory.md Phase 6) — the part no per-turn pass can do.
|
|
3
|
+
*
|
|
4
|
+
* Why a sweep exists at all, when the write gate refuses near-copies and `search()` already collapses
|
|
5
|
+
* same-fact rows at read time: read-time collapsing **hides** a duplicate, it does not remove it. Both rows
|
|
6
|
+
* stay `active`, both keep taking a slot in the top-5 fetch every later write is judged against, and they
|
|
7
|
+
* **split `use_count`/`cited_count` between them** — which is exactly what the promotion gates below read,
|
|
8
|
+
* so a fact cited across three turns but split over two copies may never reach ≥ 3 on either. And nothing
|
|
9
|
+
* else in the system ever looks at the store as a whole: the write gate sees five neighbours at insert time
|
|
10
|
+
* and then never again.
|
|
11
|
+
*
|
|
12
|
+
* The LLM "reflect" phase this phase used to name is gone — Phase 4.8's review fork does that judging while
|
|
13
|
+
* the context is warm, instead of re-reading conversations later. What is left here is deterministic, which
|
|
14
|
+
* is also what makes it safe to run unattended: **cluster, gate, then write — and only the last step
|
|
15
|
+
* writes**, so a sweep interrupted halfway has changed nothing. hermes is the precedent for the shape: its
|
|
16
|
+
* curator runs the deterministic transitions always and leaves the model-driven half off by default
|
|
17
|
+
* (`agent/curator.py`, `DEFAULT_CONSOLIDATE = False`).
|
|
18
|
+
*
|
|
19
|
+
* The safety story is one line and it lives in the store, not here: a sweep may supersede and it may not
|
|
20
|
+
* delete (`UNATTENDED_ACTORS` in src/memory/store.ts).
|
|
21
|
+
*/
|
|
22
|
+
import { cosine } from "./embed.ts";
|
|
23
|
+
import {
|
|
24
|
+
NEAR_DUPLICATE,
|
|
25
|
+
sameFact,
|
|
26
|
+
valueSwap,
|
|
27
|
+
type MemoryRow,
|
|
28
|
+
type MemoryStore,
|
|
29
|
+
type OriginClass,
|
|
30
|
+
} from "./store.ts";
|
|
31
|
+
|
|
32
|
+
/** The queue kind the daemon registers a handler for (src/daemon/index.ts). */
|
|
33
|
+
export const CONSOLIDATE_JOB = "memory.consolidate";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The trigger, on flypi's conditions rather than a 3am cron (docs/memory.md Phase 6): the store is idle
|
|
37
|
+
* **and** (this many rows have arrived since the last sweep, **or** the last one is older than this). A
|
|
38
|
+
* desktop app is off at 3am some nights, so a cron would simply not run.
|
|
39
|
+
*/
|
|
40
|
+
export const MIN_NEW_ROWS = 25;
|
|
41
|
+
export const SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The promotion gates: memory gets promoted because it kept being useful, not because it was written
|
|
45
|
+
* confidently. `citations` counts turns whose answer actually cited the row, `queries` how many different
|
|
46
|
+
* questions led to it (one question asked five times is one query), and `score` is the citation rate over
|
|
47
|
+
* the turns it was retrieved for — a row that is always fetched and never used has earned nothing.
|
|
48
|
+
*/
|
|
49
|
+
export const GATE_CITATIONS = 3;
|
|
50
|
+
export const GATE_QUERIES = 3;
|
|
51
|
+
export const GATE_SCORE = 0.75;
|
|
52
|
+
|
|
53
|
+
export interface TurnEvidence {
|
|
54
|
+
userText: string;
|
|
55
|
+
retrieved: number[];
|
|
56
|
+
cited: number[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface MergePlan {
|
|
60
|
+
survivor: number;
|
|
61
|
+
merged: number[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface SweepPlan {
|
|
65
|
+
merges: MergePlan[];
|
|
66
|
+
promotions: number[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** What one pass did, in the shape `job_runs.detail` prints and the tests read. */
|
|
70
|
+
export interface SweepReport extends SweepPlan {
|
|
71
|
+
rowsBefore: number;
|
|
72
|
+
rowsAfter: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Authority order. A merge retires rows, so it must never retire the more authoritative copy: an `owner`
|
|
77
|
+
* row (the user's own words) and an `agent` row that says the same thing collapse to the `owner` one, or
|
|
78
|
+
* the sweep would quietly downgrade a fact the user stated into one the agent inferred.
|
|
79
|
+
*/
|
|
80
|
+
const AUTHORITY: Record<OriginClass, number> = { owner: 3, agent: 2, untrusted: 1, system: 0 };
|
|
81
|
+
|
|
82
|
+
/** Which copy of a duplicated fact survives: authority first, then what earned its place, then oldest. */
|
|
83
|
+
function compareSurvivor(a: MemoryRow, b: MemoryRow): number {
|
|
84
|
+
return (
|
|
85
|
+
AUTHORITY[b.origin_class] - AUTHORITY[a.origin_class] ||
|
|
86
|
+
b.cited_count - a.cited_count ||
|
|
87
|
+
b.use_count - a.use_count ||
|
|
88
|
+
a.id - b.id
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Are these two rows the same fact? Two signals, both deterministic:
|
|
94
|
+
*
|
|
95
|
+
* - the vectors say so (`NEAR_DUPLICATE`, the same threshold the write gate uses) — but only inside one
|
|
96
|
+
* index, which is why `activeVectors` hands back the current model's vectors and nothing else. This is
|
|
97
|
+
* also the pass that catches what a re-embed makes: a new vector space is a different set of near pairs.
|
|
98
|
+
* - token containment says so (`sameFact`, the rule the read path already uses to hide a duplicate from
|
|
99
|
+
* the model). The sweep is where it stops being merely hidden.
|
|
100
|
+
*
|
|
101
|
+
* Two shapes are corrections, not copies, and merging one would retire whichever side the survivor rule
|
|
102
|
+
* happened to prefer: a pair whose difference is one changed value (`valueSwap`), and a pair where one side
|
|
103
|
+
* is negated and the other is not. The second is the one the vectors are worst at — measured, "we do not use
|
|
104
|
+
* pnpm" against "we use pnpm" sits at 0.9835, above the duplicate threshold — so the guard runs before both
|
|
105
|
+
* signals. Telling a contradiction from a copy in general is the NLI judge's job at the write gate
|
|
106
|
+
* (src/memory/judge.ts); this is the part that needs no model.
|
|
107
|
+
*/
|
|
108
|
+
const NEGATION = /\b(?:not|never|no|none|without|don'?t|doesn'?t|didn'?t|isn'?t|aren'?t|won'?t|cannot|can'?t)\b/;
|
|
109
|
+
const polarityMismatch = (a: string, b: string): boolean => NEGATION.test(a) !== NEGATION.test(b);
|
|
110
|
+
|
|
111
|
+
function sameStoredFact(a: MemoryRow, b: MemoryRow, vectors: Map<number, Float32Array>): boolean {
|
|
112
|
+
if (valueSwap(a.body, b.body) !== undefined || polarityMismatch(a.body, b.body)) return false;
|
|
113
|
+
const left = vectors.get(a.id);
|
|
114
|
+
const right = vectors.get(b.id);
|
|
115
|
+
if (left && right && cosine(left, right) >= NEAR_DUPLICATE) return true;
|
|
116
|
+
return sameFact(a.body, b.body);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Connected components over the same-fact relation, in id order. Union-find rather than one anchor per
|
|
121
|
+
* cluster because the relation is not transitive in one hop: a reworded chain A~B~C has to land in one
|
|
122
|
+
* cluster even when A and C share no tokens, or the copies at the ends of it stay active forever.
|
|
123
|
+
* shortcut: every pair is compared, like `vectorSearch`'s full scan — fine to a few tens of thousands of
|
|
124
|
+
* rows, and the same place a vector index would plug in.
|
|
125
|
+
*/
|
|
126
|
+
export function clusterRows(rows: MemoryRow[], vectors: Map<number, Float32Array>): number[][] {
|
|
127
|
+
const parent = new Map<number, number>(rows.map((row) => [row.id, row.id]));
|
|
128
|
+
const find = (id: number): number => {
|
|
129
|
+
const above = parent.get(id)!;
|
|
130
|
+
if (above === id) return id;
|
|
131
|
+
const root = find(above);
|
|
132
|
+
parent.set(id, root);
|
|
133
|
+
return root;
|
|
134
|
+
};
|
|
135
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
136
|
+
for (let j = i + 1; j < rows.length; j += 1) {
|
|
137
|
+
if (sameStoredFact(rows[i]!, rows[j]!, vectors)) parent.set(find(rows[i]!.id), find(rows[j]!.id));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const clusters = new Map<number, number[]>();
|
|
141
|
+
for (const row of rows) {
|
|
142
|
+
const root = find(row.id);
|
|
143
|
+
clusters.set(root, [...(clusters.get(root) ?? []), row.id]);
|
|
144
|
+
}
|
|
145
|
+
return [...clusters.values()];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* What the gates read for one row — over the turns that cited **it or any row it absorbed**. That is the
|
|
150
|
+
* whole point of carrying citations through a merge: two copies one criterion short of a gate each must not
|
|
151
|
+
* stay permanently short, and `turns.cited_json` is an immutable record of what a turn actually wrote, so
|
|
152
|
+
* the citations are followed at read time rather than rewritten into history.
|
|
153
|
+
*/
|
|
154
|
+
export function promotionEvidence(turns: TurnEvidence[], ids: number[]): { citations: number; queries: number; retrieved: number; score: number } {
|
|
155
|
+
const wanted = new Set(ids);
|
|
156
|
+
let citations = 0;
|
|
157
|
+
let retrieved = 0;
|
|
158
|
+
const queries = new Set<string>();
|
|
159
|
+
for (const turn of turns) {
|
|
160
|
+
if (turn.retrieved.some((id) => wanted.has(id))) retrieved += 1;
|
|
161
|
+
if (turn.cited.some((id) => wanted.has(id))) {
|
|
162
|
+
citations += 1;
|
|
163
|
+
queries.add(turn.userText);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { citations, queries: queries.size, retrieved, score: retrieved === 0 ? 0 : citations / retrieved };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function passesGates(evidence: { citations: number; queries: number; score: number }): boolean {
|
|
170
|
+
return evidence.citations >= GATE_CITATIONS && evidence.queries >= GATE_QUERIES && evidence.score >= GATE_SCORE;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The plan, with nothing written: clusters of two or more collapse to one row, and the rows that survive a
|
|
175
|
+
* merge are gated on evidence that includes the citations of the rows they absorbed.
|
|
176
|
+
*/
|
|
177
|
+
export function planSweep(rows: MemoryRow[], vectors: Map<number, Float32Array>, turns: TurnEvidence[]): SweepPlan {
|
|
178
|
+
const byId = new Map(rows.map((row) => [row.id, row]));
|
|
179
|
+
const merges: MergePlan[] = [];
|
|
180
|
+
for (const cluster of clusterRows(rows, vectors)) {
|
|
181
|
+
if (cluster.length < 2) continue;
|
|
182
|
+
const survivor = cluster.map((id) => byId.get(id)!).reduce((best, row) => (compareSurvivor(row, best) < 0 ? row : best)).id;
|
|
183
|
+
merges.push({ survivor, merged: cluster.filter((id) => id !== survivor) });
|
|
184
|
+
}
|
|
185
|
+
const absorbed = new Map<number, number[]>(merges.map((merge) => [merge.survivor, merge.merged]));
|
|
186
|
+
const retired = new Set(merges.flatMap((merge) => merge.merged));
|
|
187
|
+
const promotions = rows
|
|
188
|
+
.map((row) => row.id)
|
|
189
|
+
.filter((id) => !retired.has(id))
|
|
190
|
+
.filter((id) => passesGates(promotionEvidence(turns, [id, ...(absorbed.get(id) ?? [])])));
|
|
191
|
+
return { merges, promotions };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Is a sweep due? Idle first — a sweep reorganises the store a conversation is being answered from, so it
|
|
196
|
+
* waits for the same quiet the re-embed job waits for. Then either enough new rows to be worth a pass, or a
|
|
197
|
+
* day since the last one. A store that has never been swept is not due until it has the rows: an empty store
|
|
198
|
+
* has nothing to consolidate, and a daemon that swept on start-up would queue work for every fresh checkout.
|
|
199
|
+
*/
|
|
200
|
+
export function sweepDue(state: { newRows: number; lastSweepAt: string | null; at: string; idle: boolean }): boolean {
|
|
201
|
+
if (!state.idle) return false;
|
|
202
|
+
if (state.newRows >= MIN_NEW_ROWS) return true;
|
|
203
|
+
if (state.lastSweepAt === null) return false;
|
|
204
|
+
return Date.parse(state.at) - Date.parse(state.lastSweepAt) >= SWEEP_INTERVAL_MS;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* One pass, end to end: read, plan, then apply. The apply half is four store calls and nothing else, which
|
|
209
|
+
* is what makes "a sweep can be interrupted without half-applying" true rather than asserted.
|
|
210
|
+
*/
|
|
211
|
+
export function consolidate(store: MemoryStore, options: { actor?: string } = {}): SweepReport {
|
|
212
|
+
const actor = options.actor ?? "sweep";
|
|
213
|
+
const rowsBefore = store.memoryRowCount();
|
|
214
|
+
const plan = planSweep(store.activeMemories(), store.activeVectors(), store.citationEvidence());
|
|
215
|
+
for (const merge of plan.merges) store.applyMerge(merge, actor);
|
|
216
|
+
for (const id of plan.promotions) store.applyPromotion(id, actor);
|
|
217
|
+
return { ...plan, rowsBefore, rowsAfter: store.memoryRowCount() };
|
|
218
|
+
}
|