@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.
- package/.claude-plugin/plugin.json +5 -0
- package/README.md +57 -7
- package/bin/amem.js +26 -3
- package/hooks/hooks.json +81 -0
- package/package.json +3 -1
- package/src/cli.ts +33 -7
- package/src/commands-table.ts +4 -0
- package/src/hook-contract.ts +3 -0
- package/src/hook-git.ts +42 -0
- package/src/hook-native.ts +92 -0
- package/src/hook-project.ts +206 -0
- package/src/hook-resources.ts +15 -0
- package/src/hook-spool.ts +544 -0
- package/src/hooks.ts +249 -0
- package/src/index.ts +4 -0
- package/src/ingest.ts +350 -0
- package/src/mcp-memory.ts +71 -28
- package/src/memory/aliases.ts +83 -0
- package/src/memory/candidates.ts +11 -7
- package/src/memory/capacity.ts +23 -48
- package/src/memory/capture-input.ts +31 -0
- package/src/memory/capture.ts +245 -59
- package/src/memory/catalog.ts +3 -4
- package/src/memory/import.ts +2 -3
- package/src/memory/machine.ts +31 -0
- package/src/memory/metrics.ts +63 -0
- package/src/memory/mutation.ts +6 -4
- package/src/memory/platform.ts +9 -4
- package/src/memory/project.ts +129 -20
- package/src/memory/reader-lease.ts +22 -10
- package/src/memory/search-fts.ts +44 -3
- package/src/memory/shard.ts +3 -46
- package/src/memory/store.ts +108 -62
- package/src/memory/work-lists.ts +227 -68
- package/src/session-brief-worker.ts +81 -0
- package/src/session-brief.ts +202 -0
package/src/memory/store.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
-
import { createHash
|
|
3
|
-
import { appendFileSync, chmodSync, closeSync, constants, existsSync, fstatSync, fsyncSync,
|
|
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 {
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) {
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
968
|
-
|
|
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
|
|
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
|
|
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
|
|
1217
|
-
|
|
1218
|
-
|
|
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. */
|