@staix/agent-mem 0.17.0 → 0.19.0
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/.claude-plugin/plugin.json +1 -1
- package/README.md +26 -5
- package/package.json +4 -2
- package/src/cli.ts +27 -4
- package/src/commands-table.ts +3 -1
- package/src/hook-contract.ts +9 -0
- package/src/hook-jcode.ts +56 -0
- package/src/hook-native.ts +66 -13
- package/src/hook-project.ts +27 -4
- package/src/hook-resources.ts +54 -12
- package/src/hook-rss-wire.ts +85 -0
- package/src/hook-spool.ts +80 -20
- package/src/hook-transcript.ts +189 -0
- package/src/hooks.ts +132 -61
- package/src/mcp-memory.ts +13 -3
- package/src/memory/candidates.ts +13 -4
- package/src/memory/capture-input.ts +34 -0
- package/src/memory/capture.ts +46 -0
- package/src/memory/catalog.ts +2 -1
- package/src/memory/cross-project.ts +2 -1
- package/src/memory/doctor.ts +88 -17
- package/src/memory/formats.ts +65 -0
- package/src/memory/index-file.ts +15 -0
- package/src/memory/metrics.ts +4 -3
- package/src/memory/public-prune-activation.ts +2 -1
- package/src/memory/search-fts.ts +63 -15
- package/src/memory/search-score.ts +14 -0
- package/src/memory/shard.ts +2 -1
- package/src/memory/store.ts +29 -6
- package/src/memory/sync.ts +2 -0
- package/src/memory/work-lists.ts +2 -0
- package/src/session-brief-worker.ts +38 -37
- package/src/session-brief.ts +26 -12
- package/src/setup-drift.ts +217 -0
- package/src/setup-jcode.ts +65 -0
- package/src/setup-owned.ts +18 -1
- package/src/setup-paths.ts +76 -0
- package/src/setup.ts +92 -47
- package/src/ui/panel.js +25 -0
- package/src/ui/plugin.ts +17 -5
package/src/memory/doctor.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
2
|
+
import { indexFileName } from "./index-file.ts";
|
|
3
|
+
import { assertFormatFloor } from "./formats.ts";
|
|
2
4
|
import { tmpdir } from "node:os";
|
|
3
5
|
import { basename, join } from "node:path";
|
|
4
6
|
import { Database } from "bun:sqlite";
|
|
5
|
-
import { sessionStartBrief } from "../session-brief.ts";
|
|
7
|
+
import { sessionStartBrief, type BriefOptions } from "../session-brief.ts";
|
|
8
|
+
import { openCuratedForRead } from "./curated-read.ts";
|
|
6
9
|
import { pendingSummary } from "./candidate-pending.ts";
|
|
7
10
|
import { readCaptureDrops } from "./metrics.ts";
|
|
8
11
|
import { walkShards } from "./catalog.ts";
|
|
@@ -11,10 +14,11 @@ import { readSelector } from "./generation.ts";
|
|
|
11
14
|
import { processLiveness, withGitTimeout } from "./platform.ts";
|
|
12
15
|
import { endLikeSignal, STOP_SIGNALS } from "./sync-cadence.ts";
|
|
13
16
|
import { boundRepoRoot } from "./repo-scope.ts";
|
|
14
|
-
import { facts, label, PLAIN, type Style } from "./style.ts";
|
|
15
|
-
import { shardDir } from "./shard.ts";
|
|
17
|
+
import { facts, label, PLAIN, singleLine, type Style } from "./style.ts";
|
|
18
|
+
import { aliasedKeys, shardDir } from "./shard.ts";
|
|
16
19
|
import type { MemoryStore, View } from "./store.ts";
|
|
17
20
|
import { nextSyncAt, readSyncState, remoteConfigured, syncDue, usableState } from "./sync-cadence.ts";
|
|
21
|
+
import { readSetupDrift } from "../setup-drift.ts";
|
|
18
22
|
|
|
19
23
|
/**
|
|
20
24
|
* F15, `amem doctor`: one report over the memory home. It writes nothing into the home. Every check that needs a store (the notes, the brief, the index proof) runs on a
|
|
@@ -41,6 +45,8 @@ const MAX_IDS = 10;
|
|
|
41
45
|
|
|
42
46
|
export interface DoctorOptions {
|
|
43
47
|
home: string; homeSource: string; cwd: string; project: string; aliases: readonly string[]; now: number;
|
|
48
|
+
/** Test seam for setup path resolution; the CLI uses the process environment. */
|
|
49
|
+
env?: Readonly<Record<string, string | undefined>>;
|
|
44
50
|
/** Opens this project's curated store on `base`, the way the other person reads do (a split home through its project shard). Doctor passes a private scratch base, never the live home. */
|
|
45
51
|
openCurated: (base: string) => MemoryStore;
|
|
46
52
|
/** The cadence command the person runs from their own shell; shown in the report, never installed. */
|
|
@@ -59,6 +65,8 @@ export interface DoctorReport {
|
|
|
59
65
|
drops: Section;
|
|
60
66
|
/** #188, additive: present only when the project's curated logs hold forget or prune operations. Informational; status is always ok. */
|
|
61
67
|
masked?: Section;
|
|
68
|
+
/** #202, additive: applied native setup registrations checked against the shared path resolver. */
|
|
69
|
+
setup?: Section;
|
|
62
70
|
};
|
|
63
71
|
}
|
|
64
72
|
|
|
@@ -93,7 +101,22 @@ function curatedPresent(home: string, project: string, layout: Layout): boolean
|
|
|
93
101
|
/** What a scratch copy leaves out: databases, locks, repositories, the machine-local capture and work trees, spools and ingest cursors. */
|
|
94
102
|
const SKIP = /\.db(-wal|-shm)?$|\.lock$|\.tmp$|^\.git$|\.git$|^spool$|^ingest$|^readers$/;
|
|
95
103
|
class TooBig extends Error {}
|
|
96
|
-
|
|
104
|
+
interface CopyBudget { bytes: number; verify?: (() => void)[] }
|
|
105
|
+
function observedCopy(from: string, to: string, budget: CopyBudget): void {
|
|
106
|
+
budget.bytes += statSync(from).size; if (budget.bytes > COPY_LIMIT_BYTES) throw new TooBig();
|
|
107
|
+
if (budget.verify) {
|
|
108
|
+
const stamp = () => { const s = lstatSync(from, { bigint: true }); if (!s.isFile()) throw Error("recap source is not a plain file"); return `${s.dev}:${s.ino}:${s.size}:${s.mtimeNs}:${s.ctimeNs}`; };
|
|
109
|
+
const before = stamp(); budget.verify.push(() => { if (stamp() !== before) throw Error("recap source changed"); });
|
|
110
|
+
}
|
|
111
|
+
copyFileSync(from, to);
|
|
112
|
+
}
|
|
113
|
+
function observeDirectory(from: string, budget: CopyBudget, keep: (name: string) => boolean): void {
|
|
114
|
+
if (!budget.verify) return;
|
|
115
|
+
const stamp = () => { const s = lstatSync(from, { bigint: true }); if (!s.isDirectory()) throw Error("recap source directory changed"); return JSON.stringify([String(s.dev), String(s.ino), readdirSync(from).filter(keep).sort()]); };
|
|
116
|
+
const before = stamp(); budget.verify.push(() => { if (stamp() !== before) throw Error("recap source directory changed"); });
|
|
117
|
+
}
|
|
118
|
+
function copyTree(from: string, to: string, budget: CopyBudget, skipNames: ReadonlySet<string> = new Set()): void {
|
|
119
|
+
observeDirectory(from, budget, name => !(SKIP.test(name) && !(basename(from) === "public-prune-objects" && /^[a-f0-9]{40}\.git$/.test(name))) && !skipNames.has(name));
|
|
97
120
|
mkdirSync(to, { recursive: true, mode: 0o700 });
|
|
98
121
|
for (const name of readdirSync(from)) {
|
|
99
122
|
const proofObject = basename(from) === "public-prune-objects" && /^[a-f0-9]{40}\.git$/.exec(name)?.[0] === name;
|
|
@@ -102,25 +125,24 @@ function copyTree(from: string, to: string, budget: { bytes: number }, skipNames
|
|
|
102
125
|
if (proofObject && !stat.isFile()) throw new Error("a public prune object is not a plain file");
|
|
103
126
|
if (stat.isSymbolicLink()) throw new Error("the memory home holds a link where a plain file or directory is expected");
|
|
104
127
|
if (stat.isDirectory()) copyTree(source, join(to, name), budget, skipNames);
|
|
105
|
-
else if (stat.isFile())
|
|
128
|
+
else if (stat.isFile()) observedCopy(source, join(to, name), budget);
|
|
106
129
|
}
|
|
107
130
|
}
|
|
108
131
|
|
|
109
132
|
/** The curated side of the home, without any database: the logs of this project (and its aliases), and the small files that bind them. */
|
|
110
|
-
function copyCurated(options: DoctorOptions, layout: Layout, scratch: string, budget:
|
|
133
|
+
function copyCurated(options: Pick<DoctorOptions, "home" | "project" | "aliases">, layout: Layout, scratch: string, budget: CopyBudget): void {
|
|
111
134
|
const home = options.home;
|
|
135
|
+
observeDirectory(home, budget, name => !SKIP.test(name) && regularFile(join(home, name)));
|
|
112
136
|
for (const name of readdirSync(home)) { // top-level plain files: machine.json, shards.json, profile.json, aliases.jsonl, lifecycle.jsonl, the configuration
|
|
113
137
|
if (SKIP.test(name) || !regularFile(join(home, name))) continue;
|
|
114
|
-
|
|
115
|
-
copyFileSync(join(home, name), join(scratch, name));
|
|
138
|
+
observedCopy(join(home, name), join(scratch, name), budget);
|
|
116
139
|
}
|
|
117
140
|
const selected = readSelector(home).selector;
|
|
118
141
|
if (selected?.schema === "agent-mem.profile/v2") {
|
|
119
142
|
const name = `${selected.pruneInputSetHash}.json`, source = join(home, "public-prune-input-sets", name), stat = lstatSync(source);
|
|
120
143
|
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw new Error("the committed public prune view is not a plain file");
|
|
121
|
-
budget.bytes += stat.size; if (budget.bytes > COPY_LIMIT_BYTES) throw new TooBig();
|
|
122
144
|
const directory = join(scratch, "public-prune-input-sets"); mkdirSync(directory, { mode: 0o700 });
|
|
123
|
-
|
|
145
|
+
observedCopy(source, join(directory, name), budget); chmodSync(join(directory, name), 0o600);
|
|
124
146
|
}
|
|
125
147
|
if (layout === "split") {
|
|
126
148
|
for (const key of [options.project, ...options.aliases]) {
|
|
@@ -135,22 +157,59 @@ function copyCurated(options: DoctorOptions, layout: Layout, scratch: string, bu
|
|
|
135
157
|
}
|
|
136
158
|
|
|
137
159
|
/** The work logs of this project and its aliases, in a private base of their own: the brief opens a work list read-write and migrates its index. */
|
|
138
|
-
function copyWork(options: DoctorOptions, base: string, budget:
|
|
160
|
+
function copyWork(options: Pick<DoctorOptions, "home" | "project" | "aliases">, base: string, budget: CopyBudget): boolean {
|
|
139
161
|
let any = false;
|
|
140
|
-
for (const name of ["machine.json", "aliases.jsonl", "lifecycle.jsonl"]) if (regularFile(join(options.home, name))) { mkdirSync(base, { recursive: true, mode: 0o700 });
|
|
162
|
+
for (const name of ["machine.json", "aliases.jsonl", "lifecycle.jsonl"]) if (regularFile(join(options.home, name))) { mkdirSync(base, { recursive: true, mode: 0o700 }); observedCopy(join(options.home, name), join(base, name), budget); }
|
|
141
163
|
for (const key of [options.project, ...options.aliases]) {
|
|
142
164
|
const dir = shardDir(options.home, key), log = join(dir, "work", "log");
|
|
143
165
|
if (!regularDir(log)) continue;
|
|
144
166
|
copyTree(log, join(base, "projects", dir.split("/").pop()!, "work", "log"), budget);
|
|
145
|
-
if (regularFile(join(dir, "project.json")))
|
|
167
|
+
if (regularFile(join(dir, "project.json"))) observedCopy(join(dir, "project.json"), join(base, "projects", dir.split("/").pop()!, "project.json"), budget);
|
|
146
168
|
any = true;
|
|
147
169
|
}
|
|
148
170
|
return any;
|
|
149
171
|
}
|
|
150
172
|
|
|
173
|
+
/** Reuse doctor's bounded private inputs for an on-demand brief, never a live store or reader lease. */
|
|
174
|
+
export async function withBriefSnapshot<T>(home: string, project: string, read: (inputs: NonNullable<BriefOptions["measureOnly"]>) => Promise<T>): Promise<T> {
|
|
175
|
+
const aliases = aliasedKeys(home, project), selection = readSelector(home).revision, layout = layoutOf(home), guard = guardScratch();
|
|
176
|
+
let scratch: string | undefined, curated: MemoryStore | undefined;
|
|
177
|
+
const budget: CopyBudget = { bytes: 0, verify: [] };
|
|
178
|
+
const inputDirectories = [join(home, "projects"), ...(layout === "split" ? [join(home, "user")] : [join(home, "log"), join(home, "local")]),
|
|
179
|
+
...(layout === "generation" ? [join(home, "generations")] : []),
|
|
180
|
+
...[project, ...aliases].flatMap(key => { const dir = shardDir(home, key); return [dir, join(dir, "work"), join(dir, "work", "log")]; })];
|
|
181
|
+
const checkDirectories = () => {
|
|
182
|
+
for (const dir of inputDirectories) { const stat = lstatSync(dir, { throwIfNoEntry: false }); if (stat && !stat.isDirectory()) throw Error("recap input is not a plain directory"); }
|
|
183
|
+
};
|
|
184
|
+
const check = () => {
|
|
185
|
+
checkDirectories();
|
|
186
|
+
if (readSelector(home).revision !== selection || layoutOf(home) !== layout || JSON.stringify(aliasedKeys(home, project)) !== JSON.stringify(aliases)) throw Error("recap binding changed");
|
|
187
|
+
for (const verify of budget.verify!) verify();
|
|
188
|
+
};
|
|
189
|
+
try {
|
|
190
|
+
checkDirectories();
|
|
191
|
+
scratch = mkdtempSync(join(tmpdir(), `amem-doctor-${process.pid}-`)); chmodSync(scratch, 0o700); guard.adopt(scratch);
|
|
192
|
+
const options = { home, project, aliases }, workBase = join(scratch, "work-base"); mkdirSync(workBase, { mode: 0o700 });
|
|
193
|
+
copyWork(options, workBase, budget);
|
|
194
|
+
if (curatedPresent(home, project, layout)) {
|
|
195
|
+
const copy = join(scratch, "home"); mkdirSync(copy, { mode: 0o700 }); copyCurated(options, layout, copy, budget);
|
|
196
|
+
curated = openCuratedForRead(copy, project);
|
|
197
|
+
if (!curated) throw Error("recap curated snapshot unavailable");
|
|
198
|
+
}
|
|
199
|
+
check();
|
|
200
|
+
const result = await read({ ...(curated ? { curated } : {}), workBase });
|
|
201
|
+
check(); return result;
|
|
202
|
+
} catch (error) {
|
|
203
|
+
if (error instanceof TooBig) throw Error("recap snapshot exceeds the 64 MiB copy limit");
|
|
204
|
+
throw error;
|
|
205
|
+
} finally {
|
|
206
|
+
try { curated?.close(); } finally { try { if (scratch) rmSync(scratch, { recursive: true, force: true }); } finally { guard.release(); } }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
151
210
|
/** A snapshot of the live shared-v1 index (a read-only connection, so no checkpoint, no lease), or false when there is none. */
|
|
152
211
|
function snapshotIndex(live: string, to: string): boolean {
|
|
153
|
-
const file = join(live,
|
|
212
|
+
const file = join(live, indexFileName(false));
|
|
154
213
|
if (!regularFile(file)) return false;
|
|
155
214
|
const forms: (() => Database)[] = [
|
|
156
215
|
() => new Database(file, { readonly: true }),
|
|
@@ -383,7 +442,11 @@ function guardScratch(): { adopt: (dir: string) => void; release: () => void } {
|
|
|
383
442
|
* a lone signal waits at most one call for the signal handler, which then removes the scratch copy and ends doctor.
|
|
384
443
|
*/
|
|
385
444
|
export const DOCTOR_GIT_TIMEOUT_MS = 5_000;
|
|
386
|
-
export function doctorReport(options: DoctorOptions): Promise<DoctorReport> {
|
|
445
|
+
export function doctorReport(options: DoctorOptions): Promise<DoctorReport> {
|
|
446
|
+
// Doctor reads every surface below, live or through a scratch copy: a floor on any of them refuses the whole report before anything is read (#199 AC5).
|
|
447
|
+
for (const surface of ["curated", "capture", "candidates", "work-lists"] as const) assertFormatFloor(options.home, surface);
|
|
448
|
+
return withGitTimeout(DOCTOR_GIT_TIMEOUT_MS, () => runDoctor(options));
|
|
449
|
+
}
|
|
387
450
|
async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
|
|
388
451
|
let scratch: string | undefined, unguard: (() => void) | undefined;
|
|
389
452
|
let duplicates: Section, stale: Section, index: Section;
|
|
@@ -407,7 +470,7 @@ async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
|
|
|
407
470
|
} else {
|
|
408
471
|
const home = join(scratch, "home"); mkdirSync(home, { mode: 0o700 });
|
|
409
472
|
copyCurated(options, layout, home, budget);
|
|
410
|
-
const snapshotted = layout === "shared-v1" && snapshotIndex(options.home, join(home,
|
|
473
|
+
const snapshotted = layout === "shared-v1" && snapshotIndex(options.home, join(home, indexFileName(false)));
|
|
411
474
|
curated = options.openCurated(home);
|
|
412
475
|
const rows = curated.list({ project: options.project, limit: LIST_ALL });
|
|
413
476
|
await tick();
|
|
@@ -428,10 +491,13 @@ async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
|
|
|
428
491
|
candidates = summary === null ? unavailable("candidates.db exists but cannot be read")
|
|
429
492
|
: { status: summary.dueWithin7Days ? "attention" : "ok", ...summary, ...(summary.dueWithin7Days ? { hint: "run amem review walk" } : {}) };
|
|
430
493
|
} catch (error) { candidates = unavailable(message(error)); }
|
|
494
|
+
let setup: Section;
|
|
495
|
+
try { setup = { ...readSetupDrift({ home: options.home, ...(options.env ? { env: options.env } : {}), cwd: options.cwd }) }; }
|
|
496
|
+
catch { setup = unavailable("setup registrations could not be checked"); }
|
|
431
497
|
let sections: DoctorReport["sections"];
|
|
432
498
|
try {
|
|
433
499
|
await tick();
|
|
434
|
-
sections = { duplicates, stale, candidates, capture: captureSection(options), brief: await briefSection(options, curated, workBase, copyFailed), index, readers: readersSection(options), policy: policySection(options), cadence: cadenceSection(options), drops: dropsSection(options), ...maskedSection(curated) };
|
|
500
|
+
sections = { duplicates, stale, candidates, capture: captureSection(options), brief: await briefSection(options, curated, workBase, copyFailed), index, readers: readersSection(options), policy: policySection(options), cadence: cadenceSection(options), drops: dropsSection(options), setup, ...maskedSection(curated) };
|
|
435
501
|
await tick(); // a signal that arrived during the last synchronous stage is handled while the guard is still installed
|
|
436
502
|
} finally { try { curated?.close(); } finally { try { if (scratch) rmSync(scratch, { recursive: true, force: true }); } finally { unguard?.(); } } }
|
|
437
503
|
return { schema: DOCTOR_SCHEMA, status: worst(Object.values(sections).map(section => section.status)), home: { path: options.home, source: options.homeSource }, project: options.project, sections };
|
|
@@ -441,6 +507,7 @@ async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
|
|
|
441
507
|
const SECTION_TITLE: Record<keyof DoctorReport["sections"], string> = {
|
|
442
508
|
duplicates: "duplicate notes", stale: "stale path references", candidates: "review candidates", capture: "capture per CLI", brief: "session brief",
|
|
443
509
|
index: "index equals rebuild", readers: "reader leases", policy: "policy in effect", cadence: "ingest and sync cadence", drops: "recent capture drops",
|
|
510
|
+
setup: "native setup registrations",
|
|
444
511
|
masked: "forgotten or pruned entries (informational)",
|
|
445
512
|
};
|
|
446
513
|
|
|
@@ -461,6 +528,10 @@ function sectionDetail(name: string, detail: Record<string, unknown>, s: Style):
|
|
|
461
528
|
const counts = Object.entries((detail.counts ?? {}) as Record<string, number>);
|
|
462
529
|
return [counts.length ? `${counts.map(([reason, n]) => `${reason.replaceAll("_", " ")} ${n}`).join(", ")} (recent metrics tail)` : "none in the recent metrics tail"].map(line => detail.malformedLines ? `${line}; ${detail.malformedLines} malformed lines skipped` : line);
|
|
463
530
|
}
|
|
531
|
+
if (name === "setup" && Array.isArray(detail.entries)) {
|
|
532
|
+
return (detail.entries as { target?: unknown; file?: unknown; entry?: unknown; reason?: unknown; expectedFile?: unknown }[]).map(item =>
|
|
533
|
+
singleLine(`${item.target} ${item.file}${item.expectedFile ? ` -> ${item.expectedFile}` : ""} ${item.entry}: ${item.reason}`));
|
|
534
|
+
}
|
|
464
535
|
if (name === "brief" && typeof detail.tokens === "number") return [`${detail.tokens} of ${detail.budget} tokens${detail.coverage ? `, coverage ${facts(detail.coverage as Record<string, unknown>) || "complete"}` : ""}`];
|
|
465
536
|
const { examples, ...rest } = detail;
|
|
466
537
|
const line = facts(rest);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, openSync, readFileSync, readSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Format floors (#199 AC5). `formats.json` in the memory home is an append-only JSON array of `{ surface, minReader, since }` records: a
|
|
6
|
+
* person appends one to stop releases older than `minReader` from reading that surface (`home` covers every surface). This package never
|
|
7
|
+
* writes the file, so no writer raises a floor implicitly. Each surface checks its floor before it creates, opens or writes anything, and a
|
|
8
|
+
* reader below it refuses with `code: "format_floor"`; a file this version cannot read refuses with `code: "format_floor_unreadable"`.
|
|
9
|
+
* A record naming a surface this version does not know is not its concern. Releases before this one do not read the file.
|
|
10
|
+
*/
|
|
11
|
+
export const FORMATS_FILE = "formats.json";
|
|
12
|
+
export const FORMAT_SURFACES = ["curated", "capture", "hook-spool", "candidates", "work-lists", "sync"] as const;
|
|
13
|
+
export type FormatSurface = typeof FORMAT_SURFACES[number];
|
|
14
|
+
export interface FormatFloor { surface: string; minReader: string; since: string }
|
|
15
|
+
const MAX_BYTES = 64 * 1024, SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/;
|
|
16
|
+
|
|
17
|
+
export class FormatFloorError extends Error {
|
|
18
|
+
constructor(readonly code: "format_floor" | "format_floor_unreadable", message: string, readonly floor?: FormatFloor & { reader: string }) { super(message); this.name = "FormatFloorError"; }
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* This release's version, read once when the module loads, so a long-running process whose install is replaced in place keeps comparing its own
|
|
22
|
+
* version. Undefined when it cannot be read: then every applicable floor refuses.
|
|
23
|
+
*/
|
|
24
|
+
const READER_VERSION: string | undefined = (() => {
|
|
25
|
+
try { const { version } = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")) as { version?: unknown }; return typeof version === "string" && SEMVER.test(version) ? version : undefined; }
|
|
26
|
+
catch { return undefined; }
|
|
27
|
+
})();
|
|
28
|
+
const parts = (version: string) => SEMVER.exec(version)!.slice(1, 4).map(Number);
|
|
29
|
+
function below(reader: string, floor: string): boolean {
|
|
30
|
+
const a = parts(reader), b = parts(floor);
|
|
31
|
+
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i]! < b[i]!;
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Bounded read of the floor records: undefined when there is no file; anything that is not a regular file of valid records refuses. */
|
|
36
|
+
export function readFormatFloors(home: string): FormatFloor[] | undefined {
|
|
37
|
+
const file = join(home, FORMATS_FILE), unreadable = (why: string) => new FormatFloorError("format_floor_unreadable", `${file}: ${why}; nothing was read or written. Fix or restore the file (it is append-only and written by a person).`);
|
|
38
|
+
let fd: number;
|
|
39
|
+
try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
|
40
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT" || (error as NodeJS.ErrnoException).code === "ENOTDIR") return undefined; throw unreadable("not a readable regular file"); }
|
|
41
|
+
let text: string;
|
|
42
|
+
try {
|
|
43
|
+
const stat = fstatSync(fd);
|
|
44
|
+
if (!stat.isFile() || stat.size > MAX_BYTES) throw unreadable("not a regular file of at most 64 KiB");
|
|
45
|
+
const data = Buffer.alloc(MAX_BYTES + 1); let size = 0, n: number;
|
|
46
|
+
while (size < data.length && (n = readSync(fd, data, size, data.length - size, null)) > 0) size += n;
|
|
47
|
+
if (size > MAX_BYTES) throw unreadable("not a regular file of at most 64 KiB");
|
|
48
|
+
text = data.subarray(0, size).toString("utf8");
|
|
49
|
+
} finally { closeSync(fd); }
|
|
50
|
+
let value: unknown;
|
|
51
|
+
try { value = JSON.parse(text); } catch { throw unreadable("not JSON"); }
|
|
52
|
+
if (!Array.isArray(value) || !value.every(record => record && typeof record === "object" && !Array.isArray(record)
|
|
53
|
+
&& typeof record.surface === "string" && typeof record.minReader === "string" && SEMVER.test(record.minReader) && typeof record.since === "string")) throw unreadable("not an array of { surface, minReader, since } records");
|
|
54
|
+
return value.map(({ surface, minReader, since }) => ({ surface, minReader, since }));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Refuse before touching `surface` when the home records a floor above this release. */
|
|
58
|
+
export function assertFormatFloor(home: string, surface: FormatSurface): void {
|
|
59
|
+
const floors = readFormatFloors(home);
|
|
60
|
+
if (!floors) return;
|
|
61
|
+
const reader = READER_VERSION ?? "unknown";
|
|
62
|
+
for (const floor of floors) if ((floor.surface === surface || floor.surface === "home") && (READER_VERSION === undefined || below(READER_VERSION, floor.minReader))) {
|
|
63
|
+
throw new FormatFloorError("format_floor", `amem ${reader} is below the ${surface} format floor ${floor.minReader} (since ${floor.since}) recorded in ${join(home, FORMATS_FILE)}; nothing was read or written. Upgrade amem to ${floor.minReader} or later.`, { ...floor, reader });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Bumped when the index's tables change: an index with another version is dropped and rebuilt from the logs. */
|
|
2
|
+
export const INDEX_VERSION = 3; // 3: suggest, confirm and reject ops (an older index skipped them)
|
|
3
|
+
/** The version that released 0.14.0 through 0.18.0 build into the unversioned names below; it keeps them so this release shares their warm index. */
|
|
4
|
+
const SHARED_INDEX_VERSION = 3;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The derived curated index file of a profile directory, one per `INDEX_VERSION` (#199 AC4): two releases with different index versions on
|
|
8
|
+
* one home each keep their own file instead of dropping and rebuilding the other's. Version 3 keeps the released names (`index.db`, and
|
|
9
|
+
* `index-v2.db` for the v2 public-prune view, whose `v2` is that view's format, not the index version); every later version names its own,
|
|
10
|
+
* `index-v<N>.db` or `index-v2-i<N>.db`. An older version's file is left as it is.
|
|
11
|
+
*/
|
|
12
|
+
export function indexFileName(foreignPruneFormat: boolean, version: number = INDEX_VERSION): string {
|
|
13
|
+
const base = foreignPruneFormat ? "index-v2" : "index";
|
|
14
|
+
return version === SHARED_INDEX_VERSION ? `${base}.db` : foreignPruneFormat ? `${base}-i${version}.db` : `${base}-v${version}.db`;
|
|
15
|
+
}
|
package/src/memory/metrics.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { closeSync, constants, fstatSync, openSync, readSync, appendFileSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import type { HookRssSources } from "../hook-resources.ts";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
|
|
4
5
|
/** Rolling resource samples; importing this leaf never loads a memory store or capacity probe. */
|
|
5
|
-
export interface MetricSample { at: string; kind: "hook" | "search"; wallMs?: number; rssMB?: number; rawRssMB?: number; idleMB?: number; captureDrops?: number; captureDropReason?: CaptureDropReason }
|
|
6
|
+
export interface MetricSample { at: string; kind: "hook" | "search"; wallMs?: number; rssMB?: number; rawRssMB?: number; rssSources?: HookRssSources; rssScope?: "owned-images"; transcriptOffset?: number; transcriptStopReason?: "cap" | "deadline" | "spool_full"; idleMB?: number; captureDrops?: number; captureDropReason?: CaptureDropReason }
|
|
6
7
|
const METRICS_MAX_BYTES = 64 * 1024, METRICS_KEEP = 200;
|
|
7
8
|
|
|
8
9
|
export function metricsPath(base: string): string {
|
|
@@ -36,8 +37,8 @@ export function readMetrics(base: string, limit = METRICS_KEEP): MetricSample[]
|
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
|
|
39
|
-
export type CaptureDropReason = "spool_full" | "cwd_removed" | "undrainable_expired" | "scope_unknown" | "project_archived" | "project_forgotten";
|
|
40
|
-
const DROP_REASONS: readonly CaptureDropReason[] = ["spool_full", "cwd_removed", "undrainable_expired", "scope_unknown", "project_archived", "project_forgotten"];
|
|
40
|
+
export type CaptureDropReason = "spool_full" | "cwd_removed" | "undrainable_expired" | "scope_unknown" | "project_archived" | "project_forgotten" | "unknown_schema_expired" | "transcript_line_bound";
|
|
41
|
+
const DROP_REASONS: readonly CaptureDropReason[] = ["spool_full", "cwd_removed", "undrainable_expired", "scope_unknown", "project_archived", "project_forgotten", "unknown_schema_expired", "transcript_line_bound"];
|
|
41
42
|
/** Numeric notice only, bounded to the rolling metric tail; never an all-time counter. Fails open to no counts: hooks never deny over metrics. */
|
|
42
43
|
export function captureDropSummary(base: string): Partial<Record<CaptureDropReason, number>> {
|
|
43
44
|
const read = readCaptureDrops(base);
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Checkpoints are fault seams after intent, shard marker, selector and intent removal.
|
|
3
3
|
* A caller resumes an interrupted operation from its immutable direct-profile journal. */
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
|
+
import { indexFileName } from "./index-file.ts";
|
|
5
6
|
import { closeSync, constants, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
6
7
|
import { dirname, join } from "node:path";
|
|
7
8
|
import { canonicalJSON, fsyncDirectory, hashJSON, readSelector, withProfileLock, writeProfileJSON, type BoundProfile } from "./generation.ts";
|
|
@@ -105,7 +106,7 @@ function immutable(file: string, text: string): void {
|
|
|
105
106
|
linkSync(temp, file); fsyncDirectory(base); unlinkSync(temp); fsyncDirectory(base);
|
|
106
107
|
}
|
|
107
108
|
function syncIndex(profile: BoundProfile): void {
|
|
108
|
-
const file = join(profile.dir,
|
|
109
|
+
const file = join(profile.dir, indexFileName(true)); owned(file); const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
109
110
|
try { fsyncSync(fd); } finally { closeSync(fd); } fsyncDirectory(profile.dir);
|
|
110
111
|
}
|
|
111
112
|
function nextProfile(realm: Realm, intent: Intent): BoundProfile {
|
package/src/memory/search-fts.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { rankedScoreSql, rankedScoreSqlDetails, type ScoreFactors } from "./search-score.ts";
|
|
1
|
+
import { captureEventWeightSql, rankedScoreSql, rankedScoreSqlDetails, type ScoreFactors } from "./search-score.ts";
|
|
2
2
|
import { Database } from "bun:sqlite";
|
|
3
3
|
import { chmodSync, existsSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
@@ -204,8 +204,8 @@ export class ScreenedSearchIndex<T> {
|
|
|
204
204
|
if ((db.query("SELECT sqlite_compileoption_used('TEMP_STORE=3') AS memoryOnly").get() as { memoryOnly: number }).memoryOnly) throw Error("disk-backed search unavailable");
|
|
205
205
|
db.run("PRAGMA temp_store = FILE");
|
|
206
206
|
db.run(this.available
|
|
207
|
-
? "CREATE VIRTUAL TABLE temp.docs USING fts5(body, ref UNINDEXED, tier UNINDEXED, at UNINDEXED, payload UNINDEXED, expires UNINDEXED)"
|
|
208
|
-
: "CREATE TEMP TABLE docs (body TEXT, ref TEXT, tier TEXT, at TEXT, payload TEXT, expires REAL)");
|
|
207
|
+
? "CREATE VIRTUAL TABLE temp.docs USING fts5(body, ref UNINDEXED, tier UNINDEXED, at UNINDEXED, payload UNINDEXED, expires UNINDEXED, event UNINDEXED, session UNINDEXED)"
|
|
208
|
+
: "CREATE TEMP TABLE docs (body TEXT, ref TEXT, tier TEXT, at TEXT, payload TEXT, expires REAL, event TEXT, session TEXT)");
|
|
209
209
|
db.run("PRAGMA temp.cache_size = -2048");
|
|
210
210
|
// A platform spill minimum can exceed cache_size (macOS: 20,000 pages).
|
|
211
211
|
// Spill at the cache target so dirty TEMP pages cannot accumulate behind that minimum.
|
|
@@ -214,13 +214,13 @@ export class ScreenedSearchIndex<T> {
|
|
|
214
214
|
} catch (error) { db?.close(); throw error; }
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
-
append(rows: readonly { body: string; ref: string; tier: string; at: string; value: T; expiresAt?: string | null }[]): void {
|
|
218
|
-
const insert = this.db.query("INSERT INTO docs (body, ref, tier, at, payload, expires) VALUES (?, ?, ?, ?, ?, ?)");
|
|
217
|
+
append(rows: readonly { body: string; ref: string; tier: string; at: string; value: T; expiresAt?: string | null; event?: string; session?: string }[]): void {
|
|
218
|
+
const insert = this.db.query("INSERT INTO docs (body, ref, tier, at, payload, expires, event, session) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
|
|
219
219
|
this.db.transaction(() => {
|
|
220
220
|
for (const row of rows) {
|
|
221
221
|
const deadline = row.expiresAt == null ? null : Date.parse(row.expiresAt);
|
|
222
222
|
// Invalid deadlines fail closed; this sentinel predates every representable Date.
|
|
223
|
-
insert.run(row.body, row.ref, row.tier, row.at, JSON.stringify(row.value), deadline !== null && !Number.isFinite(deadline) ? Number.MIN_SAFE_INTEGER : deadline);
|
|
223
|
+
insert.run(row.body, row.ref, row.tier, row.at, JSON.stringify(row.value), deadline !== null && !Number.isFinite(deadline) ? Number.MIN_SAFE_INTEGER : deadline, row.event ?? null, row.session ?? null);
|
|
224
224
|
}
|
|
225
225
|
})();
|
|
226
226
|
}
|
|
@@ -242,6 +242,31 @@ export class ScreenedSearchIndex<T> {
|
|
|
242
242
|
});
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
+
/** Weighted capture-only fill after the caller fixes the legacy noncapture slots. The caller has already screened every row before append. */
|
|
246
|
+
bestCaptureGroups(query: string, limit: number, now: number, useFts = this.available, explain = false): { value: T; score: number; scoreDetails?: ScoreFactors }[] {
|
|
247
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || useFts && !ftsTerms(query)) return [];
|
|
248
|
+
const instant = new Date(now).toISOString(), score = useFts ? SCREENED_SCORE : SCREENED_SUBSTRING_SCORE;
|
|
249
|
+
const weight = captureEventWeightSql("event");
|
|
250
|
+
const factors = explain ? rankedScoreSqlDetails(useFts ? "bm25(docs)" : "-1.0", useFts) : undefined;
|
|
251
|
+
const columns = factors ? `, ${factors.rawBm25Rank} AS rawBm25Rank, ${factors.tierFactor} AS tierFactor, ${factors.recencyFactor} AS recencyFactor` : "";
|
|
252
|
+
const where = "WHERE tier = 'capture' AND (expires IS NULL OR expires > ?)" + (useFts ? " AND docs MATCH ?" : "");
|
|
253
|
+
const selected: { value: T; score: number; scoreDetails?: ScoreFactors }[] = [], groups = new Set<string>();
|
|
254
|
+
// Sort once in SQLite-owned TEMP storage, then stream past duplicate sessions without growing JS pages or re-sorting OFFSET windows.
|
|
255
|
+
const statement = this.db.query(`SELECT payload, ref, event, session, ${score} AS score${columns} FROM docs ${where} ORDER BY score * ${weight} DESC, ref`);
|
|
256
|
+
const orderedArgs = [instant, ...(explain ? [instant] : []), now, ...(useFts ? [ftsTerms(query)] : [])];
|
|
257
|
+
for (const row of statement.iterate(...orderedArgs) as Iterable<{ payload: string; ref: string; event: string | null; session: string | null; score: number; rawBm25Rank?: number | null; tierFactor?: number | null; recencyFactor?: number | null }>) {
|
|
258
|
+
const session = typeof row.session === "string" && /^[a-f0-9]{16}$/.test(row.session) ? row.session : undefined;
|
|
259
|
+
const group = session ? `session:${session}` : `row:${row.ref}`;
|
|
260
|
+
if (groups.has(group)) continue;
|
|
261
|
+
groups.add(group);
|
|
262
|
+
const hit: { value: T; score: number; scoreDetails?: ScoreFactors } = { value: JSON.parse(row.payload) as T, score: row.score };
|
|
263
|
+
if (explain) hit.scoreDetails = { rawBm25Rank: row.rawBm25Rank ?? null, tierFactor: row.tierFactor ?? null, recencyFactor: row.recencyFactor ?? null, finalScore: row.score, matchMode: useFts ? "fts5" : "substring" };
|
|
264
|
+
selected.push(hit);
|
|
265
|
+
if (selected.length === limit) break;
|
|
266
|
+
}
|
|
267
|
+
return selected;
|
|
268
|
+
}
|
|
269
|
+
|
|
245
270
|
close(): void {
|
|
246
271
|
if (this.closed) return;
|
|
247
272
|
this.closed = true;
|
|
@@ -250,6 +275,7 @@ export class ScreenedSearchIndex<T> {
|
|
|
250
275
|
}
|
|
251
276
|
|
|
252
277
|
interface RawCaptureRow { id: number; at: string; project: string; agent: string; event: string; tool: string | null; paths: string; status: string | null; excerpt: string; session?: string | null; subagent?: number | null }
|
|
278
|
+
export interface CaptureSearchOptions { weightEvents?: boolean; onePerSession?: boolean; now?: number }
|
|
253
279
|
|
|
254
280
|
/**
|
|
255
281
|
* The capture leg of #13 search: BM25 over the excerpts of one capture shard (`machine/<id>` of a project shard,
|
|
@@ -257,21 +283,43 @@ interface RawCaptureRow { id: number; at: string; project: string; agent: string
|
|
|
257
283
|
* Rows older than `notBefore` (an ISO time) are left out; the store passes the shard's retention cutoff, so an expired
|
|
258
284
|
* row is not recalled before a later append evicts it.
|
|
259
285
|
*/
|
|
260
|
-
export function captureSearch(dir: string, q: string, limit: number, notBefore?: string): { row: CaptureRow; rank: number }[] {
|
|
286
|
+
export function captureSearch(dir: string, q: string, limit: number, notBefore?: string, options: CaptureSearchOptions = {}): { row: CaptureRow; rank: number }[] {
|
|
261
287
|
const match = ftsTerms(q);
|
|
262
|
-
if (!match || !fts5Available() || !existsSync(join(dir, "capture.db"))) return [];
|
|
288
|
+
if (!match || !Number.isSafeInteger(limit) || limit < 1 || !fts5Available() || !existsSync(join(dir, "capture.db"))) return [];
|
|
289
|
+
const weightEvents = options.weightEvents !== false, onePerSession = options.onePerSession ?? weightEvents;
|
|
290
|
+
const eventWeight = captureEventWeightSql("r.event");
|
|
291
|
+
const weightedOrder = `bm25(fts) * ${eventWeight}, r.id`;
|
|
292
|
+
const selected: { row: CaptureRow; rank: number }[] = [], groups = new Set<string>();
|
|
263
293
|
let db: Database | undefined;
|
|
264
294
|
try {
|
|
265
295
|
db = new Database(join(dir, "capture.db"), { readonly: true });
|
|
266
296
|
db.run("PRAGMA busy_timeout = 3000");
|
|
267
297
|
if (!db.query("SELECT 1 FROM sqlite_master WHERE name = 'fts' AND type = 'table'").get()) return [];
|
|
268
|
-
//
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
298
|
+
// Filter first, then sort once. In the grouped path the first row of each session is
|
|
299
|
+
// its best complete-score match, so stopping at limit cannot hide a better unread row.
|
|
300
|
+
const matchWhere = notBefore === undefined ? "fts MATCH ?" : "fts MATCH ? AND r.at >= ?";
|
|
301
|
+
const args: (string | number)[] = notBefore === undefined ? [match] : [match, notBefore];
|
|
302
|
+
const order = onePerSession
|
|
303
|
+
? `${rankedScoreSql("bm25(fts)")} * ${weightEvents ? eventWeight : "1.0"} DESC, r.id`
|
|
304
|
+
: weightEvents ? weightedOrder : "rank";
|
|
305
|
+
// The capture tier is an existing score input, projected only for the shared SQL recipe.
|
|
306
|
+
const sql = `SELECT r.*, ${onePerSession ? "'capture' AS tier, " : ""}bm25(fts) AS rank FROM fts JOIN rows r ON r.id = fts.rowid WHERE ${matchWhere} ORDER BY ${order}${onePerSession ? "" : " LIMIT ?"}`;
|
|
307
|
+
args.push(onePerSession ? new Date(options.now ?? Date.now()).toISOString() : limit);
|
|
308
|
+
const statement = db.query(sql);
|
|
309
|
+
const rows = (onePerSession ? statement.iterate(...args) : statement.all(...args)) as Iterable<RawCaptureRow & { rank: number }>;
|
|
310
|
+
for (const r of rows) {
|
|
311
|
+
const session = typeof r.session === "string" && /^[a-f0-9]{16}$/.test(r.session) ? r.session : undefined;
|
|
312
|
+
if (onePerSession && session) {
|
|
313
|
+
if (groups.has(session)) continue;
|
|
314
|
+
groups.add(session);
|
|
315
|
+
}
|
|
316
|
+
const row: CaptureRow = { id: r.id, at: r.at, project: r.project, agent: r.agent, event: r.event, tool: r.tool, paths: JSON.parse(r.paths) as string[], status: r.status, excerpt: r.excerpt,
|
|
317
|
+
// #173 attribution, mirrored from capture-input.ts captureAttribution (this module stays import-light for the ablation harness).
|
|
318
|
+
...(session ? { session } : {}), ...(r.subagent === 1 ? { subagent: true as const } : {}) };
|
|
319
|
+
selected.push({ row, rank: r.rank });
|
|
320
|
+
if (selected.length === limit) break;
|
|
321
|
+
}
|
|
322
|
+
return selected;
|
|
275
323
|
} catch { return []; }
|
|
276
324
|
finally { db?.close(); }
|
|
277
325
|
}
|
|
@@ -14,6 +14,17 @@ type ScoreExpr = { op: "rank" | "tier" | "age" } | { op: "constant"; value: numb
|
|
|
14
14
|
const MONTH_DAYS = 30;
|
|
15
15
|
const TIER_WEIGHTS: Readonly<Record<string, number>> = { long: 1, capture: 0.5 };
|
|
16
16
|
const DEFAULT_TIER_WEIGHT = 0.9;
|
|
17
|
+
// ponytail: fixed 1.25x ceiling for conversational capture, not a learned relevance model.
|
|
18
|
+
// Raise it only with fresh S/M no-loss and private-capture evidence; curated tier factors stay unchanged.
|
|
19
|
+
export const CAPTURE_CONVERSATION_WEIGHT = 1.25;
|
|
20
|
+
const CONVERSATIONAL_CAPTURE_EVENTS = ["Stop", "UserPromptSubmit", "Title", "Recap", "Compact"] as const;
|
|
21
|
+
export function captureEventWeight(event: string): number {
|
|
22
|
+
return (CONVERSATIONAL_CAPTURE_EVENTS as readonly string[]).includes(event) ? CAPTURE_CONVERSATION_WEIGHT : 1;
|
|
23
|
+
}
|
|
24
|
+
/** Only fixed internal SQL identifiers are supplied by callers; event values remain stored data. */
|
|
25
|
+
export function captureEventWeightSql(eventColumn: string): string {
|
|
26
|
+
return `(CASE WHEN ${eventColumn} IN (${CONVERSATIONAL_CAPTURE_EVENTS.map(event => `'${event}'`).join(", ")}) THEN ${CAPTURE_CONVERSATION_WEIGHT} ELSE 1.0 END)`;
|
|
27
|
+
}
|
|
17
28
|
const TIER_FACTOR: ScoreExpr = { op: "tier" };
|
|
18
29
|
const DENOMINATOR: ScoreExpr = { op: "add", left: { op: "constant", value: 1 }, right: { op: "max", left: { op: "constant", value: 0 }, right: { op: "age" } } };
|
|
19
30
|
const RECENCY_FACTOR: ScoreExpr = { op: "reciprocal", value: DENOMINATOR };
|
|
@@ -53,6 +64,9 @@ function sql(expr: ScoreExpr, rank: string): string {
|
|
|
53
64
|
export function rankedScore(rank: number, tier: string, at: string, now: number): number {
|
|
54
65
|
return numeric(SCORE, rank, tier, at, now);
|
|
55
66
|
}
|
|
67
|
+
export function captureWeightedScore(rank: number, event: string, at: string, now: number): number {
|
|
68
|
+
return rankedScore(rank, "capture", at, now) * captureEventWeight(event);
|
|
69
|
+
}
|
|
56
70
|
/** SQL uses fixed internal identifiers and bound clock values, never interpolated row/query input. */
|
|
57
71
|
export function rankedScoreSql(rank = "bm25(docs)"): string { return sql(SCORE, rank); }
|
|
58
72
|
export function rankedScoreSqlDetails(rank = "bm25(docs)", useFts = true): { rawBm25Rank: string; tierFactor: string; recencyFactor: string } {
|
package/src/memory/shard.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { assertNoPublicPruneIntent, readPublicPruneView } from "./public-prune-view.ts";
|
|
2
|
+
import { indexFileName } from "./index-file.ts";
|
|
2
3
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
4
|
import { existsSync, linkSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
5
|
import { join } from "node:path";
|
|
@@ -145,7 +146,7 @@ export function assertCuratedShardPaths(memoryDir: string, opts: { project?: str
|
|
|
145
146
|
check(dir, true);
|
|
146
147
|
for (const folder of ["log", "local", "legacy", "legacy/log", "legacy/local", "legacy-main", "legacy-main/log", "legacy-main/local"]) check(join(dir, folder), true);
|
|
147
148
|
for (const name of ["project.json", "generation.json", "generation-seal.json", "migration-sources.json", "machine.json", "legacy/machine.json", "legacy-main/machine.json", "prune-translations.jsonl"]) check(join(dir, name), false);
|
|
148
|
-
for (const database of ["index.db", "index-v2.db", "search.db", "mutation-lock.db"]) for (const suffix of ["", "-wal", "-shm", "-journal"]) check(join(dir, database + suffix), false);
|
|
149
|
+
for (const database of new Set(["index.db", "index-v2.db", indexFileName(false), indexFileName(true), "search.db", "mutation-lock.db"])) for (const suffix of ["", "-wal", "-shm", "-journal"]) check(join(dir, database + suffix), false);
|
|
149
150
|
}
|
|
150
151
|
|
|
151
152
|
/** Open one already-sealed shard without consulting the legacy home or the catalog. */
|