@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.
@@ -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
- const SECRETS = [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, /\bAKIA[0-9A-Z]{16}\b/, /\bgh[pousr]_[A-Za-z0-9]{30,}/, /\bgithub_pat_[A-Za-z0-9_]{40,}/, /\bsk-[A-Za-z0-9_-]{20,}/,
83
- /\bxox[abprs]-[A-Za-z0-9-]{10,}/, /\bAIza[0-9A-Za-z_-]{35}\b/, /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/, /\b(?:password|passwd|secret|api[_-]?key|token)\s*[:=]\s*\S{8,}/i];
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: readonly LegacyCheckpoint[]; commit?: string }
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 !== undefined;
204
- const home = opts.home === undefined ? memoryHome() : join(opts.home, "memory");
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
- const db = new Database(join(this.dir, "index.db"), { create: true });
221
- db.run(`PRAGMA busy_timeout = ${MEMORY_DB_BUSY_TIMEOUT_MS}`);
222
- db.run("PRAGMA journal_mode = WAL");
223
- if ((db.query("PRAGMA user_version").get() as { user_version: number }).user_version !== INDEX_VERSION) {
224
- for (const t of ["segments", "heads", "entries", "ops", "edges", "goals"]) db.run(`DROP TABLE IF EXISTS ${t}`);
225
- db.run(`PRAGMA user_version = ${INDEX_VERSION}`);
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 { this.db.close(); this.mutationDb.close(); }
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.project, scope: v.scope, tier: "long", kind: v.kind, peer: v.peer, ...(v.title ? { title: v.title } : {}), text: v.text,
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
- this.append({ op: "settle", target: task, actor, by: this.until() }, true);
446
- return this.promoteSettled(task, actor);
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 !!this.db.query(this.generationIndex && !options.includeLegacy
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 = ?").get(task);
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 !!this.db.query("SELECT 1 FROM entries WHERE task = ?").get(task); }
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.db.transaction(() => { for (const t of ["segments", "heads", "entries", "ops", "edges", "goals"]) this.db.run(`DELETE FROM ${t}`); this.refresh(); }).immediate();
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
- return this.db.transaction(() => this.viewRows(expired, selection, expiredOnly)).deferred();
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.db.query("SELECT id FROM generation_targets").all() as { id: string }[]).map(row => row.id)) : undefined;
604
- if (this.generationIndex && pruned) this.generationIndex.pruned = pruned;
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.db.query(selecting
767
+ const rows = this.readRows(selecting
607
768
  ? "SELECT id, at, tier, kind, task, expires, supersedes, promoted_from FROM entries"
608
- : "SELECT * FROM entries").all() as Record<string, string | null>[];
609
- const ops = this.db.query("SELECT id, op, at, target, by FROM ops ORDER BY at, id").all() as { id: string; op: string; at: string; target: string; by: string | null }[];
610
- const realms = this.generationIndex ? new Map((this.db.query("SELECT id,own,legacy FROM generation_rows").all() as { id: string; own: number; legacy: number }[]).map(row => [row.id, row])) : undefined;
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) { where.push("(project = ? OR scope = 'shared')"); bind.push(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.db.query(`SELECT * FROM entries${where.length ? ` WHERE ${where.join(" AND ")}` : ""}`).all(...bind) as Record<string, string | null>[];
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!, scope: r.scope as Scope, tier, kind: r.kind as Kind, peer: r.peer!, title: r.title ?? null, text: r.text!,
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 === f.project || v.scope === "shared") && (!f.tier || v.tier === f.tier) && (!f.kind || v.kind === f.kind)
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 === project || v.scope === "shared");
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.db.query("SELECT id FROM entries UNION SELECT promoted_from FROM entries WHERE promoted_from IS NOT NULL").all() as { id: string }[])?.id;
778
- for (let copy = at; copy; copy = (this.db.query("SELECT id FROM entries WHERE promoted_from = ?").get(copy) as { id: string } | null)?.id) { if (this.generationIndex?.pruned.has(copy)) return undefined; at = copy; }
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.db.query("SELECT src FROM edges WHERE dst = ?").all(n) as { src: string }[]) if (live.has(r.src)) ids.add(r.src);
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.db.query("SELECT * FROM goals").all() as { id: string; at: string; machine: string; project: string; scope: string; title: string; milestones: string; depends: string }[];
897
- const ops = this.db.query("SELECT op, at, machine, target, by FROM ops WHERE op IN ('goal_state', 'goal_link', 'lease', 'lease_force') ORDER BY at, id").all() as { op: string; at: string; machine: string; target: string; by: string | null }[];
898
- return rows.filter((r) => !project || r.project === project || r.scope === "shared").map((r): GoalView => {
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.db.query("SELECT id, op, at, target, by FROM ops WHERE op IN ('suggest', 'confirm', 'reject') ORDER BY at, id").all() as { id: string; op: string; at: string; target: string; by: string | null }[];
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" : ""));