@staix/agent-mem 0.4.0 → 0.6.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,12 +1,14 @@
1
1
  import { Database } from "bun:sqlite";
2
- import { createHash, randomBytes } from "node:crypto";
3
- import { appendFileSync, chmodSync, closeSync, constants, existsSync, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import { appendFileSync, chmodSync, closeSync, constants, existsSync, fstatSync, fsyncSync, 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
6
  import { looksSecret } from "./screen.ts";
7
7
  import { mutationDatabase } from "./mutation.ts";
8
8
  import { memoryHome } from "./home.ts";
9
- import { SearchIndex, captureSearch } from "./search-fts.ts";
9
+ import { machineId, type Machine } from "./machine.ts";
10
+ export { machineId, type Machine } from "./machine.ts";
11
+ import { readWindowSql, readRef, type ReadWindow, type ReadRef, SearchIndex, captureSearch } from "./search-fts.ts";
10
12
  import { assertCuratedShardPaths, ensureShardDescriptor, readShardDescriptor, readShardedSelection, resolveAlias, resolveShardProfile, shardDir, shardKeyHash, shardMachineDir, userShardDir } from "./shard.ts";
11
13
  import { readCaptureConfig, type CaptureRow } from "./capture.ts";
12
14
  import { assertSharedV1Op } from "./ops-contract.ts";
@@ -124,33 +126,6 @@ const oneLine = (text: string): string => text.replace(/\s+/g, " ").trim().slice
124
126
  /** A board task's key in hub memory: unique across `ahub reset --all`, which restarts the ids. */
125
127
  export const taskKey = (project: string, task: { id: number; created: number }): string => `${project}#${task.id}@${task.created}`;
126
128
 
127
- export interface Machine { id: string; label: string }
128
- /** Random per `~/.agenthub`, with a person-given label; created on first use. */
129
- export function machineId(dir: string): Machine {
130
- const file = join(dir, "machine.json");
131
- const read = (): Machine | undefined => {
132
- try {
133
- const m = JSON.parse(readFileSync(file, "utf8")) as Machine;
134
- if (/^[0-9a-f]{16}$/.test(m?.id)) return { id: m.id, label: typeof m.label === "string" ? m.label : m.id };
135
- } catch { /* none, or unreadable */ }
136
- return undefined;
137
- };
138
- const found = read();
139
- if (found) return found;
140
- // Hubs of several projects can start at once on a fresh home: each writes its own temp file and links it into place,
141
- // which fails if another got there first; then everyone reads the one identity that won.
142
- const id = randomBytes(8).toString("hex");
143
- mkdirSync(dir, { recursive: true, mode: 0o700 });
144
- const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
145
- writeFileSync(tmp, JSON.stringify({ id, label: id }) + "\n", { mode: 0o600 });
146
- try { linkSync(tmp, file); } catch { /* another process created it: its identity wins */ } finally { rmSync(tmp, { force: true }); }
147
- // A file that is there but holds no valid id is never overwritten (another process may be reading it): the store
148
- // fails to open, so the hub says hub memory is unavailable instead of filing notes under no machine.
149
- const won = read();
150
- if (!won) throw new Error(`${file} holds no valid machine id; remove it and a new one is made`);
151
- return won;
152
- }
153
-
154
129
  /** A person's name for this machine, shown beside its id. */
155
130
  export function setMachineLabel(dir: string, label: string): Machine {
156
131
  const m = machineId(dir);
@@ -194,10 +169,12 @@ export type PromotionScreen = (v: View) => "promote" | "keep" | "skip";
194
169
  * went) wait for a process that has one; a person's explicit promote goes through.
195
170
  */
196
171
  export interface StoreOptions { home?: string; memoryDir?: string; ttlMs?: number; now?: () => number; log?: (line: string) => void; screen?: PromotionScreen; deferPromotions?: boolean; profile?: ProfileInput;
172
+ /** #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`. */
173
+ reconcile?: { canonical: string; aliases: readonly string[] };
197
174
  }
198
175
 
199
176
  const MIGRATION_BOOTSTRAP = Symbol("migration index bootstrap");
200
- interface BootstrapBinding { token: typeof MIGRATION_BOOTSTRAP; profile: BoundProfile; limits?: readonly LegacyCheckpoint[]; commit?: string; authoring?: boolean; userOverlay?: MemoryStore }
177
+ interface BootstrapBinding { token: typeof MIGRATION_BOOTSTRAP; profile: BoundProfile; limits?: readonly LegacyCheckpoint[]; commit?: string; authoring?: boolean; userOverlay?: MemoryStore; aliasStores?: MemoryStore[] }
201
178
  export class MemoryStore {
202
179
  readonly dir: string;
203
180
  readonly profile: BoundProfile;
@@ -215,6 +192,9 @@ export class MemoryStore {
215
192
  private readonly memoryBaseDir: string;
216
193
  private releaseReaderLease?: () => void;
217
194
  private readonly userOverlay?: MemoryStore;
195
+ /** #27: the other keys' shards of a reconciled read (curated shards); shared-v1 reads them from its own index. */
196
+ private readonly aliasStores: MemoryStore[];
197
+ private readonly reconcile?: { canonical: string; aliases: readonly string[] };
218
198
  /** A full rebuild refills FTS in one resync instead of one derived transaction per entry. */
219
199
  private rebuildingSearch = false;
220
200
 
@@ -222,6 +202,8 @@ export class MemoryStore {
222
202
  if (bootstrap && bootstrap.token !== MIGRATION_BOOTSTRAP) throw new Error("invalid migration bootstrap");
223
203
  this.bootstrapOnly = bootstrap ? bootstrap.authoring !== true : false;
224
204
  this.userOverlay = bootstrap?.userOverlay;
205
+ this.aliasStores = bootstrap?.aliasStores ?? [];
206
+ this.reconcile = opts.reconcile && opts.reconcile.aliases.length ? { canonical: opts.reconcile.canonical, aliases: [...opts.reconcile.aliases] } : undefined;
225
207
  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");
226
208
  const home = bootstrap?.profile.home ?? opts.memoryDir ?? (opts.home === undefined ? memoryHome() : join(opts.home, "memory"));
227
209
  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");
@@ -290,9 +272,9 @@ export class MemoryStore {
290
272
  if (!project.trim() || this.profile.sharded && !this.profile.shardUser && this.profile.shardKey !== project) throw new Error("vault project does not match the bound store");
291
273
  this.withDeferredPromotions(() => this.ensureFresh());
292
274
  const scanLimit = 5000;
293
- const own = this.db.query("SELECT id FROM entries WHERE project=? LIMIT ?").all(project, scanLimit + 1) as { id: string }[];
275
+ 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 }[];
294
276
  if (own.length > scanLimit) throw new Error("vault scan budget exceeded; refine the store before retrying");
295
- const overlay = new Map(this.userOverlay?.vaultEntries(project).map(entry => [entry.view.id, entry.rowHash]));
277
+ const overlay = new Map([...(this.userOverlay?.vaultEntries(project) ?? []), ...this.aliasStores.flatMap(store => store.vaultEntries(store.profile.shardKey!))].map(entry => [entry.view.id, entry.rowHash]));
296
278
  if (new Set([...own.map(row => row.id), ...overlay.keys()]).size > scanLimit) throw new Error("vault scan budget exceeded; refine the store before retrying");
297
279
  const authored = this.generationIndex?.vaultWitnesses(own.map(row => row.id));
298
280
  return this.views(false, { project }).filter(view => view.project === project).map(view => {
@@ -305,20 +287,24 @@ export class MemoryStore {
305
287
  vaultLinks(project: string): VaultLink[] {
306
288
  this.ensureFresh();
307
289
  if (!project.trim() || this.profile.sharded && !this.profile.shardUser && this.profile.shardKey !== project) throw new Error("vault project does not match the bound store");
308
- return [...(this.generationIndex?.vaultLinks(project) ?? []), ...(this.userOverlay?.vaultLinks(project) ?? [])];
290
+ return [...this.scopeKeys(project).flatMap(key => this.generationIndex?.vaultLinks(key) ?? []), ...(this.userOverlay?.vaultLinks(project) ?? []), ...this.aliasStores.flatMap(store => store.vaultLinks(store.profile.shardKey!))];
309
291
  }
310
292
  /** Exact id/hash recheck under the existing mutation fence; never follows promoted/superseded aliases. */
311
293
  recordVaultLink(id: string, path: string, project: string, rowHash: string): string {
312
294
  if (this.bootstrapOnly || !this.generationIndex) throw new Error("vault links require an active generation handle");
313
295
  if (this.userOverlay?.vaultEntries(project).some(entry => entry.view.id === id)) return this.userOverlay.recordVaultLink(id, path, project, rowHash);
296
+ const aliased = this.aliasStores.find(store => store.vaultEntries(store.profile.shardKey!).some(entry => entry.view.id === id));
297
+ if (aliased) return aliased.recordVaultLink(id, path, aliased.profile.shardKey!, rowHash);
314
298
  return this.withMutation(() => {
315
299
  this.refresh();
316
300
  const entry = this.vaultEntries(project).find(row => row.view.id === id && row.rowHash === rowHash);
317
301
  if (!entry || entry.view.tier !== "long" || entry.view.local || entry.view.supersededBy) throw new Error("exact vault entry changed or unavailable");
302
+ // 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.
303
+ const written = this.rawProject(entry.view);
318
304
  const prior = this.vaultLinks(project).find(row => row.target === id && row.rowHash === rowHash && row.path === path);
319
305
  if (prior) return prior.id;
320
306
  const body = { schema: "agent-mem.vault-link/v1", op: "vault_link", generation: this.profile.generation!,
321
- at: new Date(this.now()).toISOString(), machine: this.machine.id, actor: "person", placement: "local", target: id, rowHash, project, path } as const;
307
+ at: new Date(this.now()).toISOString(), machine: this.machine.id, actor: "person", placement: "local", target: id, rowHash, project: written, path } as const;
322
308
  const row: VaultLinkRow = { ...body, id: vaultLinkId(body) };
323
309
  validateVaultLinkRow(row, this.profile.generation, "local");
324
310
  this.generationIndex!.assertAppendable(true, this.machine.id);
@@ -345,7 +331,7 @@ export class MemoryStore {
345
331
  const release = this.releaseReaderLease;
346
332
  this.releaseReaderLease = undefined;
347
333
  try { this.db.close(); this.mutationDb.close(); this.searchIndex.close(); }
348
- finally { try { this.userOverlay?.close(); } finally { release?.(); } }
334
+ finally { try { this.userOverlay?.close(); } finally { try { for (const store of this.aliasStores) store.close(); } finally { release?.(); } } }
349
335
  }
350
336
 
351
337
  /** A synchronous host-authorized mutation whose reads must not implicitly promote unrelated notes. */
@@ -400,10 +386,8 @@ export class MemoryStore {
400
386
 
401
387
  private appendBody(body: Omit<Entry, "id">, local: boolean): string {
402
388
  if (this.bootstrapOnly) throw new Error("migration bootstrap cannot author memory");
403
- if (this.userOverlay) {
404
- if ((body.op === "note" || body.op === "goal") && "scope" in body && body.scope === "shared") return this.userOverlay.appendBody(body, local);
405
- if ("target" in body && body.op !== "settle" && this.userOverlay.ownsTarget(body.target as string)) return this.userOverlay.appendBody(body, local);
406
- }
389
+ if (this.userOverlay && (body.op === "note" || body.op === "goal") && "scope" in body && body.scope === "shared") return this.userOverlay.appendBody(body, local);
390
+ if ("target" in body && body.op !== "settle") { const owner = this.ownerOf(body.target as string); if (owner) return owner.appendBody(body, local); }
407
391
  if (this.profile.sharded && (body.op === "note" || body.op === "goal") && "scope" in body && "project" in body) {
408
392
  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");
409
393
  }
@@ -522,7 +506,8 @@ export class MemoryStore {
522
506
  */
523
507
  promote(id: string, actor: string, project?: string, auto = false): string | undefined {
524
508
  const v = this.get(id, project);
525
- if (v && this.userOverlay?.ownsTarget(v.id)) return this.userOverlay.promote(v.id, actor, project, auto);
509
+ const owner = v ? this.ownerOf(v.id) : undefined;
510
+ if (v && owner) return owner.promote(v.id, actor, owner.profile.shardKey ?? project, auto);
526
511
  if (v && this.generationIndex && !this.generationIndex.own(v.id)) return undefined;
527
512
  if (!v || v.tier !== "short") return v?.id;
528
513
  if (v.supersededBy) return undefined;
@@ -533,7 +518,7 @@ export class MemoryStore {
533
518
  // ponytail: the check and the append are not one step across processes: a person forgetting or replacing this entry
534
519
  // at the very moment another process promotes it can leave the copy current on other machines. A per-machine lock
535
520
  // around local mutations (like sync's) would close it.
536
- 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,
521
+ 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,
537
522
  ...(v.task ? { task: v.task } : {}), about: v.about, promotedFrom: v.id, source: v.source as NoteEntry["source"] }, false,
538
523
  // Dated as its source, from its source's machine and with its fields, whoever promotes and wherever: two processes or
539
524
  // two machines promoting the same entry write one id, which the index keeps once.
@@ -548,9 +533,11 @@ export class MemoryStore {
548
533
  try {
549
534
  const entry = { op: "settle", target: task, actor, by: this.until() } as const;
550
535
  const at = new Date(this.now()).toISOString();
551
- this.append(entry, true, at);
536
+ // #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.
537
+ const owner = this.aliasStores.find(store => store.profile.shardKey !== undefined && task.startsWith(`${store.profile.shardKey}#`));
538
+ (owner ?? this).append(entry, true, at, owner ? this.machine.id : undefined);
552
539
  if (this.userOverlay) this.userOverlay.append(entry, true, at, this.machine.id);
553
- return this.promoteSettled(task, actor) + (this.userOverlay?.promoteSettled(task, actor) ?? 0);
540
+ return this.promoteSettled(task, actor) + (this.userOverlay?.promoteSettled(task, actor) ?? 0) + this.aliasStores.reduce((n, store) => n + store.promoteSettled(task, actor), 0);
554
541
  } finally { this.promoting = false; }
555
542
  }
556
543
  private promoteSettled(task: string, actor: string): number {
@@ -705,7 +692,23 @@ export class MemoryStore {
705
692
  }
706
693
 
707
694
  /** Only the bound project and user overlay participate; never scan another project's private shard. */
708
- private readStores(): MemoryStore[] { return this.userOverlay ? [this, this.userOverlay] : [this]; }
695
+ private readStores(): MemoryStore[] { return [this, ...(this.userOverlay ? [this.userOverlay] : []), ...this.aliasStores]; }
696
+ /** #27: the keys a project filter covers: the project, and with a reconciled read every key aliased to it. */
697
+ private scopeKeys(project: string): string[] { return this.reconcile && project === this.reconcile.canonical ? [project, ...this.reconcile.aliases] : [project]; }
698
+ private inScope(rowProject: string, project: string): boolean { return this.scopeKeys(project).includes(rowProject); }
699
+ /** 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). */
700
+ private rawProject(view: View): string {
701
+ if (!this.reconcile || !this.reconcile.aliases.length) return view.project;
702
+ for (const store of this.readStores()) {
703
+ const row = store.db.query("SELECT project FROM entries WHERE id = ?").get(view.id) as { project: string } | null;
704
+ if (row) return row.project;
705
+ }
706
+ return view.project;
707
+ }
708
+ /** A row of an aliased key reads under the canonical key. */
709
+ private label(project: string): string { return this.reconcile?.aliases.includes(project) ? this.reconcile.canonical : project; }
710
+ /** The store that holds the log line of `id`, when it is not this one: the user overlay or an aliased key's shard. */
711
+ private ownerOf(id: string): MemoryStore | undefined { return [...(this.userOverlay ? [this.userOverlay] : []), ...this.aliasStores].find(store => store.ownsTarget(id)); }
709
712
 
710
713
  private readRows(sql: string, ...bind: string[]): Record<string, unknown>[] {
711
714
  const rows = this.readStores().flatMap(store => store.db.query(sql).all(...bind) as Record<string, unknown>[]);
@@ -724,13 +727,14 @@ export class MemoryStore {
724
727
  * prune. Lifecycle relations still fold globally; SQL selects full rows before View/JSON materialization.
725
728
  * ponytail: lifecycle metadata is still scanned on every read; measure before adding a revision cache.
726
729
  */
727
- private views(expired = false, selection?: Filter, expiredOnly = false): View[] {
730
+ private views(expired = false, selection?: Filter & { ids?: string[] }, expiredOnly = false): View[] {
728
731
  // Metadata, ops and candidate bodies must describe one WAL snapshot, even if another process indexes a copy.
729
732
  const read = () => this.viewRows(expired, selection, expiredOnly);
730
- return this.db.transaction(() => this.userOverlay ? this.userOverlay.db.transaction(read).deferred() : read()).deferred();
733
+ const nest = (stores: MemoryStore[]): View[] => stores.length ? stores[0]!.db.transaction(() => nest(stores.slice(1))).deferred() : read();
734
+ return nest(this.readStores());
731
735
  }
732
736
 
733
- private viewRows(expired: boolean, selection: Filter | undefined, expiredOnly: boolean): View[] {
737
+ private viewRows(expired: boolean, selection: (Filter & { ids?: string[] }) | undefined, expiredOnly: boolean): View[] {
734
738
  const now = this.now(), selecting = selection !== undefined || expiredOnly;
735
739
  const pruned = this.generationIndex ? new Set((this.readRows("SELECT id FROM generation_targets") as { id: string }[]).map(row => row.id)) : undefined;
736
740
  if (this.generationIndex) this.generationIndex.pruned = new Set((this.db.query("SELECT id FROM generation_targets").all() as { id: string }[]).map(row => row.id));
@@ -825,7 +829,13 @@ export class MemoryStore {
825
829
  ids(hidden, true);
826
830
  if (expiredOnly) ids(expiredIds, false); else if (!expired) ids(expiredIds, true);
827
831
  if (selection) {
828
- if (selection.project) { where.push("(project = ? OR scope = 'shared')"); bind.push(selection.project); }
832
+ if (selection.project) {
833
+ // The exact-key form is unchanged unless a reconciled read widens it (#27).
834
+ const keys = this.scopeKeys(selection.project);
835
+ if (keys.length === 1) { where.push("(project = ? OR scope = 'shared')"); bind.push(selection.project); }
836
+ else { where.push("(project IN (SELECT value FROM json_each(?)) OR scope = 'shared')"); bind.push(JSON.stringify(keys)); }
837
+ }
838
+ if (selection.ids) ids(selection.ids, false);
829
839
  if (selection.tier) {
830
840
  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); }
831
841
  else { where.push("tier = ?"); bind.push(selection.tier); }
@@ -845,7 +855,7 @@ export class MemoryStore {
845
855
  const tier: Tier = demoted.has(id) ? "short" : r.tier as Tier;
846
856
  const expiresAt = expiry(r);
847
857
  if (!expired && expiresAt !== null && Date.parse(expiresAt) <= now) continue;
848
- 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!,
858
+ 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!,
849
859
  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" });
850
860
  }
851
861
  return out;
@@ -873,6 +883,7 @@ export class MemoryStore {
873
883
  /** Index deferred writes before any note, goal or suggestion read. */
874
884
  private ensureFresh(): void {
875
885
  this.userOverlay?.ensureFresh();
886
+ for (const store of this.aliasStores) store.ensureFresh();
876
887
  if (this.generationIndex) { this.generationIndex.refresh(); return; }
877
888
  // Another process (a CLI, another project's hub, a sync) may have written since this one last read the segments.
878
889
  if (this.stale || this.shape() !== this.seen) try { this.stale = false; this.refresh(); } catch { this.stale = true; /* still busy: the next read tries again */ }
@@ -884,7 +895,7 @@ export class MemoryStore {
884
895
  */
885
896
  private live(selection?: Filter): View[] {
886
897
  this.ensureFresh();
887
- this.prune(); this.userOverlay?.promoteHeld(); this.promoteHeld(); return this.views(false, selection);
898
+ this.prune(); this.userOverlay?.promoteHeld(); for (const store of this.aliasStores) store.promoteHeld(); this.promoteHeld(); return this.views(false, selection);
888
899
  }
889
900
  /** A write whose index update failed: the next read refreshes first. */
890
901
  private stale = false;
@@ -892,12 +903,32 @@ export class MemoryStore {
892
903
  /** Entries visible from `project` (its own, and `shared` ones of any project), newest first, filtered. */
893
904
  list(f: Filter = {}): View[] {
894
905
  const q = f.q?.toLowerCase().split(/\s+/).filter(Boolean) ?? [];
895
- 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)
906
+ 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)
896
907
  && (!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)
897
908
  && q.every((w) => `${v.title ?? ""}\n${v.text}\n${v.about.join(" ")}`.toLowerCase().includes(w)))
898
909
  .sort((a, b) => b.at.localeCompare(a.at) || a.id.localeCompare(b.id)).slice(0, f.limit ?? 200);
899
910
  }
900
911
 
912
+ /** Exact-project body pages for policy-aware callers. Relations still fold over complete metadata. */
913
+ readPage(window: ReadWindow & { query?: string; includeSuperseded?: boolean }): { rows: View[]; next?: ReadRef } {
914
+ this.ensureFresh(); this.prune();
915
+ const refs: ReadRef[] = [];
916
+ for (const store of this.readStores()) {
917
+ if (window.query !== undefined && store.searchIndex.available) {
918
+ store.syncSearchIndex(); refs.push(...store.searchIndex.candidates(window.query, this.reconcile ? { ...window, projects: this.scopeKeys(window.project) } : window));
919
+ } else {
920
+ const { columns, where, bind } = readWindowSql(this.reconcile ? { ...window, projects: this.scopeKeys(window.project) } : window, "id");
921
+ refs.push(...(store.db.query(`SELECT ${columns} FROM entries WHERE ${where.join(" AND ")} ORDER BY candidate_position, candidate_id LIMIT ?`)
922
+ .all(...bind, window.limit) as { candidate_id: string; at: string; candidate_position: number }[]).map(readRef));
923
+ }
924
+ }
925
+ 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);
926
+ if (!page.length) return { rows: [] };
927
+ const views = new Map(this.views(false, { project: window.project, ids: page.map(ref => ref.id), includeSuperseded: window.includeSuperseded })
928
+ .filter(view => view.project === window.project && (window.includeSuperseded || !view.supersededBy)).map(view => [view.id, view]));
929
+ return { rows: page.flatMap(ref => views.get(ref.id) ?? []), next: page[page.length - 1] };
930
+ }
931
+
901
932
  /**
902
933
  * #13 R5: natural-language search over the curated tier and this machine's capture tier, ranked by BM25 with
903
934
  * tier and recency weights (curated outranks capture; a newer row beats an older one at equal text). Visibility
@@ -908,7 +939,7 @@ export class MemoryStore {
908
939
  search(q: string, f: SearchFilter = {}): SearchResult {
909
940
  const limit = f.limit ?? 200;
910
941
  if (!this.searchIndex.available) return { engine: "substring", hits: this.list({ ...f, q }).map((view) => ({ source: "curated" as const, score: 0, view })) };
911
- const visible = new Map(this.live(f).filter((v) => (!f.project || v.project === f.project || v.scope === "shared")
942
+ const visible = new Map(this.live(f).filter((v) => (!f.project || this.inScope(v.project, f.project) || v.scope === "shared")
912
943
  && (!f.since || Date.parse(v.at) >= Date.parse(f.since)) && (f.includeSuperseded || !v.supersededBy)).map((v) => [v.id, v]));
913
944
  for (const store of this.readStores()) store.syncSearchIndex();
914
945
  const now = this.now();
@@ -926,7 +957,7 @@ export class MemoryStore {
926
957
  let found = 0;
927
958
  const seen = new Set<string>(); // another process's sync between pages can shift offsets and repeat a ref
928
959
  for (let offset = 0; found < limit; offset += page) {
929
- const ranked = store.searchIndex.query(q, page, f.project, offset);
960
+ const ranked = store.searchIndex.query(q, page, f.project === undefined ? undefined : this.scopeKeys(f.project), offset);
930
961
  for (const hit of ranked) {
931
962
  const view = visible.get(hit.ref);
932
963
  if (!view || seen.has(hit.ref)) continue;
@@ -964,14 +995,18 @@ export class MemoryStore {
964
995
  if (!this.searchIndex.available) return;
965
996
  const curated = (this.db.query("SELECT COUNT(*) AS n FROM entries").get() as { n: number }).n;
966
997
  if (!this.searchIndex.needsResync(curated)) return;
967
- this.searchIndex.resync(() => (this.db.query("SELECT id, at, project, tier, scope, title, text, about FROM entries").all() as { id: string; at: string; project: string; tier: string; scope: string; title: string | null; text: string; about: string }[])
968
- .map((r) => ({ 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(" ")}` })));
998
+ const db = this.db;
999
+ this.searchIndex.resync(function* () {
1000
+ 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 }>) {
1001
+ 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(" ")}` };
1002
+ }
1003
+ });
969
1004
  }
970
1005
 
971
1006
  /** One entry by id or unique id prefix; with `project`, only one that project may read (its own or shared). */
972
1007
  get(id: string, project?: string): View | undefined {
973
1008
  id = id.replace(/^mem:/, ""); // briefs and recall print `mem:<id>`
974
- const all = this.live().filter((v) => !project || v.project === project || v.scope === "shared");
1009
+ const all = this.live().filter((v) => !project || this.inScope(v.project, project) || v.scope === "shared");
975
1010
  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);
976
1011
  if (this.generationIndex?.pruned.has(id)) return undefined;
977
1012
  const found = pick(all);
@@ -1099,7 +1134,7 @@ export class MemoryStore {
1099
1134
  const now = this.now();
1100
1135
  const rows = this.readRows("SELECT * FROM goals") as { id: string; at: string; machine: string; project: string; scope: string; title: string; milestones: string; depends: string }[];
1101
1136
  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 }[];
1102
- return rows.filter((r) => !project || r.project === project || r.scope === "shared").map((r): GoalView => {
1137
+ return rows.filter((r) => !project || this.inScope(r.project, project) || r.scope === "shared").map((r): GoalView => {
1103
1138
  const mine = ops.filter((o) => o.target === r.id);
1104
1139
  const states = mine.filter((o) => o.op === "goal_state" && (GOAL_STATES as readonly string[]).includes(o.by ?? ""));
1105
1140
  const links = mine.filter((o) => o.op === "goal_link" && o.by).map((o) => ({ task: o.by!, at: o.at, machine: o.machine }));
@@ -1117,7 +1152,7 @@ export class MemoryStore {
1117
1152
  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]));
1118
1153
  const last = fresh.at(-1);
1119
1154
  // One line whatever another machine wrote: titles are rendered into agents' context.
1120
- 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),
1155
+ 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),
1121
1156
  state: (states.at(-1)?.by as GoalState | undefined) ?? "open", links,
1122
1157
  lease: active.length ? { holder: active[0]![0], until: active[0]![1], conflict: active.slice(1).map(([m]) => m), expired: false }
1123
1158
  : { holder: null, until: last?.by ?? null, conflict: [], expired: !!last, lastHolder: last?.machine ?? null } };
@@ -1213,9 +1248,20 @@ export function openShardStore(opts: StoreOptions & { project?: string; user?: b
1213
1248
  const missing = !existsSync(join(dir, "generation.json"));
1214
1249
  if (missing) initializeShard(home, opts);
1215
1250
  const profile = resolveShardProfile(home, opts);
1216
- const userOverlay = opts.user === true ? undefined : openShardStore({ ...opts, project: undefined, user: true });
1217
- try { return new MemoryStore(opts, { token: MIGRATION_BOOTSTRAP, profile, authoring: true, userOverlay }); }
1218
- catch (error) { userOverlay?.close(); throw error; }
1251
+ const { reconcile, ...plain } = opts;
1252
+ const userOverlay = opts.user === true ? undefined : openShardStore({ ...plain, project: undefined, user: true });
1253
+ const aliasStores: MemoryStore[] = [];
1254
+ try {
1255
+ // #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.
1256
+ if (opts.user !== true && reconcile && reconcile.canonical === opts.project) {
1257
+ for (const alias of reconcile.aliases) {
1258
+ if (alias === opts.project || !existsSync(join(shardDir(home, alias), "generation.json"))) continue;
1259
+ assertCuratedShardPaths(home, { project: alias });
1260
+ aliasStores.push(new MemoryStore(plain, { token: MIGRATION_BOOTSTRAP, profile: resolveShardProfile(home, { project: alias }), authoring: true }));
1261
+ }
1262
+ }
1263
+ return new MemoryStore(opts, { token: MIGRATION_BOOTSTRAP, profile, authoring: true, userOverlay, aliasStores });
1264
+ } catch (error) { for (const store of aliasStores) store.close(); userOverlay?.close(); throw error; }
1219
1265
  }
1220
1266
 
1221
1267
  /** Person-only migration receipt; this inspector reads raw rows without opening a store or writing any index. */