@staix/agent-mem 0.4.0 → 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.
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
2
2
  import { chmodSync, existsSync, readdirSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { memoryBase } from "./home.ts";
5
- import { listAliases, readShardDescriptor, SHARDED_SCHEMA } from "./shard.ts";
5
+ import { effectiveAliases, readShardDescriptor, SHARDED_SCHEMA } from "./shard.ts";
6
6
  import { readProfileJSON, validGeneration } from "./generation.ts";
7
7
  import { captureShardStats, readCaptureConfig } from "./capture.ts";
8
8
  import { openShardStore } from "./store.ts";
@@ -73,9 +73,8 @@ export function walkShards(base: string): { hash: string; key: string; local: bo
73
73
  /** What the shards and the alias log say, before anything is persisted. */
74
74
  export function scanCatalog(base: string): CatalogProject[] {
75
75
  const captured = capturedSelection(base);
76
- // The latest op per alias: an alias lists only under the key it currently points at, never under a past target.
77
- const latest = new Map<string, string>();
78
- for (const op of listAliases(base)) latest.set(op.alias, op.key);
76
+ // The latest op per alias (a removal included): an alias lists only under the key it currently points at, never under a past target or after `alias remove`.
77
+ const latest = effectiveAliases(base);
79
78
  const out: CatalogProject[] = [];
80
79
  for (const shard of walkShards(base)) {
81
80
  let rows = 0, bytes = 0, lastAt: string | null = null;
@@ -5,7 +5,7 @@ import { realPath } from "./platform.ts";
5
5
  import { homedir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import { KINDS, looksSecret, type Kind, type MemoryStore } from "./store.ts";
8
- import { listAliases, resolveAlias } from "./shard.ts";
8
+ import { aliasedKeys, resolveAlias } from "./shard.ts";
9
9
  import { WorkListStore, type WorkListFields, type WorkListPolicy } from "./work-lists.ts";
10
10
 
11
11
  /** The claude-mem store is another program's: opened read-only, never written, its locks waited on briefly. */
@@ -27,8 +27,7 @@ export async function importClaudeMemWorkState(opts: { base: string; project: st
27
27
  const limit = opts.limit ?? 10_000;
28
28
  if (!Number.isSafeInteger(limit) || limit <= 0) throw new Error("--limit takes a positive integer");
29
29
  const project = resolveAlias(opts.base, opts.project);
30
- const aliases = [...new Map(listAliases(opts.base).map(op => [op.alias, op.key]))]
31
- .filter(([, key]) => key === project).map(([alias]) => alias).sort();
30
+ const aliases = aliasedKeys(opts.base, project);
32
31
  const result: WorkStateImportResult = { read: 0, added: 0, skipped: { invalid: 0, policy: 0 }, more: false, cursor: 0 };
33
32
  if (!aliases.length) return result;
34
33
  const db = new Database(file, { readonly: true });
@@ -165,15 +165,25 @@ export class SearchIndex {
165
165
  * Best BM25 first (rank is negative: smaller wins). `project` filters inside the MATCH (its own rows plus shared
166
166
  * ones), so a busy sibling project can never starve a project's LIMIT. Visibility stays with the caller.
167
167
  */
168
- query(q: string, limit: number, project?: string, offset = 0): RankedRef[] {
168
+ query(q: string, limit: number, project?: string | readonly string[], offset = 0): RankedRef[] {
169
169
  if (!this.db) return [];
170
170
  const match = ftsTerms(q);
171
171
  if (!match) return [];
172
- const where = project ? "docs MATCH ? AND (project = ? OR scope = 'shared')" : "docs MATCH ?";
173
- const bind = project ? [match, project] : [match];
172
+ const where = project ? "docs MATCH ? AND (project IN (SELECT value FROM json_each(?)) OR scope = 'shared')" : "docs MATCH ?";
173
+ const bind = project ? [match, JSON.stringify(typeof project === "string" ? [project] : project)] : [match];
174
174
  return this.db.query(`SELECT ref, project, tier, at, bm25(docs) AS rank FROM docs WHERE ${where} ORDER BY rank, ref LIMIT ? OFFSET ?`).all(...bind, limit, offset) as RankedRef[];
175
175
  }
176
176
 
177
+ /** Matching identities in chronological keyset pages; raw-corpus BM25 never selects a private-policy window. */
178
+ candidates(q: string, window: ReadWindow): ReadRef[] {
179
+ if (!this.db) return [];
180
+ const match = ftsTerms(q); if (!match) return [];
181
+ const { columns, where, bind } = readWindowSql(window, "ref");
182
+ where.push("docs MATCH ?"); bind.push(match);
183
+ return (this.db.query(`SELECT ${columns} FROM docs WHERE ${where.join(" AND ")} ORDER BY candidate_position, candidate_id LIMIT ?`)
184
+ .all(...bind, window.limit) as { candidate_id: string; at: string; candidate_position: number }[]).map(readRef);
185
+ }
186
+
177
187
  close(): void { this.db?.close(); }
178
188
  }
179
189
 
@@ -201,3 +211,34 @@ export function captureSearch(dir: string, q: string, limit: number, notBefore?:
201
211
  } catch { return []; }
202
212
  finally { db?.close(); }
203
213
  }
214
+
215
+ /** Internal keyset pages: bounds apply to row bodies, never to the total corpus. */
216
+ export interface ReadWindow {
217
+ project: string;
218
+ /** #27: every key the page covers; `project` alone when absent. */
219
+ projects?: readonly string[];
220
+ limit: number;
221
+ since?: string;
222
+ anchor?: string;
223
+ prefix?: string;
224
+ id?: string;
225
+ after?: { position: number; id: string };
226
+ }
227
+ export interface ReadRef { id: string; at: string; position: number }
228
+ /** Fixed column names supplied by the three derived stores; all caller values remain SQL parameters. */
229
+ export function readWindowSql(window: ReadWindow, id: "id" | "ref") {
230
+ if (!Number.isSafeInteger(window.limit) || window.limit < 1 || window.limit > 256) throw new Error("invalid read page limit");
231
+ const bind: (string | number)[] = [];
232
+ const position = window.anchor ? (bind.push(window.anchor), "abs(julianday(at) - julianday(?))") : "-julianday(at)";
233
+ const columns = `CAST(${id} AS TEXT) AS candidate_id, at, ${position} AS candidate_position`;
234
+ const where = ["project IN (SELECT value FROM json_each(?))"]; bind.push(JSON.stringify(window.projects ?? [window.project]));
235
+ if (window.since) { where.push("julianday(at) >= julianday(?)"); bind.push(window.since); }
236
+ if (window.id !== undefined) { where.push(`${id} = ?`); bind.push(window.id); }
237
+ if (window.prefix !== undefined) { where.push(`substr(${id}, 1, length(?)) = ?`); bind.push(window.prefix, window.prefix); }
238
+ if (window.after) {
239
+ where.push("(candidate_position > ? OR (candidate_position = ? AND candidate_id > ?))");
240
+ bind.push(window.after.position, window.after.position, window.after.id);
241
+ }
242
+ return { columns, where, bind };
243
+ }
244
+ export const readRef = (row: { candidate_id: string; at: string; candidate_position: number }): ReadRef => ({ id: row.candidate_id, at: row.at, position: row.candidate_position });
@@ -74,6 +74,13 @@ export function readShardDescriptor(base: string, hash: string): ShardDescriptor
74
74
  }
75
75
 
76
76
  export interface AliasOp { schema: typeof SHARD_SCHEMA; op: "alias"; at: string; machine: string; alias: string; key: string; actor: string }
77
+ /**
78
+ * #27 option 2: the person's removal of one alias. An append-only op in the same local `aliases.jsonl` (never a shared-v1 segment, so no shared-v1 reader sees a new op
79
+ * kind there: an older reader skips the line and keeps resolving the alias, which is the C1 "local only" placement). The latest op for an alias wins, so after an
80
+ * `unalias` the key resolves to itself again, and a later `alias` op maps it again.
81
+ */
82
+ export interface UnaliasOp { schema: typeof SHARD_SCHEMA; op: "unalias"; at: string; machine: string; alias: string; actor: string }
83
+ export type AliasLogOp = AliasOp | UnaliasOp;
77
84
 
78
85
  export function aliasLogPath(base: string): string {
79
86
  return join(base, "aliases.jsonl");
@@ -88,8 +95,8 @@ export function appendAlias(base: string, input: { alias: string; key: string; a
88
95
  if (!alias || !key) throw new Error("an alias needs both keys");
89
96
  if (alias === key) throw new Error("an alias cannot target itself");
90
97
  if (resolveAlias(base, key) !== key) throw new Error("an alias cannot target another alias");
91
- // The other direction too, using only the latest op per alias: a target abandoned by a later retarget is free again.
92
- const currentTargets = new Set([...new Map(listAliases(base).map((op) => [op.alias, op.key])).values()]);
98
+ // The other direction too, using only the latest op per alias: a target abandoned by a later retarget or a removal is free again.
99
+ const currentTargets = new Set(effectiveAliases(base).values());
93
100
  if (currentTargets.has(alias)) throw new Error("an alias target cannot become an alias");
94
101
  const op: AliasOp = { schema: SHARD_SCHEMA, op: "alias", at: input.at ?? new Date().toISOString(), machine: input.machine, alias, key, actor: input.actor };
95
102
  mkdirSync(base, { recursive: true, mode: 0o700 });
@@ -97,24 +104,51 @@ export function appendAlias(base: string, input: { alias: string; key: string; a
97
104
  return op;
98
105
  }
99
106
 
100
- /** Every alias op in order; a malformed line is skipped, never fatal. */
101
- export function listAliases(base: string): AliasOp[] {
107
+ /** Every alias and unalias op in order (the full history); a malformed or unknown line is skipped, never fatal. */
108
+ export function listAliases(base: string): AliasLogOp[] {
102
109
  let text = "";
103
110
  try { text = readFileSync(aliasLogPath(base), "utf8"); } catch { return []; }
104
- const ops: AliasOp[] = [];
111
+ const ops: AliasLogOp[] = [];
105
112
  for (const line of text.split("\n").filter(Boolean)) {
106
113
  try {
107
- const op = JSON.parse(line) as AliasOp;
108
- if (op?.schema === SHARD_SCHEMA && op.op === "alias" && typeof op.alias === "string" && typeof op.key === "string") ops.push(op);
114
+ const op = JSON.parse(line) as AliasLogOp;
115
+ if (op?.schema !== SHARD_SCHEMA || typeof op.alias !== "string") continue;
116
+ if (op.op === "alias" && typeof op.key === "string") ops.push(op);
117
+ else if (op.op === "unalias") ops.push(op);
109
118
  } catch { /* a malformed line is skipped */ }
110
119
  }
111
120
  return ops;
112
121
  }
113
122
 
114
- /** The canonical key for a possibly aliased one: the latest op for it, one hop (aliases never chain). */
123
+ /** The aliases in force: for each alias key the latest op decides, and an `unalias` removes it. Map of alias key to canonical key. */
124
+ export function effectiveAliases(base: string): Map<string, string> {
125
+ const latest = new Map<string, string>();
126
+ for (const op of listAliases(base)) { if (op.op === "alias") latest.set(op.alias, op.key); else latest.delete(op.alias); }
127
+ return latest;
128
+ }
129
+
130
+ /** The canonical key for a possibly aliased one: the latest op for it, one hop (aliases never chain). An unaliased key is its own canonical key. */
115
131
  export function resolveAlias(base: string, key: string): string {
116
- const ops = listAliases(base).filter((op) => op.alias === key);
117
- return ops.length ? ops[ops.length - 1]!.key : key;
132
+ return effectiveAliases(base).get(key) ?? key;
133
+ }
134
+
135
+ /** Every key currently aliased to `canonical`, sorted (the keys whose curated rows the CLI and MCP project filter now covers, #27 option 1). */
136
+ export function aliasedKeys(base: string, canonical: string): string[] {
137
+ return [...effectiveAliases(base)].filter(([, key]) => key === canonical).map(([alias]) => alias).sort();
138
+ }
139
+
140
+ /**
141
+ * One person-reviewed removal, appended to `aliases.jsonl`. Nothing is rewritten: the alias line stays in the history and the later op wins. Refused when the key is
142
+ * not aliased now, so a typo never records a removal that means nothing.
143
+ */
144
+ export function appendUnalias(base: string, input: { alias: string; actor: string; machine: string; at?: string }): UnaliasOp {
145
+ const alias = input.alias.trim();
146
+ if (!alias) throw new Error("an alias removal needs the alias key");
147
+ if (!effectiveAliases(base).has(alias)) throw new Error(`no alias to remove: ${alias} is not aliased now`);
148
+ const op: UnaliasOp = { schema: SHARD_SCHEMA, op: "unalias", at: input.at ?? new Date().toISOString(), machine: input.machine, alias, actor: input.actor };
149
+ mkdirSync(base, { recursive: true, mode: 0o700 });
150
+ appendFileSync(aliasLogPath(base), JSON.stringify(op) + "\n", { mode: 0o600 });
151
+ return op;
118
152
  }
119
153
 
120
154
  /** `memoryBase(home)` plus the shard of `key` after alias resolution. */
@@ -6,7 +6,7 @@ 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 { readWindowSql, readRef, type ReadWindow, type ReadRef, SearchIndex, captureSearch } from "./search-fts.ts";
10
10
  import { assertCuratedShardPaths, ensureShardDescriptor, readShardDescriptor, readShardedSelection, resolveAlias, resolveShardProfile, shardDir, shardKeyHash, shardMachineDir, userShardDir } from "./shard.ts";
11
11
  import { readCaptureConfig, type CaptureRow } from "./capture.ts";
12
12
  import { assertSharedV1Op } from "./ops-contract.ts";
@@ -194,10 +194,12 @@ export type PromotionScreen = (v: View) => "promote" | "keep" | "skip";
194
194
  * went) wait for a process that has one; a person's explicit promote goes through.
195
195
  */
196
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[] };
197
199
  }
198
200
 
199
201
  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 }
202
+ interface BootstrapBinding { token: typeof MIGRATION_BOOTSTRAP; profile: BoundProfile; limits?: readonly LegacyCheckpoint[]; commit?: string; authoring?: boolean; userOverlay?: MemoryStore; aliasStores?: MemoryStore[] }
201
203
  export class MemoryStore {
202
204
  readonly dir: string;
203
205
  readonly profile: BoundProfile;
@@ -215,6 +217,9 @@ export class MemoryStore {
215
217
  private readonly memoryBaseDir: string;
216
218
  private releaseReaderLease?: () => void;
217
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[] };
218
223
  /** A full rebuild refills FTS in one resync instead of one derived transaction per entry. */
219
224
  private rebuildingSearch = false;
220
225
 
@@ -222,6 +227,8 @@ export class MemoryStore {
222
227
  if (bootstrap && bootstrap.token !== MIGRATION_BOOTSTRAP) throw new Error("invalid migration bootstrap");
223
228
  this.bootstrapOnly = bootstrap ? bootstrap.authoring !== true : false;
224
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;
225
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");
226
233
  const home = bootstrap?.profile.home ?? opts.memoryDir ?? (opts.home === undefined ? memoryHome() : join(opts.home, "memory"));
227
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");
@@ -290,9 +297,9 @@ export class MemoryStore {
290
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");
291
298
  this.withDeferredPromotions(() => this.ensureFresh());
292
299
  const scanLimit = 5000;
293
- const own = this.db.query("SELECT id FROM entries WHERE project=? LIMIT ?").all(project, scanLimit + 1) as { id: string }[];
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 }[];
294
301
  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]));
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]));
296
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");
297
304
  const authored = this.generationIndex?.vaultWitnesses(own.map(row => row.id));
298
305
  return this.views(false, { project }).filter(view => view.project === project).map(view => {
@@ -305,20 +312,24 @@ export class MemoryStore {
305
312
  vaultLinks(project: string): VaultLink[] {
306
313
  this.ensureFresh();
307
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");
308
- return [...(this.generationIndex?.vaultLinks(project) ?? []), ...(this.userOverlay?.vaultLinks(project) ?? [])];
315
+ return [...this.scopeKeys(project).flatMap(key => this.generationIndex?.vaultLinks(key) ?? []), ...(this.userOverlay?.vaultLinks(project) ?? []), ...this.aliasStores.flatMap(store => store.vaultLinks(store.profile.shardKey!))];
309
316
  }
310
317
  /** Exact id/hash recheck under the existing mutation fence; never follows promoted/superseded aliases. */
311
318
  recordVaultLink(id: string, path: string, project: string, rowHash: string): string {
312
319
  if (this.bootstrapOnly || !this.generationIndex) throw new Error("vault links require an active generation handle");
313
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);
314
323
  return this.withMutation(() => {
315
324
  this.refresh();
316
325
  const entry = this.vaultEntries(project).find(row => row.view.id === id && row.rowHash === rowHash);
317
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);
318
329
  const prior = this.vaultLinks(project).find(row => row.target === id && row.rowHash === rowHash && row.path === path);
319
330
  if (prior) return prior.id;
320
331
  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;
332
+ at: new Date(this.now()).toISOString(), machine: this.machine.id, actor: "person", placement: "local", target: id, rowHash, project: written, path } as const;
322
333
  const row: VaultLinkRow = { ...body, id: vaultLinkId(body) };
323
334
  validateVaultLinkRow(row, this.profile.generation, "local");
324
335
  this.generationIndex!.assertAppendable(true, this.machine.id);
@@ -345,7 +356,7 @@ export class MemoryStore {
345
356
  const release = this.releaseReaderLease;
346
357
  this.releaseReaderLease = undefined;
347
358
  try { this.db.close(); this.mutationDb.close(); this.searchIndex.close(); }
348
- finally { try { this.userOverlay?.close(); } finally { release?.(); } }
359
+ finally { try { this.userOverlay?.close(); } finally { try { for (const store of this.aliasStores) store.close(); } finally { release?.(); } } }
349
360
  }
350
361
 
351
362
  /** A synchronous host-authorized mutation whose reads must not implicitly promote unrelated notes. */
@@ -400,10 +411,8 @@ export class MemoryStore {
400
411
 
401
412
  private appendBody(body: Omit<Entry, "id">, local: boolean): string {
402
413
  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
- }
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); }
407
416
  if (this.profile.sharded && (body.op === "note" || body.op === "goal") && "scope" in body && "project" in body) {
408
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");
409
418
  }
@@ -522,7 +531,8 @@ export class MemoryStore {
522
531
  */
523
532
  promote(id: string, actor: string, project?: string, auto = false): string | undefined {
524
533
  const v = this.get(id, project);
525
- if (v && this.userOverlay?.ownsTarget(v.id)) return this.userOverlay.promote(v.id, actor, project, auto);
534
+ const owner = v ? this.ownerOf(v.id) : undefined;
535
+ if (v && owner) return owner.promote(v.id, actor, owner.profile.shardKey ?? project, auto);
526
536
  if (v && this.generationIndex && !this.generationIndex.own(v.id)) return undefined;
527
537
  if (!v || v.tier !== "short") return v?.id;
528
538
  if (v.supersededBy) return undefined;
@@ -533,7 +543,7 @@ export class MemoryStore {
533
543
  // ponytail: the check and the append are not one step across processes: a person forgetting or replacing this entry
534
544
  // at the very moment another process promotes it can leave the copy current on other machines. A per-machine lock
535
545
  // 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,
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,
537
547
  ...(v.task ? { task: v.task } : {}), about: v.about, promotedFrom: v.id, source: v.source as NoteEntry["source"] }, false,
538
548
  // Dated as its source, from its source's machine and with its fields, whoever promotes and wherever: two processes or
539
549
  // two machines promoting the same entry write one id, which the index keeps once.
@@ -548,9 +558,11 @@ export class MemoryStore {
548
558
  try {
549
559
  const entry = { op: "settle", target: task, actor, by: this.until() } as const;
550
560
  const at = new Date(this.now()).toISOString();
551
- this.append(entry, true, at);
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);
552
564
  if (this.userOverlay) this.userOverlay.append(entry, true, at, this.machine.id);
553
- return this.promoteSettled(task, actor) + (this.userOverlay?.promoteSettled(task, actor) ?? 0);
565
+ return this.promoteSettled(task, actor) + (this.userOverlay?.promoteSettled(task, actor) ?? 0) + this.aliasStores.reduce((n, store) => n + store.promoteSettled(task, actor), 0);
554
566
  } finally { this.promoting = false; }
555
567
  }
556
568
  private promoteSettled(task: string, actor: string): number {
@@ -705,7 +717,23 @@ export class MemoryStore {
705
717
  }
706
718
 
707
719
  /** 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]; }
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)); }
709
737
 
710
738
  private readRows(sql: string, ...bind: string[]): Record<string, unknown>[] {
711
739
  const rows = this.readStores().flatMap(store => store.db.query(sql).all(...bind) as Record<string, unknown>[]);
@@ -724,13 +752,14 @@ export class MemoryStore {
724
752
  * prune. Lifecycle relations still fold globally; SQL selects full rows before View/JSON materialization.
725
753
  * ponytail: lifecycle metadata is still scanned on every read; measure before adding a revision cache.
726
754
  */
727
- private views(expired = false, selection?: Filter, expiredOnly = false): View[] {
755
+ private views(expired = false, selection?: Filter & { ids?: string[] }, expiredOnly = false): View[] {
728
756
  // Metadata, ops and candidate bodies must describe one WAL snapshot, even if another process indexes a copy.
729
757
  const read = () => this.viewRows(expired, selection, expiredOnly);
730
- return this.db.transaction(() => this.userOverlay ? this.userOverlay.db.transaction(read).deferred() : read()).deferred();
758
+ const nest = (stores: MemoryStore[]): View[] => stores.length ? stores[0]!.db.transaction(() => nest(stores.slice(1))).deferred() : read();
759
+ return nest(this.readStores());
731
760
  }
732
761
 
733
- private viewRows(expired: boolean, selection: Filter | undefined, expiredOnly: boolean): View[] {
762
+ private viewRows(expired: boolean, selection: (Filter & { ids?: string[] }) | undefined, expiredOnly: boolean): View[] {
734
763
  const now = this.now(), selecting = selection !== undefined || expiredOnly;
735
764
  const pruned = this.generationIndex ? new Set((this.readRows("SELECT id FROM generation_targets") as { id: string }[]).map(row => row.id)) : undefined;
736
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));
@@ -825,7 +854,13 @@ export class MemoryStore {
825
854
  ids(hidden, true);
826
855
  if (expiredOnly) ids(expiredIds, false); else if (!expired) ids(expiredIds, true);
827
856
  if (selection) {
828
- 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);
829
864
  if (selection.tier) {
830
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); }
831
866
  else { where.push("tier = ?"); bind.push(selection.tier); }
@@ -845,7 +880,7 @@ export class MemoryStore {
845
880
  const tier: Tier = demoted.has(id) ? "short" : r.tier as Tier;
846
881
  const expiresAt = expiry(r);
847
882
  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!,
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!,
849
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" });
850
885
  }
851
886
  return out;
@@ -873,6 +908,7 @@ export class MemoryStore {
873
908
  /** Index deferred writes before any note, goal or suggestion read. */
874
909
  private ensureFresh(): void {
875
910
  this.userOverlay?.ensureFresh();
911
+ for (const store of this.aliasStores) store.ensureFresh();
876
912
  if (this.generationIndex) { this.generationIndex.refresh(); return; }
877
913
  // Another process (a CLI, another project's hub, a sync) may have written since this one last read the segments.
878
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 */ }
@@ -884,7 +920,7 @@ export class MemoryStore {
884
920
  */
885
921
  private live(selection?: Filter): View[] {
886
922
  this.ensureFresh();
887
- this.prune(); this.userOverlay?.promoteHeld(); 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);
888
924
  }
889
925
  /** A write whose index update failed: the next read refreshes first. */
890
926
  private stale = false;
@@ -892,12 +928,32 @@ export class MemoryStore {
892
928
  /** Entries visible from `project` (its own, and `shared` ones of any project), newest first, filtered. */
893
929
  list(f: Filter = {}): View[] {
894
930
  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)
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)
896
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)
897
933
  && q.every((w) => `${v.title ?? ""}\n${v.text}\n${v.about.join(" ")}`.toLowerCase().includes(w)))
898
934
  .sort((a, b) => b.at.localeCompare(a.at) || a.id.localeCompare(b.id)).slice(0, f.limit ?? 200);
899
935
  }
900
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
+
901
957
  /**
902
958
  * #13 R5: natural-language search over the curated tier and this machine's capture tier, ranked by BM25 with
903
959
  * tier and recency weights (curated outranks capture; a newer row beats an older one at equal text). Visibility
@@ -908,7 +964,7 @@ export class MemoryStore {
908
964
  search(q: string, f: SearchFilter = {}): SearchResult {
909
965
  const limit = f.limit ?? 200;
910
966
  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")
967
+ const visible = new Map(this.live(f).filter((v) => (!f.project || this.inScope(v.project, f.project) || v.scope === "shared")
912
968
  && (!f.since || Date.parse(v.at) >= Date.parse(f.since)) && (f.includeSuperseded || !v.supersededBy)).map((v) => [v.id, v]));
913
969
  for (const store of this.readStores()) store.syncSearchIndex();
914
970
  const now = this.now();
@@ -926,7 +982,7 @@ export class MemoryStore {
926
982
  let found = 0;
927
983
  const seen = new Set<string>(); // another process's sync between pages can shift offsets and repeat a ref
928
984
  for (let offset = 0; found < limit; offset += page) {
929
- const ranked = store.searchIndex.query(q, page, f.project, offset);
985
+ const ranked = store.searchIndex.query(q, page, f.project === undefined ? undefined : this.scopeKeys(f.project), offset);
930
986
  for (const hit of ranked) {
931
987
  const view = visible.get(hit.ref);
932
988
  if (!view || seen.has(hit.ref)) continue;
@@ -964,14 +1020,18 @@ export class MemoryStore {
964
1020
  if (!this.searchIndex.available) return;
965
1021
  const curated = (this.db.query("SELECT COUNT(*) AS n FROM entries").get() as { n: number }).n;
966
1022
  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(" ")}` })));
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
+ });
969
1029
  }
970
1030
 
971
1031
  /** One entry by id or unique id prefix; with `project`, only one that project may read (its own or shared). */
972
1032
  get(id: string, project?: string): View | undefined {
973
1033
  id = id.replace(/^mem:/, ""); // briefs and recall print `mem:<id>`
974
- 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");
975
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);
976
1036
  if (this.generationIndex?.pruned.has(id)) return undefined;
977
1037
  const found = pick(all);
@@ -1099,7 +1159,7 @@ export class MemoryStore {
1099
1159
  const now = this.now();
1100
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 }[];
1101
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 }[];
1102
- return rows.filter((r) => !project || r.project === project || r.scope === "shared").map((r): GoalView => {
1162
+ return rows.filter((r) => !project || this.inScope(r.project, project) || r.scope === "shared").map((r): GoalView => {
1103
1163
  const mine = ops.filter((o) => o.target === r.id);
1104
1164
  const states = mine.filter((o) => o.op === "goal_state" && (GOAL_STATES as readonly string[]).includes(o.by ?? ""));
1105
1165
  const links = mine.filter((o) => o.op === "goal_link" && o.by).map((o) => ({ task: o.by!, at: o.at, machine: o.machine }));
@@ -1117,7 +1177,7 @@ export class MemoryStore {
1117
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]));
1118
1178
  const last = fresh.at(-1);
1119
1179
  // 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),
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),
1121
1181
  state: (states.at(-1)?.by as GoalState | undefined) ?? "open", links,
1122
1182
  lease: active.length ? { holder: active[0]![0], until: active[0]![1], conflict: active.slice(1).map(([m]) => m), expired: false }
1123
1183
  : { holder: null, until: last?.by ?? null, conflict: [], expired: !!last, lastHolder: last?.machine ?? null } };
@@ -1213,9 +1273,20 @@ export function openShardStore(opts: StoreOptions & { project?: string; user?: b
1213
1273
  const missing = !existsSync(join(dir, "generation.json"));
1214
1274
  if (missing) initializeShard(home, opts);
1215
1275
  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; }
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; }
1219
1290
  }
1220
1291
 
1221
1292
  /** Person-only migration receipt; this inspector reads raw rows without opening a store or writing any index. */