@staix/agent-mem 0.3.1 → 0.5.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/README.md +76 -6
- package/package.json +4 -3
- package/src/cli.ts +126 -15
- package/src/commands-table.ts +16 -2
- package/src/index.ts +13 -0
- package/src/mcp-memory.ts +194 -0
- package/src/mcp.ts +122 -0
- package/src/memory/candidates.ts +22 -14
- package/src/memory/capacity.ts +179 -0
- package/src/memory/capture.ts +557 -0
- package/src/memory/catalog.ts +135 -0
- package/src/memory/commands.ts +7 -4
- package/src/memory/generation-index.ts +54 -11
- package/src/memory/generation.ts +41 -2
- package/src/memory/home.ts +5 -0
- package/src/memory/import.ts +59 -0
- package/src/memory/migrate.ts +8 -0
- package/src/memory/mutation.ts +13 -1
- package/src/memory/ops-contract.ts +19 -2
- package/src/memory/policy.ts +120 -0
- package/src/memory/project.ts +22 -1
- package/src/memory/reader-lease.ts +136 -0
- package/src/memory/screen.ts +8 -0
- package/src/memory/search-fts.ts +244 -0
- package/src/memory/shard-bridge.ts +19 -0
- package/src/memory/shard-migration.ts +562 -0
- package/src/memory/shard.ts +213 -0
- package/src/memory/store.ts +376 -67
- package/src/memory/vault.ts +40 -0
- package/src/memory/work-lists.ts +331 -0
package/src/memory/store.ts
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
-
import { appendFileSync, chmodSync, closeSync, existsSync, fstatSync, linkSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { appendFileSync, chmodSync, closeSync, constants, existsSync, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { isAbsolute, join, normalize } from "node:path";
|
|
5
5
|
import { isDenied } from "./platform.ts";
|
|
6
|
+
import { looksSecret } from "./screen.ts";
|
|
6
7
|
import { mutationDatabase } from "./mutation.ts";
|
|
7
8
|
import { memoryHome } from "./home.ts";
|
|
9
|
+
import { readWindowSql, readRef, type ReadWindow, type ReadRef, SearchIndex, captureSearch } from "./search-fts.ts";
|
|
10
|
+
import { assertCuratedShardPaths, ensureShardDescriptor, readShardDescriptor, readShardedSelection, resolveAlias, resolveShardProfile, shardDir, shardKeyHash, shardMachineDir, userShardDir } from "./shard.ts";
|
|
11
|
+
import { readCaptureConfig, type CaptureRow } from "./capture.ts";
|
|
8
12
|
import { assertSharedV1Op } from "./ops-contract.ts";
|
|
9
|
-
import { generationDescriptor, generationDir, canonicalJSON, readProfileJSON, resolveProfile, type BoundProfile, type ProfileInput } from "./generation.ts";
|
|
13
|
+
import { generationDescriptor, generationDir, fsyncDirectory, canonicalJSON, hashJSON, readProfileJSON, resolveProfile, writeProfileJSON, type BoundProfile, type ProfileInput, validateVaultLinkRow, vaultLinkId, type VaultLink, type VaultLinkRow } from "./generation.ts";
|
|
10
14
|
import { GenerationIndex, explicitExpirySnapshot, GENERATION_TABLES } from "./generation-index.ts";
|
|
11
15
|
import { legacyEntries, type LegacyCheckpoint } from "./bridge.ts";
|
|
16
|
+
import { readMigrationSources } from "./migrate.ts";
|
|
12
17
|
import type { PruneStorePort } from "./prune.ts";
|
|
13
18
|
import { isDeepStrictEqual } from "node:util";
|
|
19
|
+
import { registerReaderLease } from "./reader-lease.ts";
|
|
14
20
|
|
|
15
21
|
/**
|
|
16
22
|
* #258: the hub's own memory. Append-only JSONL logs are the truth: `log/<machine>.jsonl` holds long-term entries and
|
|
@@ -79,10 +85,8 @@ function canonical(value: unknown): string {
|
|
|
79
85
|
}
|
|
80
86
|
export const entryId = (e: Omit<Entry, "id">): string => createHash("sha256").update(canonical(e)).digest("hex").slice(0, 32);
|
|
81
87
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
/** A secret-looking string: refused before anything is written, since long-term entries leave the machine. */
|
|
85
|
-
export const looksSecret = (text: string): boolean => SECRETS.some((re) => re.test(text));
|
|
88
|
+
// Single-sourced in screen.ts so the capture tier (#13) screens excerpts without importing this store; the export stays.
|
|
89
|
+
export { looksSecret };
|
|
86
90
|
|
|
87
91
|
/** `task`: the board task's id and proposal time; its node carries both, so a task id reused after `ahub reset --all` is another node. */
|
|
88
92
|
export interface Extract { cwd: string; project: string; repo: string; deny?: string[]; paths?: string[]; symbols?: string[]; task?: { id: number; created: number }; checkExists?: boolean }
|
|
@@ -162,6 +166,7 @@ export interface View {
|
|
|
162
166
|
/** Written as short-term, so it lives in this machine's local segment and never syncs. */
|
|
163
167
|
local: boolean;
|
|
164
168
|
}
|
|
169
|
+
export interface VaultEntry { view: View; /** Full authored hash in generation profiles; empty for read-only shared-v1. */ rowHash: string }
|
|
165
170
|
export interface GoalView {
|
|
166
171
|
id: string; at: string; machine: string; project: string; scope: Scope; title: string; milestones: string[]; dependsOn: string[];
|
|
167
172
|
state: GoalState; links: { task: string; at: string; machine: string }[];
|
|
@@ -169,6 +174,14 @@ export interface GoalView {
|
|
|
169
174
|
lease: { holder: string | null; until: string | null; conflict: string[]; expired: boolean; lastHolder?: string | null };
|
|
170
175
|
}
|
|
171
176
|
export interface Filter { project?: string; tier?: Tier; kind?: Kind; peer?: string; machine?: string; since?: string; q?: string; limit?: number; includeSuperseded?: boolean }
|
|
177
|
+
/** #13 search: `Filter` plus the capture-tier switch (on by default; capture is recall, never curated evidence). */
|
|
178
|
+
export interface SearchFilter extends Filter {
|
|
179
|
+
includeCapture?: boolean;
|
|
180
|
+
/** A host that writes capture with a `retentionDays` override passes the same value here; files decide otherwise. */
|
|
181
|
+
captureRetentionDays?: number;
|
|
182
|
+
}
|
|
183
|
+
export interface SearchHit { source: "curated" | "capture"; score: number; view?: View; capture?: CaptureRow }
|
|
184
|
+
export interface SearchResult { engine: "fts5" | "substring"; hits: SearchHit[] }
|
|
172
185
|
export interface Graph { nodes: { id: Node; type: string; label: string; kind?: string; tier?: Tier }[]; edges: { from: Node; to: Node; rel: string }[] }
|
|
173
186
|
|
|
174
187
|
/**
|
|
@@ -180,11 +193,13 @@ export type PromotionScreen = (v: View) => "promote" | "keep" | "skip";
|
|
|
180
193
|
* `screen`: applied to every promotion. Without one, automatic promotions (a settlement, a held note whose replacement
|
|
181
194
|
* went) wait for a process that has one; a person's explicit promote goes through.
|
|
182
195
|
*/
|
|
183
|
-
export interface StoreOptions { home?: string; ttlMs?: number; now?: () => number; log?: (line: string) => void; screen?: PromotionScreen; deferPromotions?: boolean; profile?: ProfileInput;
|
|
196
|
+
export interface StoreOptions { home?: string; memoryDir?: string; ttlMs?: number; now?: () => number; log?: (line: string) => void; screen?: PromotionScreen; deferPromotions?: boolean; profile?: ProfileInput;
|
|
197
|
+
/** #27: read the `aliases` keys' curated rows as part of the `canonical` project (CLI and MCP only); the library default reads the raw key. Rows are presented under `canonical`. */
|
|
198
|
+
reconcile?: { canonical: string; aliases: readonly string[] };
|
|
184
199
|
}
|
|
185
200
|
|
|
186
201
|
const MIGRATION_BOOTSTRAP = Symbol("migration index bootstrap");
|
|
187
|
-
interface BootstrapBinding { token: typeof MIGRATION_BOOTSTRAP; profile: BoundProfile; limits
|
|
202
|
+
interface BootstrapBinding { token: typeof MIGRATION_BOOTSTRAP; profile: BoundProfile; limits?: readonly LegacyCheckpoint[]; commit?: string; authoring?: boolean; userOverlay?: MemoryStore; aliasStores?: MemoryStore[] }
|
|
188
203
|
export class MemoryStore {
|
|
189
204
|
readonly dir: string;
|
|
190
205
|
readonly profile: BoundProfile;
|
|
@@ -197,49 +212,78 @@ export class MemoryStore {
|
|
|
197
212
|
private readonly now: () => number;
|
|
198
213
|
private readonly log: (line: string) => void;
|
|
199
214
|
private readonly deferPromotions: boolean;
|
|
215
|
+
/** #13: the derived FTS5 copy of the curated rows, and the memory home base for capture shard resolution (#15 T1). */
|
|
216
|
+
private readonly searchIndex: SearchIndex;
|
|
217
|
+
private readonly memoryBaseDir: string;
|
|
218
|
+
private releaseReaderLease?: () => void;
|
|
219
|
+
private readonly userOverlay?: MemoryStore;
|
|
220
|
+
/** #27: the other keys' shards of a reconciled read (curated shards); shared-v1 reads them from its own index. */
|
|
221
|
+
private readonly aliasStores: MemoryStore[];
|
|
222
|
+
private readonly reconcile?: { canonical: string; aliases: readonly string[] };
|
|
223
|
+
/** A full rebuild refills FTS in one resync instead of one derived transaction per entry. */
|
|
224
|
+
private rebuildingSearch = false;
|
|
200
225
|
|
|
201
226
|
constructor(opts: StoreOptions = {}, bootstrap?: BootstrapBinding) {
|
|
202
227
|
if (bootstrap && bootstrap.token !== MIGRATION_BOOTSTRAP) throw new Error("invalid migration bootstrap");
|
|
203
|
-
this.bootstrapOnly = bootstrap !==
|
|
204
|
-
|
|
228
|
+
this.bootstrapOnly = bootstrap ? bootstrap.authoring !== true : false;
|
|
229
|
+
this.userOverlay = bootstrap?.userOverlay;
|
|
230
|
+
this.aliasStores = bootstrap?.aliasStores ?? [];
|
|
231
|
+
this.reconcile = opts.reconcile && opts.reconcile.aliases.length ? { canonical: opts.reconcile.canonical, aliases: [...opts.reconcile.aliases] } : undefined;
|
|
232
|
+
if (opts.memoryDir !== undefined && (opts.home !== undefined || typeof opts.memoryDir !== "string" || !opts.memoryDir.trim())) throw new Error("choose a memory home or one explicit memory directory");
|
|
233
|
+
const home = bootstrap?.profile.home ?? opts.memoryDir ?? (opts.home === undefined ? memoryHome() : join(opts.home, "memory"));
|
|
234
|
+
if (!bootstrap && lstatSync(join(home, "shards.json"), { throwIfNoEntry: false })) throw new Error("memory home has a curated split; hosts must use openShardStore({ project }) or openShardStore({ user: true }); finish an incomplete split with amem migrate --shards --resume");
|
|
205
235
|
this.profile = bootstrap?.profile ?? resolveProfile(home, opts.profile);
|
|
206
236
|
if (bootstrap && (this.profile.kind !== "generation" || !this.profile.generation
|
|
207
|
-
|| this.profile.dir !== generationDir(this.profile.home, this.profile.generation)
|
|
237
|
+
|| (!this.profile.sharded && this.profile.dir !== generationDir(this.profile.home, this.profile.generation))
|
|
208
238
|
|| canonicalJSON(readProfileJSON(join(this.profile.dir, "generation.json"))) !== canonicalJSON(generationDescriptor(this.profile.generation)))) throw new Error("invalid migration profile binding");
|
|
209
239
|
// Shared-v1 preserves its historical lexical dir API; profile.home is still canonical for selector identity.
|
|
210
240
|
this.dir = this.profile.kind === "shared-v1" ? home : this.profile.dir;
|
|
211
241
|
mkdirSync(join(this.dir, "log"), { recursive: true, mode: 0o700 });
|
|
212
242
|
mkdirSync(join(this.dir, "local"), { recursive: true, mode: 0o700 });
|
|
213
243
|
chmodSync(this.dir, 0o700); // mkdir's mode applies only when it creates the directory
|
|
214
|
-
this.machine = machineId(this.dir);
|
|
244
|
+
this.machine = machineId(this.profile.sharded ? this.profile.home : this.dir);
|
|
215
245
|
this.ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
216
246
|
this.now = opts.now ?? Date.now;
|
|
217
247
|
this.log = opts.log ?? (() => {});
|
|
218
248
|
this.screen = opts.screen;
|
|
219
249
|
this.deferPromotions = opts.deferPromotions ?? false;
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
db.run(`PRAGMA
|
|
250
|
+
// Announce before opening derived handles or reading rows; failed admission leaks no database handles.
|
|
251
|
+
if (!bootstrap) this.releaseReaderLease = registerReaderLease(this.profile.home);
|
|
252
|
+
let openedDb: Database | undefined, openedMutation: Database | undefined, openedSearch: SearchIndex | undefined;
|
|
253
|
+
try {
|
|
254
|
+
const db = openedDb = new Database(join(this.dir, "index.db"), { create: true });
|
|
255
|
+
db.run(`PRAGMA busy_timeout = ${MEMORY_DB_BUSY_TIMEOUT_MS}`);
|
|
256
|
+
db.run("PRAGMA journal_mode = WAL");
|
|
257
|
+
if ((db.query("PRAGMA user_version").get() as { user_version: number }).user_version !== INDEX_VERSION) {
|
|
258
|
+
for (const t of ["segments", "heads", "entries", "ops", "edges", "goals"]) db.run(`DROP TABLE IF EXISTS ${t}`);
|
|
259
|
+
db.run(`PRAGMA user_version = ${INDEX_VERSION}`);
|
|
260
|
+
}
|
|
261
|
+
db.run("CREATE TABLE IF NOT EXISTS segments (file TEXT PRIMARY KEY, offset INTEGER NOT NULL)");
|
|
262
|
+
// The hash of each segment's whole indexed prefix: a segment rewritten in place (same size or larger) is caught.
|
|
263
|
+
db.run("CREATE TABLE IF NOT EXISTS heads (file TEXT PRIMARY KEY, hash TEXT NOT NULL)");
|
|
264
|
+
db.run("CREATE TABLE IF NOT EXISTS goals (id TEXT PRIMARY KEY, at TEXT NOT NULL, machine TEXT NOT NULL, project TEXT NOT NULL, scope TEXT NOT NULL, title TEXT NOT NULL, milestones TEXT NOT NULL, depends TEXT NOT NULL, actor TEXT NOT NULL)");
|
|
265
|
+
db.run(`CREATE TABLE IF NOT EXISTS entries (id TEXT PRIMARY KEY, at TEXT NOT NULL, machine TEXT NOT NULL, project TEXT NOT NULL, scope TEXT NOT NULL, tier TEXT NOT NULL,
|
|
266
|
+
kind TEXT NOT NULL, peer TEXT NOT NULL, title TEXT, text TEXT NOT NULL, task TEXT, about TEXT NOT NULL, supersedes TEXT, promoted_from TEXT, source TEXT NOT NULL, expires TEXT)`);
|
|
267
|
+
db.run("CREATE TABLE IF NOT EXISTS ops (id TEXT PRIMARY KEY, op TEXT NOT NULL, at TEXT NOT NULL, machine TEXT NOT NULL, target TEXT NOT NULL, by TEXT, actor TEXT NOT NULL)");
|
|
268
|
+
db.run("CREATE TABLE IF NOT EXISTS edges (src TEXT NOT NULL, dst TEXT NOT NULL, PRIMARY KEY (src, dst))");
|
|
269
|
+
db.run("CREATE INDEX IF NOT EXISTS edges_dst ON edges (dst)");
|
|
270
|
+
this.db = db;
|
|
271
|
+
this.mutationDb = openedMutation = mutationDatabase(this.dir);
|
|
272
|
+
// One derived FTS copy per profile: a generation handle and a shared-v1 handle on one home never resync-fight.
|
|
273
|
+
this.searchIndex = openedSearch = new SearchIndex(this.profile.sharded ? join(this.dir, "search.db") : join(home, this.profile.kind === "generation" ? `search-${this.profile.generation}.db` : "search.db"));
|
|
274
|
+
this.memoryBaseDir = home;
|
|
275
|
+
try { chmodSync(join(this.dir, "index.db"), 0o600); } catch { /* best effort */ }
|
|
276
|
+
if (this.profile.kind === "generation") this.generationIndex = new GenerationIndex({ profile: this.profile, db, now: this.now,
|
|
277
|
+
apply: (line, source) => this.apply(line, source), withMutation: work => this.withMutation(work),
|
|
278
|
+
...(bootstrap ? { limits: bootstrap.limits } : {}), ...(bootstrap?.commit ? { commit: bootstrap.commit } : {}) });
|
|
279
|
+
this.refresh();
|
|
280
|
+
} catch (error) {
|
|
281
|
+
for (const handle of [openedSearch, openedMutation, openedDb]) { try { handle?.close(); } catch { /* preserve the opening failure */ } }
|
|
282
|
+
const release = this.releaseReaderLease;
|
|
283
|
+
this.releaseReaderLease = undefined;
|
|
284
|
+
release?.();
|
|
285
|
+
throw error;
|
|
226
286
|
}
|
|
227
|
-
db.run("CREATE TABLE IF NOT EXISTS segments (file TEXT PRIMARY KEY, offset INTEGER NOT NULL)");
|
|
228
|
-
// The hash of each segment's whole indexed prefix: a segment rewritten in place (same size or larger) is caught.
|
|
229
|
-
db.run("CREATE TABLE IF NOT EXISTS heads (file TEXT PRIMARY KEY, hash TEXT NOT NULL)");
|
|
230
|
-
db.run("CREATE TABLE IF NOT EXISTS goals (id TEXT PRIMARY KEY, at TEXT NOT NULL, machine TEXT NOT NULL, project TEXT NOT NULL, scope TEXT NOT NULL, title TEXT NOT NULL, milestones TEXT NOT NULL, depends TEXT NOT NULL, actor TEXT NOT NULL)");
|
|
231
|
-
db.run(`CREATE TABLE IF NOT EXISTS entries (id TEXT PRIMARY KEY, at TEXT NOT NULL, machine TEXT NOT NULL, project TEXT NOT NULL, scope TEXT NOT NULL, tier TEXT NOT NULL,
|
|
232
|
-
kind TEXT NOT NULL, peer TEXT NOT NULL, title TEXT, text TEXT NOT NULL, task TEXT, about TEXT NOT NULL, supersedes TEXT, promoted_from TEXT, source TEXT NOT NULL, expires TEXT)`);
|
|
233
|
-
db.run("CREATE TABLE IF NOT EXISTS ops (id TEXT PRIMARY KEY, op TEXT NOT NULL, at TEXT NOT NULL, machine TEXT NOT NULL, target TEXT NOT NULL, by TEXT, actor TEXT NOT NULL)");
|
|
234
|
-
db.run("CREATE TABLE IF NOT EXISTS edges (src TEXT NOT NULL, dst TEXT NOT NULL, PRIMARY KEY (src, dst))");
|
|
235
|
-
db.run("CREATE INDEX IF NOT EXISTS edges_dst ON edges (dst)");
|
|
236
|
-
this.db = db;
|
|
237
|
-
this.mutationDb = mutationDatabase(this.dir);
|
|
238
|
-
try { chmodSync(join(this.dir, "index.db"), 0o600); } catch { /* best effort */ }
|
|
239
|
-
if (this.profile.kind === "generation") this.generationIndex = new GenerationIndex({ profile: this.profile, db, now: this.now,
|
|
240
|
-
apply: (line, source) => this.apply(line, source), withMutation: work => this.withMutation(work),
|
|
241
|
-
...(bootstrap ? { limits: bootstrap.limits } : {}), ...(bootstrap?.commit ? { commit: bootstrap.commit } : {}) });
|
|
242
|
-
this.refresh();
|
|
243
287
|
}
|
|
244
288
|
|
|
245
289
|
/** The host must authenticate person intent before authorizing plan/apply through this port. */
|
|
@@ -248,13 +292,72 @@ export class MemoryStore {
|
|
|
248
292
|
return this.generationIndex.port(this.machine.id);
|
|
249
293
|
}
|
|
250
294
|
|
|
295
|
+
/** Read-only project views, bounded before bodies or hashes; never promotes or authors a prune. */
|
|
296
|
+
vaultEntries(project: string): VaultEntry[] {
|
|
297
|
+
if (!project.trim() || this.profile.sharded && !this.profile.shardUser && this.profile.shardKey !== project) throw new Error("vault project does not match the bound store");
|
|
298
|
+
this.withDeferredPromotions(() => this.ensureFresh());
|
|
299
|
+
const scanLimit = 5000;
|
|
300
|
+
const own = this.db.query("SELECT id FROM entries WHERE project IN (SELECT value FROM json_each(?)) LIMIT ?").all(JSON.stringify(this.scopeKeys(project)), scanLimit + 1) as { id: string }[];
|
|
301
|
+
if (own.length > scanLimit) throw new Error("vault scan budget exceeded; refine the store before retrying");
|
|
302
|
+
const overlay = new Map([...(this.userOverlay?.vaultEntries(project) ?? []), ...this.aliasStores.flatMap(store => store.vaultEntries(store.profile.shardKey!))].map(entry => [entry.view.id, entry.rowHash]));
|
|
303
|
+
if (new Set([...own.map(row => row.id), ...overlay.keys()]).size > scanLimit) throw new Error("vault scan budget exceeded; refine the store before retrying");
|
|
304
|
+
const authored = this.generationIndex?.vaultWitnesses(own.map(row => row.id));
|
|
305
|
+
return this.views(false, { project }).filter(view => view.project === project).map(view => {
|
|
306
|
+
// A shared-v1 read cannot record links and needs no authored witness or new index field.
|
|
307
|
+
const rowHash = this.generationIndex ? authored?.get(view.id) ?? overlay.get(view.id) : "";
|
|
308
|
+
if (rowHash === undefined) throw new Error("authored vault entry witness unavailable");
|
|
309
|
+
return { view, rowHash };
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
vaultLinks(project: string): VaultLink[] {
|
|
313
|
+
this.ensureFresh();
|
|
314
|
+
if (!project.trim() || this.profile.sharded && !this.profile.shardUser && this.profile.shardKey !== project) throw new Error("vault project does not match the bound store");
|
|
315
|
+
return [...this.scopeKeys(project).flatMap(key => this.generationIndex?.vaultLinks(key) ?? []), ...(this.userOverlay?.vaultLinks(project) ?? []), ...this.aliasStores.flatMap(store => store.vaultLinks(store.profile.shardKey!))];
|
|
316
|
+
}
|
|
317
|
+
/** Exact id/hash recheck under the existing mutation fence; never follows promoted/superseded aliases. */
|
|
318
|
+
recordVaultLink(id: string, path: string, project: string, rowHash: string): string {
|
|
319
|
+
if (this.bootstrapOnly || !this.generationIndex) throw new Error("vault links require an active generation handle");
|
|
320
|
+
if (this.userOverlay?.vaultEntries(project).some(entry => entry.view.id === id)) return this.userOverlay.recordVaultLink(id, path, project, rowHash);
|
|
321
|
+
const aliased = this.aliasStores.find(store => store.vaultEntries(store.profile.shardKey!).some(entry => entry.view.id === id));
|
|
322
|
+
if (aliased) return aliased.recordVaultLink(id, path, aliased.profile.shardKey!, rowHash);
|
|
323
|
+
return this.withMutation(() => {
|
|
324
|
+
this.refresh();
|
|
325
|
+
const entry = this.vaultEntries(project).find(row => row.view.id === id && row.rowHash === rowHash);
|
|
326
|
+
if (!entry || entry.view.tier !== "long" || entry.view.local || entry.view.supersededBy) throw new Error("exact vault entry changed or unavailable");
|
|
327
|
+
// A link names the key the row was written under (never the canonical label a reconciled read shows), so a raw-key reader still finds it.
|
|
328
|
+
const written = this.rawProject(entry.view);
|
|
329
|
+
const prior = this.vaultLinks(project).find(row => row.target === id && row.rowHash === rowHash && row.path === path);
|
|
330
|
+
if (prior) return prior.id;
|
|
331
|
+
const body = { schema: "agent-mem.vault-link/v1", op: "vault_link", generation: this.profile.generation!,
|
|
332
|
+
at: new Date(this.now()).toISOString(), machine: this.machine.id, actor: "person", placement: "local", target: id, rowHash, project: written, path } as const;
|
|
333
|
+
const row: VaultLinkRow = { ...body, id: vaultLinkId(body) };
|
|
334
|
+
validateVaultLinkRow(row, this.profile.generation, "local");
|
|
335
|
+
this.generationIndex!.assertAppendable(true, this.machine.id);
|
|
336
|
+
const file = join(this.dir, "local", `${this.machine.id}.jsonl`), existed = existsSync(file);
|
|
337
|
+
const fd = openSync(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
|
|
338
|
+
try {
|
|
339
|
+
const stat = fstatSync(fd);
|
|
340
|
+
if (!stat.isFile() || stat.nlink !== 1) throw new Error("vault link segment is not an owned regular file");
|
|
341
|
+
appendFileSync(fd, canonicalJSON(row) + "\n"); fsyncSync(fd);
|
|
342
|
+
} finally { closeSync(fd); }
|
|
343
|
+
if (!existed) fsyncDirectory(join(this.dir, "local"));
|
|
344
|
+
try { this.refresh(); } catch (error) { this.stale = true; throw error; }
|
|
345
|
+
return row.id;
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
251
349
|
/** Full derived receipt for golden replay; raw debug data, never model evidence. */
|
|
252
350
|
derivedTables(): Record<string, unknown[]> {
|
|
253
351
|
const tables = this.db.query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all() as { name: string }[];
|
|
254
352
|
return Object.fromEntries(tables.map(({ name }) => [name, (this.db.query(`SELECT * FROM "${name.replaceAll('"', '""')}"`).all() as unknown[]).sort((a, b) => canonicalJSON(a).localeCompare(canonicalJSON(b)))]));
|
|
255
353
|
}
|
|
256
354
|
|
|
257
|
-
close(): void {
|
|
355
|
+
close(): void {
|
|
356
|
+
const release = this.releaseReaderLease;
|
|
357
|
+
this.releaseReaderLease = undefined;
|
|
358
|
+
try { this.db.close(); this.mutationDb.close(); this.searchIndex.close(); }
|
|
359
|
+
finally { try { this.userOverlay?.close(); } finally { try { for (const store of this.aliasStores) store.close(); } finally { release?.(); } } }
|
|
360
|
+
}
|
|
258
361
|
|
|
259
362
|
/** A synchronous host-authorized mutation whose reads must not implicitly promote unrelated notes. */
|
|
260
363
|
withDeferredPromotions<T>(work: () => T): T {
|
|
@@ -269,6 +372,7 @@ export class MemoryStore {
|
|
|
269
372
|
|
|
270
373
|
/** Serialize current-store writers, screen one normalized body, and expose a one-shot append of that body. */
|
|
271
374
|
withAcceptedNote<T>(note: NoteInput, work: (append: () => string) => T): T {
|
|
375
|
+
if (this.userOverlay && note.scope === "shared") return this.userOverlay.withAcceptedNote(note, work);
|
|
272
376
|
try { return this.withMutation(() => {
|
|
273
377
|
// Ordinary writes may defer indexing, but accepting an exact target revision must have a current index.
|
|
274
378
|
this.db.transaction(() => this.refresh()).immediate();
|
|
@@ -307,6 +411,12 @@ export class MemoryStore {
|
|
|
307
411
|
|
|
308
412
|
private appendBody(body: Omit<Entry, "id">, local: boolean): string {
|
|
309
413
|
if (this.bootstrapOnly) throw new Error("migration bootstrap cannot author memory");
|
|
414
|
+
if (this.userOverlay && (body.op === "note" || body.op === "goal") && "scope" in body && body.scope === "shared") return this.userOverlay.appendBody(body, local);
|
|
415
|
+
if ("target" in body && body.op !== "settle") { const owner = this.ownerOf(body.target as string); if (owner) return owner.appendBody(body, local); }
|
|
416
|
+
if (this.profile.sharded && (body.op === "note" || body.op === "goal") && "scope" in body && "project" in body) {
|
|
417
|
+
if (this.profile.shardUser ? body.scope !== "shared" : body.scope !== "project" || body.project !== this.profile.shardKey) throw new Error("entry does not belong to this curated shard");
|
|
418
|
+
}
|
|
419
|
+
if (this.profile.sharded && body.op === "settle" && "target" in body && (typeof body.target !== "string" || (!this.profile.shardUser && !body.target.startsWith(`${this.profile.shardKey}#`)))) throw new Error("settlement does not belong to this curated shard");
|
|
310
420
|
assertSharedV1Op(body.op);
|
|
311
421
|
// Lock the authoritative log independently: a busy derived index must never prevent an ordinary append.
|
|
312
422
|
return this.withMutation(() => {
|
|
@@ -330,6 +440,8 @@ export class MemoryStore {
|
|
|
330
440
|
*/
|
|
331
441
|
importNote(n: NoteInput, at: string): { id: string; added: boolean } {
|
|
332
442
|
if (this.bootstrapOnly) throw new Error("migration bootstrap cannot import memory");
|
|
443
|
+
if (this.userOverlay && n.scope === "shared") return this.userOverlay.importNote(n, at);
|
|
444
|
+
if (this.profile.sharded && (this.profile.shardUser ? n.scope !== "shared" : n.scope !== "project" || n.project !== this.profile.shardKey)) throw new Error("entry does not belong to this curated shard");
|
|
333
445
|
assertSharedV1Op(n.op);
|
|
334
446
|
// A fixed machine: the same observation imported on two machines is one entry once they sync.
|
|
335
447
|
const body = { schema: MEMORY_SCHEMA, at, machine: "claude-mem", ...n, text: n.text.slice(0, MAX_TEXT), ...(n.title ? { title: n.title.slice(0, MAX_TITLE) } : {}) } as Omit<Entry, "id">;
|
|
@@ -419,6 +531,8 @@ export class MemoryStore {
|
|
|
419
531
|
*/
|
|
420
532
|
promote(id: string, actor: string, project?: string, auto = false): string | undefined {
|
|
421
533
|
const v = this.get(id, project);
|
|
534
|
+
const owner = v ? this.ownerOf(v.id) : undefined;
|
|
535
|
+
if (v && owner) return owner.promote(v.id, actor, owner.profile.shardKey ?? project, auto);
|
|
422
536
|
if (v && this.generationIndex && !this.generationIndex.own(v.id)) return undefined;
|
|
423
537
|
if (!v || v.tier !== "short") return v?.id;
|
|
424
538
|
if (v.supersededBy) return undefined;
|
|
@@ -429,7 +543,7 @@ export class MemoryStore {
|
|
|
429
543
|
// ponytail: the check and the append are not one step across processes: a person forgetting or replacing this entry
|
|
430
544
|
// at the very moment another process promotes it can leave the copy current on other machines. A per-machine lock
|
|
431
545
|
// around local mutations (like sync's) would close it.
|
|
432
|
-
return this.append({ op: "note", project: v
|
|
546
|
+
return this.append({ op: "note", project: this.rawProject(v), scope: v.scope, tier: "long", kind: v.kind, peer: v.peer, ...(v.title ? { title: v.title } : {}), text: v.text,
|
|
433
547
|
...(v.task ? { task: v.task } : {}), about: v.about, promotedFrom: v.id, source: v.source as NoteEntry["source"] }, false,
|
|
434
548
|
// Dated as its source, from its source's machine and with its fields, whoever promotes and wherever: two processes or
|
|
435
549
|
// two machines promoting the same entry write one id, which the index keeps once.
|
|
@@ -442,8 +556,13 @@ export class MemoryStore {
|
|
|
442
556
|
settle(task: string, actor: string): number {
|
|
443
557
|
this.promoting = true; // promoted here, in the settling actor's name, not after each read
|
|
444
558
|
try {
|
|
445
|
-
|
|
446
|
-
|
|
559
|
+
const entry = { op: "settle", target: task, actor, by: this.until() } as const;
|
|
560
|
+
const at = new Date(this.now()).toISOString();
|
|
561
|
+
// #27: a task key `<key>#...` belongs to the shard of `<key>`. In a reconciled sharded store that can be an aliased key's shard, which refuses a settlement of any other key.
|
|
562
|
+
const owner = this.aliasStores.find(store => store.profile.shardKey !== undefined && task.startsWith(`${store.profile.shardKey}#`));
|
|
563
|
+
(owner ?? this).append(entry, true, at, owner ? this.machine.id : undefined);
|
|
564
|
+
if (this.userOverlay) this.userOverlay.append(entry, true, at, this.machine.id);
|
|
565
|
+
return this.promoteSettled(task, actor) + (this.userOverlay?.promoteSettled(task, actor) ?? 0) + this.aliasStores.reduce((n, store) => n + store.promoteSettled(task, actor), 0);
|
|
447
566
|
} finally { this.promoting = false; }
|
|
448
567
|
}
|
|
449
568
|
private promoteSettled(task: string, actor: string): number {
|
|
@@ -453,12 +572,12 @@ export class MemoryStore {
|
|
|
453
572
|
}
|
|
454
573
|
/** Whether a task was settled in this profile. Reconciliation may opt into bridged legacy settlements. */
|
|
455
574
|
settled(task: string, options: { includeLegacy?: boolean } = {}): boolean {
|
|
456
|
-
return
|
|
575
|
+
return this.readRows(this.generationIndex && !options.includeLegacy
|
|
457
576
|
? "SELECT 1 FROM ops JOIN generation_rows ON generation_rows.id=ops.id WHERE op='settle' AND target=? AND own=1"
|
|
458
|
-
: "SELECT 1 FROM ops WHERE op = 'settle' AND target = ?").
|
|
577
|
+
: "SELECT 1 FROM ops WHERE op = 'settle' AND target = ?", task).length > 0;
|
|
459
578
|
}
|
|
460
579
|
/** Whether any entry was saved against the task. */
|
|
461
|
-
hasTask(task: string): boolean { return
|
|
580
|
+
hasTask(task: string): boolean { this.ensureFresh(); return this.readRows("SELECT 1 FROM entries WHERE task = ?", task).length > 0; }
|
|
462
581
|
private promoting = false;
|
|
463
582
|
private readonly screen?: PromotionScreen;
|
|
464
583
|
/**
|
|
@@ -546,9 +665,14 @@ export class MemoryStore {
|
|
|
546
665
|
|
|
547
666
|
/** Drop the index and read every segment again: the same rows as the incremental index. */
|
|
548
667
|
rebuild(): void {
|
|
668
|
+
this.searchIndex.reset();
|
|
549
669
|
if (this.generationIndex) { this.generationIndex.rebuild(); return; }
|
|
550
670
|
// One transaction: no reader sees the index empty, and no other process commits an offset in between.
|
|
551
|
-
this.
|
|
671
|
+
this.rebuildingSearch = true;
|
|
672
|
+
try {
|
|
673
|
+
this.db.transaction(() => { for (const t of ["segments", "heads", "entries", "ops", "edges", "goals"]) this.db.run(`DELETE FROM ${t}`); this.refresh(); }).immediate();
|
|
674
|
+
} finally { this.rebuildingSearch = false; }
|
|
675
|
+
this.syncSearchIndex(); // one batched derived write, not one search.db transaction per rebuilt entry
|
|
552
676
|
}
|
|
553
677
|
|
|
554
678
|
private segments(sub: "log" | "local"): string[] {
|
|
@@ -572,9 +696,13 @@ export class MemoryStore {
|
|
|
572
696
|
if (e.op === "note") {
|
|
573
697
|
if (!KINDS.includes(e.kind) || typeof e.text !== "string" || !Array.isArray(e.about) || !str(e.project, 400) || !str(e.peer, 64)
|
|
574
698
|
|| !opt(e.title) || !opt(e.task) || !opt(e.supersedes) || !opt(e.promotedFrom) || !opt(e.expires) || (typeof e.expires === "string" && Number.isNaN(Date.parse(e.expires))) || !opt(e.source) || !["project", "shared"].includes(e.scope as string) || !["short", "long"].includes(e.tier as string)) { this.log(`memory segment ${segment}: a malformed entry was skipped`); return; }
|
|
575
|
-
this.db.query(`INSERT OR IGNORE INTO entries (id, at, machine, project, scope, tier, kind, peer, title, text, task, about, supersedes, promoted_from, source, expires)
|
|
699
|
+
const inserted = this.db.query(`INSERT OR IGNORE INTO entries (id, at, machine, project, scope, tier, kind, peer, title, text, task, about, supersedes, promoted_from, source, expires)
|
|
576
700
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, e.at, e.machine, e.project, e.scope === "shared" ? "shared" : "project", e.tier === "long" ? "long" : "short",
|
|
577
|
-
e.kind, e.peer, e.title ?? null, e.text, e.task ?? null, JSON.stringify(e.about.map(String)), e.supersedes ?? null, e.promotedFrom ?? null, e.source ?? "hub", typeof e.expires === "string" ? e.expires : null);
|
|
701
|
+
e.kind, e.peer, e.title ?? null, e.text, e.task ?? null, JSON.stringify(e.about.map(String)), e.supersedes ?? null, e.promotedFrom ?? null, e.source ?? "hub", typeof e.expires === "string" ? e.expires : null).changes;
|
|
702
|
+
// The FTS copy follows the committed row. `changes` is only a hint here (bun:sqlite misreports it for ignored
|
|
703
|
+
// inserts inside a transaction): addCurated is idempotent by entry id, and a missed hint becomes a count drift
|
|
704
|
+
// the next search resyncs.
|
|
705
|
+
if (inserted && !this.rebuildingSearch) this.searchIndex.addCurated({ id, at: e.at, project: e.project, tier: e.tier === "long" ? "long" : "short", scope: e.scope === "shared" ? "shared" : "project", body: `${e.title ?? ""}\n${e.text}\n${e.about.map(String).join(" ")}` });
|
|
578
706
|
for (const node of e.about) this.db.query("INSERT OR IGNORE INTO edges (src, dst) VALUES (?, ?)").run(id, String(node));
|
|
579
707
|
} else if (e.op === "goal") {
|
|
580
708
|
if (!str(e.title, 400) || !Array.isArray(e.milestones) || !Array.isArray(e.dependsOn) || !str(e.project, 400) || !opt(e.actor) || !opt(e.scope)) { this.log(`memory segment ${segment}: a malformed entry was skipped`); return; }
|
|
@@ -588,26 +716,59 @@ export class MemoryStore {
|
|
|
588
716
|
}
|
|
589
717
|
}
|
|
590
718
|
|
|
719
|
+
/** Only the bound project and user overlay participate; never scan another project's private shard. */
|
|
720
|
+
private readStores(): MemoryStore[] { return [this, ...(this.userOverlay ? [this.userOverlay] : []), ...this.aliasStores]; }
|
|
721
|
+
/** #27: the keys a project filter covers: the project, and with a reconciled read every key aliased to it. */
|
|
722
|
+
private scopeKeys(project: string): string[] { return this.reconcile && project === this.reconcile.canonical ? [project, ...this.reconcile.aliases] : [project]; }
|
|
723
|
+
private inScope(rowProject: string, project: string): boolean { return this.scopeKeys(project).includes(rowProject); }
|
|
724
|
+
/** The key a row was written under. A reconciled read labels aliased rows with the canonical key, but every write that copies a row keeps its own key (#27). */
|
|
725
|
+
private rawProject(view: View): string {
|
|
726
|
+
if (!this.reconcile || !this.reconcile.aliases.length) return view.project;
|
|
727
|
+
for (const store of this.readStores()) {
|
|
728
|
+
const row = store.db.query("SELECT project FROM entries WHERE id = ?").get(view.id) as { project: string } | null;
|
|
729
|
+
if (row) return row.project;
|
|
730
|
+
}
|
|
731
|
+
return view.project;
|
|
732
|
+
}
|
|
733
|
+
/** A row of an aliased key reads under the canonical key. */
|
|
734
|
+
private label(project: string): string { return this.reconcile?.aliases.includes(project) ? this.reconcile.canonical : project; }
|
|
735
|
+
/** The store that holds the log line of `id`, when it is not this one: the user overlay or an aliased key's shard. */
|
|
736
|
+
private ownerOf(id: string): MemoryStore | undefined { return [...(this.userOverlay ? [this.userOverlay] : []), ...this.aliasStores].find(store => store.ownsTarget(id)); }
|
|
737
|
+
|
|
738
|
+
private readRows(sql: string, ...bind: string[]): Record<string, unknown>[] {
|
|
739
|
+
const rows = this.readStores().flatMap(store => store.db.query(sql).all(...bind) as Record<string, unknown>[]);
|
|
740
|
+
const unique = new Map<string, Record<string, unknown>>();
|
|
741
|
+
for (const row of rows) unique.set(typeof row.id === "string" ? row.id : canonicalJSON(row), row);
|
|
742
|
+
return [...unique.values()].sort((a, b) => String(a.at ?? "").localeCompare(String(b.at ?? "")) || String(a.id ?? "").localeCompare(String(b.id ?? "")));
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
private ownsTarget(id: string): boolean {
|
|
746
|
+
this.ensureFresh();
|
|
747
|
+
return !!this.db.query("SELECT id FROM entries WHERE id=? UNION SELECT id FROM goals WHERE id=? UNION SELECT id FROM ops WHERE id=?").get(id, id, id);
|
|
748
|
+
}
|
|
749
|
+
|
|
591
750
|
/**
|
|
592
751
|
* Every live note with its derived state; an expired one is left out as soon as its deadline passes, before any
|
|
593
752
|
* prune. Lifecycle relations still fold globally; SQL selects full rows before View/JSON materialization.
|
|
594
753
|
* ponytail: lifecycle metadata is still scanned on every read; measure before adding a revision cache.
|
|
595
754
|
*/
|
|
596
|
-
private views(expired = false, selection?: Filter, expiredOnly = false): View[] {
|
|
755
|
+
private views(expired = false, selection?: Filter & { ids?: string[] }, expiredOnly = false): View[] {
|
|
597
756
|
// Metadata, ops and candidate bodies must describe one WAL snapshot, even if another process indexes a copy.
|
|
598
|
-
|
|
757
|
+
const read = () => this.viewRows(expired, selection, expiredOnly);
|
|
758
|
+
const nest = (stores: MemoryStore[]): View[] => stores.length ? stores[0]!.db.transaction(() => nest(stores.slice(1))).deferred() : read();
|
|
759
|
+
return nest(this.readStores());
|
|
599
760
|
}
|
|
600
761
|
|
|
601
|
-
private viewRows(expired: boolean, selection: Filter | undefined, expiredOnly: boolean): View[] {
|
|
762
|
+
private viewRows(expired: boolean, selection: (Filter & { ids?: string[] }) | undefined, expiredOnly: boolean): View[] {
|
|
602
763
|
const now = this.now(), selecting = selection !== undefined || expiredOnly;
|
|
603
|
-
const pruned = this.generationIndex ? new Set((this.
|
|
604
|
-
if (this.generationIndex
|
|
764
|
+
const pruned = this.generationIndex ? new Set((this.readRows("SELECT id FROM generation_targets") as { id: string }[]).map(row => row.id)) : undefined;
|
|
765
|
+
if (this.generationIndex) this.generationIndex.pruned = new Set((this.db.query("SELECT id FROM generation_targets").all() as { id: string }[]).map(row => row.id));
|
|
605
766
|
// Relations may cross projects and promoted copies: fold their small metadata globally, never filtered bodies.
|
|
606
|
-
const rows = this.
|
|
767
|
+
const rows = this.readRows(selecting
|
|
607
768
|
? "SELECT id, at, tier, kind, task, expires, supersedes, promoted_from FROM entries"
|
|
608
|
-
: "SELECT * FROM entries")
|
|
609
|
-
const ops = this.
|
|
610
|
-
const realms = this.generationIndex ? new Map((this.
|
|
769
|
+
: "SELECT * FROM entries") as Record<string, string | null>[];
|
|
770
|
+
const ops = this.readRows("SELECT id, op, at, target, by FROM ops ORDER BY at, id") as { id: string; op: string; at: string; target: string; by: string | null }[];
|
|
771
|
+
const realms = this.generationIndex ? new Map((this.readRows("SELECT id,own,legacy FROM generation_rows") as { id: string; own: number; legacy: number }[]).map(row => [row.id, row])) : undefined;
|
|
611
772
|
const born = realms ? new Set([...realms.values()].filter(row => row.own).map(row => row.id)) : undefined;
|
|
612
773
|
const forgotten = new Set(ops.filter((o) => o.op === "forget").map((o) => o.target));
|
|
613
774
|
// Deadlines were fixed when the entries were written, so every process (whatever its own TTL) prunes the same rows.
|
|
@@ -693,7 +854,13 @@ export class MemoryStore {
|
|
|
693
854
|
ids(hidden, true);
|
|
694
855
|
if (expiredOnly) ids(expiredIds, false); else if (!expired) ids(expiredIds, true);
|
|
695
856
|
if (selection) {
|
|
696
|
-
if (selection.project) {
|
|
857
|
+
if (selection.project) {
|
|
858
|
+
// The exact-key form is unchanged unless a reconciled read widens it (#27).
|
|
859
|
+
const keys = this.scopeKeys(selection.project);
|
|
860
|
+
if (keys.length === 1) { where.push("(project = ? OR scope = 'shared')"); bind.push(selection.project); }
|
|
861
|
+
else { where.push("(project IN (SELECT value FROM json_each(?)) OR scope = 'shared')"); bind.push(JSON.stringify(keys)); }
|
|
862
|
+
}
|
|
863
|
+
if (selection.ids) ids(selection.ids, false);
|
|
697
864
|
if (selection.tier) {
|
|
698
865
|
if (demoted.size) { where.push("CASE WHEN id IN (SELECT value FROM json_each(?)) THEN 'short' ELSE tier END = ?"); bind.push(JSON.stringify([...demoted.keys()]), selection.tier); }
|
|
699
866
|
else { where.push("tier = ?"); bind.push(selection.tier); }
|
|
@@ -703,7 +870,7 @@ export class MemoryStore {
|
|
|
703
870
|
}
|
|
704
871
|
// No SQL LIMIT: a host may screen the complete matching set before its own caps or visible counts.
|
|
705
872
|
// Date parsing, Unicode query matching, locale order and slice semantics stay in JavaScript.
|
|
706
|
-
candidates = this.
|
|
873
|
+
candidates = this.readRows(`SELECT * FROM entries${where.length ? ` WHERE ${where.join(" AND ")}` : ""}`, ...bind) as Record<string, string | null>[];
|
|
707
874
|
}
|
|
708
875
|
const out: View[] = [];
|
|
709
876
|
for (const r of candidates) {
|
|
@@ -713,7 +880,7 @@ export class MemoryStore {
|
|
|
713
880
|
const tier: Tier = demoted.has(id) ? "short" : r.tier as Tier;
|
|
714
881
|
const expiresAt = expiry(r);
|
|
715
882
|
if (!expired && expiresAt !== null && Date.parse(expiresAt) <= now) continue;
|
|
716
|
-
out.push({ id, at: r.at!, machine: r.machine!, project: r.project
|
|
883
|
+
out.push({ id, at: r.at!, machine: r.machine!, project: this.label(r.project!), scope: r.scope as Scope, tier, kind: r.kind as Kind, peer: r.peer!, title: r.title ?? null, text: r.text!,
|
|
717
884
|
task: r.task ?? null, about: JSON.parse(r.about!), source: r.source!, supersededBy: superseded.get(id) ?? null, supersedes: supersedes.get(id) ?? [], promotedFrom: r.promoted_from ?? null, expiresAt, local: r.tier === "short" });
|
|
718
885
|
}
|
|
719
886
|
return out;
|
|
@@ -734,11 +901,14 @@ export class MemoryStore {
|
|
|
734
901
|
for (const v of expired) for (let id: string | undefined = v.id; id && !gone.has(id); id = parent.get(id)) gone.add(id);
|
|
735
902
|
if (!gone.size) return 0;
|
|
736
903
|
this.db.transaction(() => { for (const id of gone) { this.db.query("DELETE FROM entries WHERE id = ?").run(id); this.db.query("DELETE FROM edges WHERE src = ?").run(id); } }).immediate();
|
|
904
|
+
this.searchIndex.markDirty(); // FTS cannot subtract rows incrementally: the next search resyncs it
|
|
737
905
|
return gone.size;
|
|
738
906
|
}
|
|
739
907
|
|
|
740
908
|
/** Index deferred writes before any note, goal or suggestion read. */
|
|
741
909
|
private ensureFresh(): void {
|
|
910
|
+
this.userOverlay?.ensureFresh();
|
|
911
|
+
for (const store of this.aliasStores) store.ensureFresh();
|
|
742
912
|
if (this.generationIndex) { this.generationIndex.refresh(); return; }
|
|
743
913
|
// Another process (a CLI, another project's hub, a sync) may have written since this one last read the segments.
|
|
744
914
|
if (this.stale || this.shape() !== this.seen) try { this.stale = false; this.refresh(); } catch { this.stale = true; /* still busy: the next read tries again */ }
|
|
@@ -750,7 +920,7 @@ export class MemoryStore {
|
|
|
750
920
|
*/
|
|
751
921
|
private live(selection?: Filter): View[] {
|
|
752
922
|
this.ensureFresh();
|
|
753
|
-
this.prune(); this.promoteHeld(); return this.views(false, selection);
|
|
923
|
+
this.prune(); this.userOverlay?.promoteHeld(); for (const store of this.aliasStores) store.promoteHeld(); this.promoteHeld(); return this.views(false, selection);
|
|
754
924
|
}
|
|
755
925
|
/** A write whose index update failed: the next read refreshes first. */
|
|
756
926
|
private stale = false;
|
|
@@ -758,24 +928,118 @@ export class MemoryStore {
|
|
|
758
928
|
/** Entries visible from `project` (its own, and `shared` ones of any project), newest first, filtered. */
|
|
759
929
|
list(f: Filter = {}): View[] {
|
|
760
930
|
const q = f.q?.toLowerCase().split(/\s+/).filter(Boolean) ?? [];
|
|
761
|
-
return this.live(f).filter((v) => (!f.project || v.project
|
|
931
|
+
return this.live(f).filter((v) => (!f.project || this.inScope(v.project, f.project) || v.scope === "shared") && (!f.tier || v.tier === f.tier) && (!f.kind || v.kind === f.kind)
|
|
762
932
|
&& (!f.peer || v.peer === f.peer) && (!f.machine || v.machine === f.machine) && (!f.since || Date.parse(v.at) >= Date.parse(f.since)) && (f.includeSuperseded || !v.supersededBy)
|
|
763
933
|
&& q.every((w) => `${v.title ?? ""}\n${v.text}\n${v.about.join(" ")}`.toLowerCase().includes(w)))
|
|
764
934
|
.sort((a, b) => b.at.localeCompare(a.at) || a.id.localeCompare(b.id)).slice(0, f.limit ?? 200);
|
|
765
935
|
}
|
|
766
936
|
|
|
937
|
+
/** Exact-project body pages for policy-aware callers. Relations still fold over complete metadata. */
|
|
938
|
+
readPage(window: ReadWindow & { query?: string; includeSuperseded?: boolean }): { rows: View[]; next?: ReadRef } {
|
|
939
|
+
this.ensureFresh(); this.prune();
|
|
940
|
+
const refs: ReadRef[] = [];
|
|
941
|
+
for (const store of this.readStores()) {
|
|
942
|
+
if (window.query !== undefined && store.searchIndex.available) {
|
|
943
|
+
store.syncSearchIndex(); refs.push(...store.searchIndex.candidates(window.query, this.reconcile ? { ...window, projects: this.scopeKeys(window.project) } : window));
|
|
944
|
+
} else {
|
|
945
|
+
const { columns, where, bind } = readWindowSql(this.reconcile ? { ...window, projects: this.scopeKeys(window.project) } : window, "id");
|
|
946
|
+
refs.push(...(store.db.query(`SELECT ${columns} FROM entries WHERE ${where.join(" AND ")} ORDER BY candidate_position, candidate_id LIMIT ?`)
|
|
947
|
+
.all(...bind, window.limit) as { candidate_id: string; at: string; candidate_position: number }[]).map(readRef));
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
const page = [...new Map(refs.map(ref => [ref.id, ref])).values()].sort((a, b) => a.position - b.position || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)).slice(0, window.limit);
|
|
951
|
+
if (!page.length) return { rows: [] };
|
|
952
|
+
const views = new Map(this.views(false, { project: window.project, ids: page.map(ref => ref.id), includeSuperseded: window.includeSuperseded })
|
|
953
|
+
.filter(view => view.project === window.project && (window.includeSuperseded || !view.supersededBy)).map(view => [view.id, view]));
|
|
954
|
+
return { rows: page.flatMap(ref => views.get(ref.id) ?? []), next: page[page.length - 1] };
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* #13 R5: natural-language search over the curated tier and this machine's capture tier, ranked by BM25 with
|
|
959
|
+
* tier and recency weights (curated outranks capture; a newer row beats an older one at equal text). Visibility
|
|
960
|
+
* (forgotten, superseded, expired, pruned) is decided by the same live views `list` uses. Without FTS5 the engine
|
|
961
|
+
* falls back to the substring filter and says so; capture recall needs FTS5 and is skipped in the fallback (the
|
|
962
|
+
* R3 cost of the fallback is the pre-#13 behavior: same scan, same memory).
|
|
963
|
+
*/
|
|
964
|
+
search(q: string, f: SearchFilter = {}): SearchResult {
|
|
965
|
+
const limit = f.limit ?? 200;
|
|
966
|
+
if (!this.searchIndex.available) return { engine: "substring", hits: this.list({ ...f, q }).map((view) => ({ source: "curated" as const, score: 0, view })) };
|
|
967
|
+
const visible = new Map(this.live(f).filter((v) => (!f.project || this.inScope(v.project, f.project) || v.scope === "shared")
|
|
968
|
+
&& (!f.since || Date.parse(v.at) >= Date.parse(f.since)) && (f.includeSuperseded || !v.supersededBy)).map((v) => [v.id, v]));
|
|
969
|
+
for (const store of this.readStores()) store.syncSearchIndex();
|
|
970
|
+
const now = this.now();
|
|
971
|
+
const recency = (at: string): number => 1 / (1 + Math.max(0, now - Date.parse(at)) / (30 * 24 * 3600_000));
|
|
972
|
+
const hits: SearchHit[] = [];
|
|
973
|
+
// The project filter runs inside the FTS query (its rows plus shared ones), so a busy sibling project can never
|
|
974
|
+
// starve this project's LIMIT.
|
|
975
|
+
// Superseded, forgotten, expired and `since` rows are filtered after FTS, so a fixed candidate cap could be filled
|
|
976
|
+
// by invisible rows and hide a visible match: widen the cap until `limit` visible hits or the index is exhausted.
|
|
977
|
+
// Pages of fixed size, so memory stays one page plus the hits (#13 R3) however many invisible rows rank first.
|
|
978
|
+
// ponytail: with almost nothing visible (a selective `since`, many superseded or forgotten rows) the pages walk the
|
|
979
|
+
// store's whole match set; push `since` into SearchIndex.query (docs carries `at`) if that time shows up.
|
|
980
|
+
const page = Math.max(limit * 4, 32);
|
|
981
|
+
for (const store of visible.size ? this.readStores() : []) {
|
|
982
|
+
let found = 0;
|
|
983
|
+
const seen = new Set<string>(); // another process's sync between pages can shift offsets and repeat a ref
|
|
984
|
+
for (let offset = 0; found < limit; offset += page) {
|
|
985
|
+
const ranked = store.searchIndex.query(q, page, f.project === undefined ? undefined : this.scopeKeys(f.project), offset);
|
|
986
|
+
for (const hit of ranked) {
|
|
987
|
+
const view = visible.get(hit.ref);
|
|
988
|
+
if (!view || seen.has(hit.ref)) continue;
|
|
989
|
+
seen.add(hit.ref);
|
|
990
|
+
found++;
|
|
991
|
+
hits.push({ source: "curated", score: -hit.rank * (view.tier === "long" ? 1 : 0.9) * recency(hit.at), view });
|
|
992
|
+
}
|
|
993
|
+
if (ranked.length < page) break;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
// #15 D4/T1: the capture tier is project-scoped; without a project key there is no shard to recall from.
|
|
997
|
+
if (f.includeCapture !== false && f.project) {
|
|
998
|
+
const shard = shardMachineDir(this.memoryBaseDir, resolveAlias(this.memoryBaseDir, f.project), this.machine.id);
|
|
999
|
+
// The retention cutoff and `since` run in the capture query, so neither expired nor old rows take a candidate slot.
|
|
1000
|
+
// An out-of-range retention_days gives no cutoff rather than failing the whole search (eviction keeps its own check).
|
|
1001
|
+
const override = f.captureRetentionDays, days = typeof override === "number" && Number.isFinite(override) && override > 0 ? override : readCaptureConfig(shard, this.memoryBaseDir).retentionDays;
|
|
1002
|
+
const cutoff = new Date(now - days * 24 * 3600_000);
|
|
1003
|
+
const retained = Number.isFinite(cutoff.getTime()) ? cutoff.toISOString() : undefined;
|
|
1004
|
+
const since = f.since && Number.isFinite(Date.parse(f.since)) ? new Date(Date.parse(f.since)).toISOString() : undefined;
|
|
1005
|
+
for (const { row, rank } of captureSearch(shard, q, Math.max(limit * 4, 32), since && (!retained || since > retained) ? since : retained)) {
|
|
1006
|
+
hits.push({ source: "capture", score: -rank * 0.5 * recency(row.at), capture: row });
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
// Where FTS misses entirely (a stopword-only query, a tokenization edge), the substring filter is the safety net.
|
|
1010
|
+
if (!hits.length && q.trim()) {
|
|
1011
|
+
for (const view of this.list({ ...f, q })) hits.push({ source: "curated", score: 0, view });
|
|
1012
|
+
}
|
|
1013
|
+
const ref = (h: SearchHit): string => h.view?.id ?? String(h.capture?.id ?? 0).padStart(12, "0");
|
|
1014
|
+
hits.sort((a, b) => b.score - a.score || ref(a).localeCompare(ref(b)));
|
|
1015
|
+
return { engine: "fts5", hits: hits.slice(0, limit) };
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/** Bring the derived FTS copy level with the curated rows when a prune, a crash, a lock or a rebuild drifted them apart. */
|
|
1019
|
+
private syncSearchIndex(): void {
|
|
1020
|
+
if (!this.searchIndex.available) return;
|
|
1021
|
+
const curated = (this.db.query("SELECT COUNT(*) AS n FROM entries").get() as { n: number }).n;
|
|
1022
|
+
if (!this.searchIndex.needsResync(curated)) return;
|
|
1023
|
+
const db = this.db;
|
|
1024
|
+
this.searchIndex.resync(function* () {
|
|
1025
|
+
for (const r of db.query("SELECT id, at, project, tier, scope, title, text, about FROM entries").iterate() as Iterable<{ id: string; at: string; project: string; tier: string; scope: string; title: string | null; text: string; about: string }>) {
|
|
1026
|
+
yield { id: r.id, at: r.at, project: r.project, tier: r.tier, scope: r.scope, body: `${r.title ?? ""}\n${r.text}\n${(JSON.parse(r.about) as string[]).join(" ")}` };
|
|
1027
|
+
}
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
|
|
767
1031
|
/** One entry by id or unique id prefix; with `project`, only one that project may read (its own or shared). */
|
|
768
1032
|
get(id: string, project?: string): View | undefined {
|
|
769
1033
|
id = id.replace(/^mem:/, ""); // briefs and recall print `mem:<id>`
|
|
770
|
-
const all = this.live().filter((v) => !project || v.project
|
|
1034
|
+
const all = this.live().filter((v) => !project || this.inScope(v.project, project) || v.scope === "shared");
|
|
771
1035
|
const pick = <T extends { id: string }>(rows: T[]) => rows.find((v) => v.id === id) ?? (id.length >= 6 ? (() => { const m = rows.filter((v) => v.id.startsWith(id)); return m.length === 1 ? m[0] : undefined; })() : undefined);
|
|
772
1036
|
if (this.generationIndex?.pruned.has(id)) return undefined;
|
|
773
1037
|
const found = pick(all);
|
|
774
1038
|
if (found) return found;
|
|
775
1039
|
// A short-term id an agent kept after its task was approved names the promoted copy now, here or on another machine
|
|
776
1040
|
// that has only the synced copy (which names its source).
|
|
777
|
-
let at = pick(this.
|
|
778
|
-
for (let copy = at; copy; copy = (this.
|
|
1041
|
+
let at = pick(this.readRows("SELECT id FROM entries UNION SELECT promoted_from AS id FROM entries WHERE promoted_from IS NOT NULL") as { id: string }[])?.id;
|
|
1042
|
+
for (let copy = at; copy; copy = (this.readRows("SELECT id FROM entries WHERE promoted_from = ?", copy)[0] as { id: string } | undefined)?.id) { if (this.generationIndex?.pruned.has(copy)) return undefined; at = copy; }
|
|
779
1043
|
return at ? all.find((v) => v.id === at) : undefined;
|
|
780
1044
|
}
|
|
781
1045
|
|
|
@@ -793,7 +1057,7 @@ export class MemoryStore {
|
|
|
793
1057
|
const near = new Map<string, 1 | 2>();
|
|
794
1058
|
const about = (ns: Iterable<Node>) => {
|
|
795
1059
|
const ids = new Set<string>();
|
|
796
|
-
for (const n of ns) for (const r of this.
|
|
1060
|
+
for (const n of ns) for (const r of this.readRows("SELECT src FROM edges WHERE dst = ?", n) as { src: string }[]) if (live.has(r.src)) ids.add(r.src);
|
|
797
1061
|
return ids;
|
|
798
1062
|
};
|
|
799
1063
|
const first = about(nodes);
|
|
@@ -893,9 +1157,9 @@ export class MemoryStore {
|
|
|
893
1157
|
goals(project?: string): GoalView[] {
|
|
894
1158
|
this.ensureFresh();
|
|
895
1159
|
const now = this.now();
|
|
896
|
-
const rows = this.
|
|
897
|
-
const ops = this.
|
|
898
|
-
return rows.filter((r) => !project || r.project
|
|
1160
|
+
const rows = this.readRows("SELECT * FROM goals") as { id: string; at: string; machine: string; project: string; scope: string; title: string; milestones: string; depends: string }[];
|
|
1161
|
+
const ops = this.readRows("SELECT id, op, at, machine, target, by FROM ops WHERE op IN ('goal_state', 'goal_link', 'lease', 'lease_force') ORDER BY at, id") as { op: string; at: string; machine: string; target: string; by: string | null }[];
|
|
1162
|
+
return rows.filter((r) => !project || this.inScope(r.project, project) || r.scope === "shared").map((r): GoalView => {
|
|
899
1163
|
const mine = ops.filter((o) => o.target === r.id);
|
|
900
1164
|
const states = mine.filter((o) => o.op === "goal_state" && (GOAL_STATES as readonly string[]).includes(o.by ?? ""));
|
|
901
1165
|
const links = mine.filter((o) => o.op === "goal_link" && o.by).map((o) => ({ task: o.by!, at: o.at, machine: o.machine }));
|
|
@@ -913,7 +1177,7 @@ export class MemoryStore {
|
|
|
913
1177
|
const active = [...latest].filter(([, until]) => Date.parse(until) > now).sort((a, b) => first.get(a[0])!.localeCompare(first.get(b[0])!) || a[0].localeCompare(b[0]));
|
|
914
1178
|
const last = fresh.at(-1);
|
|
915
1179
|
// One line whatever another machine wrote: titles are rendered into agents' context.
|
|
916
|
-
return { id: r.id, at: r.at, machine: r.machine, project: r.project, scope: r.scope as Scope, title: oneLine(r.title), milestones: (JSON.parse(r.milestones) as string[]).map(oneLine), dependsOn: JSON.parse(r.depends),
|
|
1180
|
+
return { id: r.id, at: r.at, machine: r.machine, project: this.label(r.project), scope: r.scope as Scope, title: oneLine(r.title), milestones: (JSON.parse(r.milestones) as string[]).map(oneLine), dependsOn: JSON.parse(r.depends),
|
|
917
1181
|
state: (states.at(-1)?.by as GoalState | undefined) ?? "open", links,
|
|
918
1182
|
lease: active.length ? { holder: active[0]![0], until: active[0]![1], conflict: active.slice(1).map(([m]) => m), expired: false }
|
|
919
1183
|
: { holder: null, until: last?.by ?? null, conflict: [], expired: !!last, lastHolder: last?.machine ?? null } };
|
|
@@ -943,7 +1207,7 @@ export class MemoryStore {
|
|
|
943
1207
|
suggestions(project?: string): Suggestion[] {
|
|
944
1208
|
this.ensureFresh();
|
|
945
1209
|
const visible = project ? new Set(this.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true }).map((v) => v.id)) : undefined;
|
|
946
|
-
const ops = this.
|
|
1210
|
+
const ops = this.readRows("SELECT id, op, at, target, by FROM ops WHERE op IN ('suggest', 'confirm', 'reject') ORDER BY at, id") as { id: string; op: string; at: string; target: string; by: string | null }[];
|
|
947
1211
|
const verdict = new Map<string, "confirmed" | "rejected">();
|
|
948
1212
|
for (const o of ops) if (o.op !== "suggest") verdict.set(o.target, o.op === "confirm" ? "confirmed" : "rejected");
|
|
949
1213
|
return ops.filter((o) => o.op === "suggest" && o.by).flatMap((o) => {
|
|
@@ -980,6 +1244,51 @@ export function buildGenerationIndex(profile: BoundProfile, sources: readonly Le
|
|
|
980
1244
|
finally { store.close(); }
|
|
981
1245
|
}
|
|
982
1246
|
|
|
1247
|
+
function initializeShard(home: string, opts: { project?: string; user?: boolean }): void {
|
|
1248
|
+
const selection = readShardedSelection(home), generation = selection.generation;
|
|
1249
|
+
const dir = opts.user === true ? userShardDir(home) : shardDir(home, opts.project!), machine = machineId(home);
|
|
1250
|
+
if (opts.project !== undefined) ensureShardDescriptor(home, opts.project);
|
|
1251
|
+
for (const folder of ["log", "local", "legacy/log", "legacy/local", "legacy-main/log", "legacy-main/local"]) mkdirSync(join(dir, folder), { recursive: true, mode: 0o700 });
|
|
1252
|
+
for (const [path, value] of [["generation.json", generationDescriptor(generation)], ["machine.json", machine], ["legacy/machine.json", machine], ["legacy-main/machine.json", machine]] as const) {
|
|
1253
|
+
const file = join(dir, path), prior = readProfileJSON(file);
|
|
1254
|
+
if (prior === undefined) writeProfileJSON(file, value);
|
|
1255
|
+
else if (canonicalJSON(prior) !== canonicalJSON(value)) throw new Error("curated shard metadata changed during initialization");
|
|
1256
|
+
}
|
|
1257
|
+
const legacyHome = join(dir, "legacy"), sources = readMigrationSources(legacyHome);
|
|
1258
|
+
writeProfileJSON(join(dir, "migration-sources.json"), { schema: "agent-mem.migration-sources/v1", sources });
|
|
1259
|
+
const profile: BoundProfile = { kind: "generation", home, dir, generation, selectorRevision: selection.selectorRevision,
|
|
1260
|
+
legacyHome, legacyMainHome: join(dir, "legacy-main"), sharded: true, ...(opts.user === true ? { shardUser: true } : { shardKey: opts.project! }) };
|
|
1261
|
+
buildGenerationIndex(profile, sources);
|
|
1262
|
+
writeProfileJSON(join(dir, "generation-seal.json"), { schema: "agent-mem.generation-seal/v1", generation,
|
|
1263
|
+
descriptorHash: hashJSON(generationDescriptor(generation)), sourceDigest: hashJSON({ sources, legacyCommit: undefined }) });
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/** Open one sealed project or user shard directly; no legacy-home scan, catalog walk or old-layout reader lease. */
|
|
1267
|
+
export function openShardStore(opts: StoreOptions & { project?: string; user?: boolean; memoryDir?: string }): MemoryStore {
|
|
1268
|
+
if (opts.user === true && opts.project !== undefined || opts.user !== true && !opts.project) throw new Error("choose exactly one project or the user shard");
|
|
1269
|
+
if (opts.memoryDir !== undefined && (opts.home !== undefined || typeof opts.memoryDir !== "string" || !opts.memoryDir.trim())) throw new Error("choose a memory home or one explicit memory directory");
|
|
1270
|
+
const home = opts.memoryDir ?? (opts.home === undefined ? memoryHome() : join(opts.home, "memory"));
|
|
1271
|
+
assertCuratedShardPaths(home, opts);
|
|
1272
|
+
const dir = opts.user === true ? userShardDir(home) : shardDir(home, opts.project!);
|
|
1273
|
+
const missing = !existsSync(join(dir, "generation.json"));
|
|
1274
|
+
if (missing) initializeShard(home, opts);
|
|
1275
|
+
const profile = resolveShardProfile(home, opts);
|
|
1276
|
+
const { reconcile, ...plain } = opts;
|
|
1277
|
+
const userOverlay = opts.user === true ? undefined : openShardStore({ ...plain, project: undefined, user: true });
|
|
1278
|
+
const aliasStores: MemoryStore[] = [];
|
|
1279
|
+
try {
|
|
1280
|
+
// #27: an aliased key that has a shard of its own is read beside this one; one that never had a shard has nothing to read and is not created.
|
|
1281
|
+
if (opts.user !== true && reconcile && reconcile.canonical === opts.project) {
|
|
1282
|
+
for (const alias of reconcile.aliases) {
|
|
1283
|
+
if (alias === opts.project || !existsSync(join(shardDir(home, alias), "generation.json"))) continue;
|
|
1284
|
+
assertCuratedShardPaths(home, { project: alias });
|
|
1285
|
+
aliasStores.push(new MemoryStore(plain, { token: MIGRATION_BOOTSTRAP, profile: resolveShardProfile(home, { project: alias }), authoring: true }));
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
return new MemoryStore(opts, { token: MIGRATION_BOOTSTRAP, profile, authoring: true, userOverlay, aliasStores });
|
|
1289
|
+
} catch (error) { for (const store of aliasStores) store.close(); userOverlay?.close(); throw error; }
|
|
1290
|
+
}
|
|
1291
|
+
|
|
983
1292
|
/** Person-only migration receipt; this inspector reads raw rows without opening a store or writing any index. */
|
|
984
1293
|
export function summarizeRawExpiry(rawLines: string[], now: number, tailBytes: number): import("./migrate.ts").MigrationCounts {
|
|
985
1294
|
const bytes = Buffer.from(rawLines.filter(Boolean).join("\n") + (rawLines.length ? "\n" : ""));
|