@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.
- package/README.md +6 -4
- package/package.json +1 -1
- package/src/cli.ts +17 -7
- package/src/commands-table.ts +1 -0
- package/src/mcp-memory.ts +71 -28
- package/src/memory/candidates.ts +11 -7
- package/src/memory/capture.ts +123 -26
- package/src/memory/catalog.ts +3 -4
- package/src/memory/import.ts +2 -3
- package/src/memory/search-fts.ts +44 -3
- package/src/memory/shard.ts +44 -10
- package/src/memory/store.ts +104 -33
- package/src/memory/work-lists.ts +138 -50
package/src/memory/catalog.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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;
|
package/src/memory/import.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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 });
|
package/src/memory/search-fts.ts
CHANGED
|
@@ -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
|
|
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 });
|
package/src/memory/shard.ts
CHANGED
|
@@ -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(
|
|
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):
|
|
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:
|
|
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
|
|
108
|
-
if (op?.schema
|
|
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
|
|
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
|
-
|
|
117
|
-
|
|
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. */
|
package/src/memory/store.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) {
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
968
|
-
|
|
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
|
|
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
|
|
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
|
|
1217
|
-
|
|
1218
|
-
|
|
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. */
|