@staix/agent-mem 0.12.0 → 0.14.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 +1 -1
- package/README.md +70 -44
- package/package.json +5 -2
- package/src/cli-agent-reads.ts +105 -0
- package/src/cli.ts +219 -56
- package/src/commands-table.ts +10 -9
- package/src/index.ts +1 -0
- package/src/ingest.ts +21 -4
- package/src/mcp.ts +8 -8
- package/src/memory/candidates.ts +15 -13
- package/src/memory/capacity-split.ts +4 -1
- package/src/memory/capacity.ts +48 -4
- package/src/memory/catalog.ts +4 -4
- package/src/memory/cli-attribution.ts +23 -0
- package/src/memory/commands.ts +63 -11
- package/src/memory/consolidate.ts +3 -2
- package/src/memory/decay-review.ts +4 -4
- package/src/memory/doctor.ts +36 -7
- package/src/memory/generation-index.ts +56 -11
- package/src/memory/generation.ts +73 -7
- package/src/memory/metrics.ts +20 -8
- package/src/memory/migrate.ts +31 -0
- package/src/memory/mutation.ts +24 -4
- package/src/memory/note-add.ts +4 -4
- package/src/memory/prune-publisher-lineage.ts +139 -0
- package/src/memory/prune.ts +28 -1
- package/src/memory/public-prune-activation.ts +260 -0
- package/src/memory/public-prune-advance.ts +149 -0
- package/src/memory/public-prune-input-chain.ts +73 -0
- package/src/memory/public-prune-inputs.ts +455 -0
- package/src/memory/public-prune-view.ts +139 -0
- package/src/memory/public-prune-witness.ts +168 -0
- package/src/memory/reader-lease.ts +46 -34
- package/src/memory/shard-migration.ts +42 -3
- package/src/memory/shard-sync-format.ts +40 -2
- package/src/memory/shard-sync-graph.ts +87 -0
- package/src/memory/shard-sync.ts +341 -53
- package/src/memory/shard.ts +50 -9
- package/src/memory/store.ts +51 -12
- package/src/memory/style.ts +3 -0
- package/src/memory/sync.ts +141 -5
- package/src/memory/vault.ts +31 -0
- package/src/setup.ts +59 -31
package/src/memory/shard.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { assertNoPublicPruneIntent, readPublicPruneView } from "./public-prune-view.ts";
|
|
1
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
3
|
import { existsSync, linkSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
4
|
import { join } from "node:path";
|
|
4
|
-
import { canonicalJSON, generationDescriptor, hashJSON, readProfileJSON, readSelector, validGeneration, type BoundProfile, type GenerationSeal } from "./generation.ts";
|
|
5
|
+
import { canonicalJSON, generationDescriptor, hashJSON, readProfileJSON, readSelector, validGeneration, PROFILE_LOCK_TIMEOUT_MS, type BoundProfile, type GenerationSeal } from "./generation.ts";
|
|
5
6
|
import { SHARD_SCHEMA, resolveAlias } from "./aliases.ts";
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -80,16 +81,53 @@ export function captureShardDir(base: string, key: string, machine: string): str
|
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
export const SHARDED_SCHEMA = "agent-mem.shards/v1";
|
|
83
|
-
export
|
|
84
|
+
export const SHARDED_V2_SCHEMA = "agent-mem.shards/v2";
|
|
85
|
+
export const SHARDED_V2_FIELDS = ["schema", "generation", "phase", "selectorRevision", "sourceGeneration", "sources", "machine", "legacyCommit", "deadLeasesRemoved"] as const;
|
|
86
|
+
export interface ShardedSelection { schema: typeof SHARDED_SCHEMA | typeof SHARDED_V2_SCHEMA; generation: string; phase: "active"; selectorRevision: string }
|
|
84
87
|
|
|
85
88
|
export function userShardDir(base: string): string {
|
|
86
89
|
return join(base, "user");
|
|
87
90
|
}
|
|
88
91
|
|
|
92
|
+
type ShardedJournal = Record<string, unknown> & ShardedSelection;
|
|
93
|
+
function shardJournal(memoryDir: string): ShardedJournal {
|
|
94
|
+
const value = readProfileJSON(join(memoryDir, "shards.json")) as Record<string, unknown> | undefined;
|
|
95
|
+
if (!value || (value.schema !== SHARDED_SCHEMA && value.schema !== SHARDED_V2_SCHEMA) || !validGeneration(value.generation) || value.phase !== "active" || typeof value.selectorRevision !== "string") throw new Error("no active curated shard migration");
|
|
96
|
+
if (value.schema === SHARDED_V2_SCHEMA && Object.keys(value).some(key => !(SHARDED_V2_FIELDS as readonly string[]).includes(key))) throw new Error("unsupported curated shard view");
|
|
97
|
+
return value as ShardedJournal;
|
|
98
|
+
}
|
|
99
|
+
function compatibleShardAdvance(before: ShardedJournal, after: ShardedJournal): boolean {
|
|
100
|
+
if (before.schema !== SHARDED_V2_SCHEMA || after.schema !== SHARDED_V2_SCHEMA) return false;
|
|
101
|
+
const { selectorRevision: _old, ...old } = before, { selectorRevision: _next, ...next } = after;
|
|
102
|
+
return canonicalJSON(old) === canonicalJSON(next);
|
|
103
|
+
}
|
|
104
|
+
function shardSnapshot(memoryDir: string, deadline: number) {
|
|
105
|
+
assertNoPublicPruneIntent(memoryDir, deadline);
|
|
106
|
+
let journal = shardJournal(memoryDir);
|
|
107
|
+
for (let attempt = 0; attempt < 6 && Date.now() <= deadline; attempt++) {
|
|
108
|
+
let selector: ReturnType<typeof readSelector>;
|
|
109
|
+
try { selector = readSelector(memoryDir, deadline); }
|
|
110
|
+
catch (error) {
|
|
111
|
+
// The writer's spent wait budget or unavailable proof is never restarted here.
|
|
112
|
+
if (error instanceof Error && error.message === "public prune view unavailable") throw error;
|
|
113
|
+
const current = shardJournal(memoryDir);
|
|
114
|
+
if (current.selectorRevision === journal.selectorRevision || !compatibleShardAdvance(journal, current)) throw error;
|
|
115
|
+
journal = current; continue;
|
|
116
|
+
}
|
|
117
|
+
const current = shardJournal(memoryDir);
|
|
118
|
+
if (canonicalJSON(current) !== canonicalJSON(journal)) {
|
|
119
|
+
if (!compatibleShardAdvance(journal, current)) throw new Error("curated shard selector changed");
|
|
120
|
+
journal = current; continue;
|
|
121
|
+
}
|
|
122
|
+
if ((journal.schema === SHARDED_V2_SCHEMA) !== (selector.selector?.schema === "agent-mem.profile/v2")) throw new Error("curated shard selector changed");
|
|
123
|
+
if (journal.schema === SHARDED_V2_SCHEMA && (selector.revision !== journal.selectorRevision || selector.selector?.generation !== journal.generation)) continue;
|
|
124
|
+
return { journal, selector };
|
|
125
|
+
}
|
|
126
|
+
throw new Error("curated shard selector changed");
|
|
127
|
+
}
|
|
89
128
|
export function readShardedSelection(memoryDir: string): ShardedSelection {
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
return { schema: SHARDED_SCHEMA, generation: value.generation, phase: "active", selectorRevision: value.selectorRevision };
|
|
129
|
+
const { journal } = shardSnapshot(memoryDir, Date.now() + PROFILE_LOCK_TIMEOUT_MS);
|
|
130
|
+
return { schema: journal.schema, generation: journal.generation, phase: "active", selectorRevision: journal.selectorRevision };
|
|
93
131
|
}
|
|
94
132
|
|
|
95
133
|
/** Fixed curated paths cannot redirect writes into legacy inputs or another shard.
|
|
@@ -107,14 +145,14 @@ export function assertCuratedShardPaths(memoryDir: string, opts: { project?: str
|
|
|
107
145
|
check(dir, true);
|
|
108
146
|
for (const folder of ["log", "local", "legacy", "legacy/log", "legacy/local", "legacy-main", "legacy-main/log", "legacy-main/local"]) check(join(dir, folder), true);
|
|
109
147
|
for (const name of ["project.json", "generation.json", "generation-seal.json", "migration-sources.json", "machine.json", "legacy/machine.json", "legacy-main/machine.json", "prune-translations.jsonl"]) check(join(dir, name), false);
|
|
110
|
-
for (const database of ["index.db", "search.db", "mutation-lock.db"]) for (const suffix of ["", "-wal", "-shm", "-journal"]) check(join(dir, database + suffix), false);
|
|
148
|
+
for (const database of ["index.db", "index-v2.db", "search.db", "mutation-lock.db"]) for (const suffix of ["", "-wal", "-shm", "-journal"]) check(join(dir, database + suffix), false);
|
|
111
149
|
}
|
|
112
150
|
|
|
113
151
|
/** Open one already-sealed shard without consulting the legacy home or the catalog. */
|
|
114
152
|
export function resolveShardProfile(memoryDir: string, opts: { project?: string; user?: boolean }): BoundProfile {
|
|
115
153
|
if (opts.user === true && opts.project !== undefined || opts.user !== true && !opts.project) throw new Error("choose exactly one project or the user shard");
|
|
116
154
|
assertCuratedShardPaths(memoryDir, opts);
|
|
117
|
-
const
|
|
155
|
+
const deadline = Date.now() + PROFILE_LOCK_TIMEOUT_MS, initial = shardSnapshot(memoryDir, deadline), generation = initial.journal.generation;
|
|
118
156
|
const dir = opts.user === true ? userShardDir(memoryDir) : shardDir(memoryDir, opts.project!);
|
|
119
157
|
if (opts.project !== undefined && readShardDescriptor(memoryDir, shardKeyHash(opts.project))?.key !== opts.project) throw new Error("curated shard project identity mismatch");
|
|
120
158
|
const expected = generationDescriptor(generation);
|
|
@@ -127,10 +165,13 @@ export function resolveShardProfile(memoryDir: string, opts: { project?: string;
|
|
|
127
165
|
const rootMachine = readProfileJSON(join(memoryDir, "machine.json"), 4096) as { id?: unknown } | undefined;
|
|
128
166
|
const shardMachine = readProfileJSON(join(dir, "machine.json"), 4096) as { id?: unknown } | undefined;
|
|
129
167
|
if (typeof rootMachine?.id !== "string" || !/^[a-f0-9]{16}$/.test(rootMachine.id) || rootMachine.id !== shardMachine?.id) throw new Error("curated shard machine identity mismatch");
|
|
130
|
-
const
|
|
131
|
-
if (
|
|
168
|
+
const final = shardSnapshot(memoryDir, deadline), selector = final.selector;
|
|
169
|
+
if (canonicalJSON(final.journal) !== canonicalJSON(initial.journal) && !compatibleShardAdvance(initial.journal, final.journal)
|
|
170
|
+
|| selector.revision !== final.journal.selectorRevision) throw new Error("curated shard selector changed; resume or inspect migration state");
|
|
171
|
+
if (selector.selector?.schema === "agent-mem.profile/v2") readPublicPruneView(memoryDir, selector.selector.pruneInputSetHash, generation, deadline);
|
|
132
172
|
const legacyMainHome = join(dir, "legacy-main");
|
|
133
173
|
const hasLegacyMain = existsSync(legacyMainHome) || sources.sources.some(source => !!source && typeof source === "object" && "source" in source && source.source === "legacy-main");
|
|
134
174
|
return { kind: "generation", home: memoryDir, dir, generation, selectorRevision: selector.revision,
|
|
175
|
+
...(selector.selector?.schema === "agent-mem.profile/v2" ? { foreignPruneFormat: selector.selector.foreignPruneFormat, pruneInputSetHash: selector.selector.pruneInputSetHash } : {}),
|
|
135
176
|
legacyHome: join(dir, "legacy"), ...(hasLegacyMain ? { legacyMainHome } : {}), sharded: true, ...(opts.user === true ? { shardUser: true } : { shardKey: opts.project! }) };
|
|
136
177
|
}
|
package/src/memory/store.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { readPublicPruneView } from "./public-prune-view.ts";
|
|
2
|
+
import { readShardLegacySnapshot } from "./shard-bridge.ts";
|
|
3
|
+
import { cliAttribution } from "./cli-attribution.ts";
|
|
1
4
|
import { Database } from "bun:sqlite";
|
|
2
5
|
import { createHash } from "node:crypto";
|
|
3
6
|
import { appendFileSync, chmodSync, closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
@@ -16,13 +19,13 @@ import { readWindowSql, readRef, type ReadWindow, type ReadRef, SearchIndex, cap
|
|
|
16
19
|
import { assertCuratedShardPaths, ensureShardDescriptor, readShardDescriptor, readShardedSelection, aliasedKeys, resolveAlias, resolveShardProfile, shardDir, shardKeyHash, shardMachineDir, userShardDir } from "./shard.ts";
|
|
17
20
|
import { readCaptureConfig, type CaptureRow } from "./capture.ts";
|
|
18
21
|
import { assertSharedV1Op } from "./ops-contract.ts";
|
|
19
|
-
import { generationDescriptor, generationDir, fsyncDirectory, canonicalJSON, hashJSON, readProfileJSON, resolveProfile, writeProfileJSON, readSelector, type BoundProfile, type ProfileInput, validateVaultLinkRow, vaultLinkId, type VaultLink, type VaultLinkRow } from "./generation.ts";
|
|
22
|
+
import { generationDescriptor, generationDir, fsyncDirectory, canonicalJSON, hashJSON, readProfileJSON, resolveProfile, writeProfileJSON, readSelector, type BoundProfile, type ProfileInput, validateVaultLinkRow, vaultLinkId, type VaultLink, type VaultLinkRow, rebindPublicPruneProfile } from "./generation.ts";
|
|
20
23
|
import { GenerationIndex, coalesceGenerationSource, explicitExpirySnapshot, GENERATION_TABLES } from "./generation-index.ts";
|
|
21
24
|
import { legacyEntries, readLegacySnapshot, SegmentReader, type LegacyCheckpoint } from "./bridge.ts";
|
|
22
25
|
import { readMigrationSources } from "./migrate.ts";
|
|
23
26
|
import type { PruneSourceRow, PruneStorePort } from "./prune.ts";
|
|
24
27
|
import { isDeepStrictEqual } from "node:util";
|
|
25
|
-
import { registerReaderLease } from "./reader-lease.ts";
|
|
28
|
+
import { registerReaderLease, type ReaderLease } from "./reader-lease.ts";
|
|
26
29
|
|
|
27
30
|
/**
|
|
28
31
|
* #258: the hub's own memory. Append-only JSONL logs are the truth: `log/<machine>.jsonl` holds long-term entries and
|
|
@@ -193,7 +196,7 @@ export interface StoreOptions { home?: string; memoryDir?: string; ttlMs?: numbe
|
|
|
193
196
|
lifecycle?: "enforce" | "ignore";
|
|
194
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`. */
|
|
195
198
|
reconcile?: { canonical: string; aliases: readonly string[] };
|
|
196
|
-
/**
|
|
199
|
+
/** Explicit project import may initialize configured alias shards; read-only reconciliation still never creates them. */
|
|
197
200
|
createAliases?: boolean;
|
|
198
201
|
}
|
|
199
202
|
|
|
@@ -214,7 +217,7 @@ export class MemoryStore {
|
|
|
214
217
|
/** #13: the derived FTS5 copy of the curated rows, and the memory home base for capture shard resolution (#15 T1). */
|
|
215
218
|
private readonly searchIndex: SearchIndex;
|
|
216
219
|
private readonly memoryBaseDir: string;
|
|
217
|
-
private releaseReaderLease?:
|
|
220
|
+
private releaseReaderLease?: ReaderLease;
|
|
218
221
|
private readonly userOverlay?: MemoryStore;
|
|
219
222
|
/** #27: the other keys' shards of a reconciled read (curated shards); shared-v1 reads them from its own index. */
|
|
220
223
|
private readonly aliasStores: MemoryStore[];
|
|
@@ -247,10 +250,16 @@ export class MemoryStore {
|
|
|
247
250
|
this.screen = opts.screen;
|
|
248
251
|
this.deferPromotions = opts.deferPromotions ?? false;
|
|
249
252
|
// Announce before opening derived handles or reading rows; failed admission leaks no database handles.
|
|
250
|
-
if (!bootstrap) this.releaseReaderLease = registerReaderLease(this.profile.home);
|
|
253
|
+
if (!bootstrap || bootstrap.authoring === true) this.releaseReaderLease = registerReaderLease(this.profile.home, this.profile.sharded ? {scope:"shard"} : {});
|
|
251
254
|
let openedDb: Database | undefined, openedMutation: Database | undefined, openedSearch: SearchIndex | undefined;
|
|
252
255
|
try {
|
|
253
|
-
const
|
|
256
|
+
const indexFile = join(this.dir, this.profile.foreignPruneFormat ? "index-v2.db" : "index.db");
|
|
257
|
+
if (this.profile.foreignPruneFormat) for (const suffix of ["", "-wal", "-shm", "-journal"]) {
|
|
258
|
+
const stat = lstatSync(indexFile + suffix, {throwIfNoEntry:false});
|
|
259
|
+
if (stat && (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1 || stat.uid !== process.getuid?.())) throw new Error("v2 index path unavailable");
|
|
260
|
+
}
|
|
261
|
+
const db = openedDb = new Database(indexFile, { create: true });
|
|
262
|
+
if (this.profile.foreignPruneFormat) chmodSync(indexFile, 0o600);
|
|
254
263
|
db.run(`PRAGMA busy_timeout = ${MEMORY_DB_BUSY_TIMEOUT_MS}`);
|
|
255
264
|
db.run("PRAGMA journal_mode = WAL");
|
|
256
265
|
if ((db.query("PRAGMA user_version").get() as { user_version: number }).user_version !== INDEX_VERSION) {
|
|
@@ -271,7 +280,7 @@ export class MemoryStore {
|
|
|
271
280
|
// One derived FTS copy per profile: a generation handle and a shared-v1 handle on one home never resync-fight.
|
|
272
281
|
this.searchIndex = openedSearch = new SearchIndex(this.profile.sharded ? join(this.dir, "search.db") : join(home, this.profile.kind === "generation" ? `search-${this.profile.generation}.db` : "search.db"));
|
|
273
282
|
this.memoryBaseDir = home;
|
|
274
|
-
try { chmodSync(join(this.dir, "index.db"), 0o600); } catch { /* best effort */ }
|
|
283
|
+
try { chmodSync(join(this.dir, this.profile.foreignPruneFormat ? "index-v2.db" : "index.db"), 0o600); } catch { /* best effort */ }
|
|
275
284
|
if (this.profile.kind === "generation") this.generationIndex = new GenerationIndex({ profile: this.profile, db, now: this.now,
|
|
276
285
|
apply: (line, source) => this.apply(line, source), withMutation: work => this.withMutation(work),
|
|
277
286
|
...(bootstrap ? { limits: bootstrap.limits } : {}), ...(bootstrap?.commit ? { commit: bootstrap.commit } : {}) });
|
|
@@ -589,7 +598,7 @@ export class MemoryStore {
|
|
|
589
598
|
let applied = 0;
|
|
590
599
|
if (apply) for (const group of groups) for (const head of group.heads) {
|
|
591
600
|
if (head === group.target) continue;
|
|
592
|
-
if (!this.supersede(head, group.target, "user", project)) throw new Error("memory dedupe replacement refused; rerun to inspect current relations");
|
|
601
|
+
if (!this.supersede(head, group.target, cliAttribution()?.actor ?? "user", project)) throw new Error("memory dedupe replacement refused; rerun to inspect current relations");
|
|
593
602
|
applied++;
|
|
594
603
|
}
|
|
595
604
|
return { groups, applied };
|
|
@@ -1002,6 +1011,8 @@ export class MemoryStore {
|
|
|
1002
1011
|
|
|
1003
1012
|
/** Index deferred writes before any note, goal or suggestion read. */
|
|
1004
1013
|
private ensureFresh(): void {
|
|
1014
|
+
this.releaseReaderLease?.verify();
|
|
1015
|
+
if (!this.bootstrapOnly) rebindPublicPruneProfile(this.profile);
|
|
1005
1016
|
this.userOverlay?.ensureFresh();
|
|
1006
1017
|
for (const store of this.aliasStores) store.ensureFresh();
|
|
1007
1018
|
if (this.generationIndex) { this.generationIndex.refresh(); return; }
|
|
@@ -1163,6 +1174,7 @@ export class MemoryStore {
|
|
|
1163
1174
|
};
|
|
1164
1175
|
const roots = new Set<string>(), fixed = new Set<string>();
|
|
1165
1176
|
for (const store of this.readStores()) {
|
|
1177
|
+
if (!store.bootstrapOnly) rebindPublicPruneProfile(store.profile);
|
|
1166
1178
|
roots.add(store.dir);
|
|
1167
1179
|
for (const name of ["profile.json", "shards.json", "aliases.jsonl", "lifecycle.jsonl", "machine.json"]) fixed.add(join(store.profile.home, name));
|
|
1168
1180
|
for (const name of ["generation.json", "generation-seal.json", "migration-sources.json", "machine.json"]) fixed.add(join(store.dir, name));
|
|
@@ -1194,6 +1206,7 @@ export class MemoryStore {
|
|
|
1194
1206
|
}
|
|
1195
1207
|
private prepareRecallRead(stores: readonly MemoryStore[]): void {
|
|
1196
1208
|
for (const store of stores) {
|
|
1209
|
+
if (!store.bootstrapOnly) rebindPublicPruneProfile(store.profile);
|
|
1197
1210
|
const active = readSelector(store.profile.home);
|
|
1198
1211
|
if (active.revision !== store.profile.selectorRevision) throw Error("recall profile changed");
|
|
1199
1212
|
if (store.profile.sharded) {
|
|
@@ -1256,6 +1269,7 @@ export class MemoryStore {
|
|
|
1256
1269
|
if (!Number.isFinite(time) || new Date(time).toISOString() !== value || time > present) throw Error("as-of must be a past canonical UTC date or timestamp");
|
|
1257
1270
|
}
|
|
1258
1271
|
const frontier = () => {
|
|
1272
|
+
if (!this.bootstrapOnly) for (const store of this.readStores()) rebindPublicPruneProfile(store.profile);
|
|
1259
1273
|
const active = readSelector(this.profile.home);
|
|
1260
1274
|
if (active.revision !== this.profile.selectorRevision) throw Error("recall profile changed or is not active");
|
|
1261
1275
|
if (this.profile.sharded) {
|
|
@@ -1552,6 +1566,18 @@ export function buildGenerationIndex(profile: BoundProfile, sources: readonly Le
|
|
|
1552
1566
|
finally { store.close(); }
|
|
1553
1567
|
}
|
|
1554
1568
|
|
|
1569
|
+
/** Trusted activation bootstrap; caller holds profile + realm mutation fences. */
|
|
1570
|
+
export function buildPublicPruneIndex(profile: BoundProfile): {targets:string[];sourceHash:string} {
|
|
1571
|
+
const sources = readShardLegacySnapshot(profile, {}).map(({bytes:_bytes,local:_local,...prefix}) => prefix);
|
|
1572
|
+
const store = new MemoryStore({deferPromotions:true}, {token:MIGRATION_BOOTSTRAP, profile, limits:sources});
|
|
1573
|
+
try {
|
|
1574
|
+
const before = store.derivedTables(); store.rebuild(); const tables = store.derivedTables();
|
|
1575
|
+
if (!isDeepStrictEqual(before, tables)) throw new Error("public prune view rebuild differs from incremental index");
|
|
1576
|
+
return { targets: (tables.generation_targets as {id:string}[]).map(row=>row.id).sort(),
|
|
1577
|
+
sourceHash: hashJSON([tables.generation_rows, tables.generation_prunes, tables.generation_segments, tables.generation_state]) };
|
|
1578
|
+
} finally {store.close();}
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1555
1581
|
function initializeShard(home: string, opts: { project?: string; user?: boolean }): void {
|
|
1556
1582
|
const selection = readShardedSelection(home), generation = selection.generation;
|
|
1557
1583
|
const dir = opts.user === true ? userShardDir(home) : shardDir(home, opts.project!), machine = machineId(home);
|
|
@@ -1564,7 +1590,9 @@ function initializeShard(home: string, opts: { project?: string; user?: boolean
|
|
|
1564
1590
|
}
|
|
1565
1591
|
const legacyHome = join(dir, "legacy"), sources = readMigrationSources(legacyHome);
|
|
1566
1592
|
writeProfileJSON(join(dir, "migration-sources.json"), { schema: "agent-mem.migration-sources/v1", sources });
|
|
1593
|
+
const selected = readSelector(home).selector;
|
|
1567
1594
|
const profile: BoundProfile = { kind: "generation", home, dir, generation, selectorRevision: selection.selectorRevision,
|
|
1595
|
+
...(selected?.schema === "agent-mem.profile/v2" ? {foreignPruneFormat: selected.foreignPruneFormat, pruneInputSetHash: selected.pruneInputSetHash} : {}),
|
|
1568
1596
|
legacyHome, legacyMainHome: join(dir, "legacy-main"), sharded: true, ...(opts.user === true ? { shardUser: true } : { shardKey: opts.project! }) };
|
|
1569
1597
|
buildGenerationIndex(profile, sources);
|
|
1570
1598
|
writeProfileJSON(join(dir, "generation-seal.json"), { schema: "agent-mem.generation-seal/v1", generation,
|
|
@@ -1577,17 +1605,27 @@ export function openShardStore(opts: StoreOptions & { project?: string; user?: b
|
|
|
1577
1605
|
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");
|
|
1578
1606
|
const home = opts.memoryDir ?? (opts.home === undefined ? memoryHome() : join(opts.home, "memory"));
|
|
1579
1607
|
assertCuratedShardPaths(home, opts);
|
|
1580
|
-
//
|
|
1608
|
+
// Lifecycle refusal must remain before admission creates any lease/fence file.
|
|
1581
1609
|
if (opts.user !== true && opts.lifecycle !== "ignore") assertProjectActive(home, opts.project!);
|
|
1610
|
+
readShardedSelection(home); // validate the floor before any lazy shard initialization
|
|
1611
|
+
const admission = registerReaderLease(home, {scope:"shard"});
|
|
1612
|
+
try {
|
|
1582
1613
|
const dir = opts.user === true ? userShardDir(home) : shardDir(home, opts.project!);
|
|
1583
1614
|
const missing = !existsSync(join(dir, "generation.json"));
|
|
1584
|
-
if (missing)
|
|
1615
|
+
if (missing) {
|
|
1616
|
+
const selected = readSelector(home).selector;
|
|
1617
|
+
if (selected?.schema === "agent-mem.profile/v2") {
|
|
1618
|
+
const view = readPublicPruneView(home, selected.pruneInputSetHash, selected.generation), key = opts.user ? "user" : `project:${opts.project}`;
|
|
1619
|
+
if (view.realms[key] || ["generation-seal.json", "migration-sources.json", "machine.json"].some(name => existsSync(join(dir, name)))) throw new Error("v2 generation input unavailable; reader cannot repair committed inputs");
|
|
1620
|
+
}
|
|
1621
|
+
initializeShard(home, opts);
|
|
1622
|
+
}
|
|
1585
1623
|
const profile = resolveShardProfile(home, opts);
|
|
1586
1624
|
const { reconcile, ...plain } = opts;
|
|
1587
1625
|
const userOverlay = opts.user === true ? undefined : openShardStore({ ...plain, project: undefined, user: true });
|
|
1588
1626
|
const aliasStores: MemoryStore[] = [];
|
|
1589
1627
|
try {
|
|
1590
|
-
// #27: read-only reconciliation never creates an alias shard.
|
|
1628
|
+
// #27: read-only reconciliation never creates an alias shard. An explicit project import can.
|
|
1591
1629
|
if (opts.user !== true && reconcile && reconcile.canonical === opts.project) {
|
|
1592
1630
|
for (const alias of reconcile.aliases) {
|
|
1593
1631
|
if (alias === opts.project || !opts.createAliases && !existsSync(join(shardDir(home, alias), "generation.json"))) continue;
|
|
@@ -1598,9 +1636,10 @@ export function openShardStore(opts: StoreOptions & { project?: string; user?: b
|
|
|
1598
1636
|
}
|
|
1599
1637
|
return new MemoryStore(opts, { token: MIGRATION_BOOTSTRAP, profile, authoring: true, userOverlay, aliasStores });
|
|
1600
1638
|
} catch (error) { for (const store of aliasStores) store.close(); userOverlay?.close(); throw error; }
|
|
1639
|
+
} finally { admission(); }
|
|
1601
1640
|
}
|
|
1602
1641
|
|
|
1603
|
-
/**
|
|
1642
|
+
/** Administrative migration receipt; this inspector reads raw rows without opening a store or writing any index. */
|
|
1604
1643
|
export function summarizeRawExpiry(rawLines: string[], now: number, tailBytes: number): import("./migrate.ts").MigrationCounts {
|
|
1605
1644
|
const bytes = Buffer.from(rawLines.filter(Boolean).join("\n") + (rawLines.length ? "\n" : ""));
|
|
1606
1645
|
let malformed = 0;
|
package/src/memory/style.ts
CHANGED
|
@@ -23,6 +23,9 @@ export function style(color: boolean): Style {
|
|
|
23
23
|
}
|
|
24
24
|
export const PLAIN: Style = style(false);
|
|
25
25
|
|
|
26
|
+
/** Data stays on one human-output row and cannot inject terminal or bidirectional controls. */
|
|
27
|
+
export const singleLine = (text: string): string => text.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, " ");
|
|
28
|
+
|
|
26
29
|
/** Status label, padded so the columns line up; the word is always present. */
|
|
27
30
|
export function label(s: Style, state: "ok" | "attention" | "skipped" | "unavailable" | "error"): string {
|
|
28
31
|
const word = { ok: "OK", attention: "ATTENTION", skipped: "SKIPPED", unavailable: "UNAVAILABLE", error: "ERROR" }[state].padEnd(11);
|
package/src/memory/sync.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { closeSync, existsSync, openSync, readFileSync, rmSync, writeFileSync, writeSync } from "node:fs";
|
|
2
|
-
import {
|
|
1
|
+
import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, readSync, rmSync, writeFileSync, writeSync, type BigIntStats } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
4
|
+
import { rebindPublicPruneProfile } from "./generation.ts";
|
|
3
5
|
import { memoryGitArgv, memoryGitEnv, processLiveness, processSignature } from "./platform.ts";
|
|
4
6
|
import { acquireMutation } from "./mutation.ts";
|
|
5
7
|
import type { MemoryStore } from "./store.ts";
|
|
@@ -12,7 +14,9 @@ import { outboundScreen, screenOutboundRows, screenOwnSegment, type RowScreen }
|
|
|
12
14
|
* machine's history never conflicts. Pull, then push; fail-open: a failure is a result with one reason, never a throw.
|
|
13
15
|
*/
|
|
14
16
|
export type Visibility = "public" | "private" | "unknown";
|
|
15
|
-
export interface SyncResult { ok: boolean; pushed: boolean; entries: number; warning?: string; error?: string
|
|
17
|
+
export interface SyncResult { ok: boolean; pushed: boolean; entries: number; warning?: string; error?: string;
|
|
18
|
+
pruneApplication?: {transport:"durable"|"unconfirmed";proofComplete:number;projectionActive:number|null;nativeApplied:number|null;held:number;phase:"preflight"|"prepared"|"active"|"complete"};
|
|
19
|
+
}
|
|
16
20
|
|
|
17
21
|
const GITIGNORE = "/*\n!/.gitignore\n!/log/\n/log/*\n!/log/*.jsonl\n";
|
|
18
22
|
const STEP_MS = 60_000;
|
|
@@ -62,13 +66,19 @@ async function git(dir: string, args: string[], signal?: AbortSignal, ms = STEP_
|
|
|
62
66
|
|
|
63
67
|
export { git as syncGit };
|
|
64
68
|
|
|
69
|
+
/** Shared hardening for text history queries and bounded binary object reads. */
|
|
70
|
+
function spawnGenerationGit(dir: string, args: string[], group: boolean) {
|
|
71
|
+
// The shell receives only positional arguments; its umask is child-local.
|
|
72
|
+
return Bun.spawn(["/bin/sh", "-c", 'umask 077; exec "$@"', "amem-git", ...memoryGitArgv(["-C", dir, ...args], { credentials: true })], { stdout: "pipe", stderr: "pipe", stdin: "ignore", env: memoryGitEnv(process.env, { repositoryIsolation: true }), ...(group ? { detached: true } : {}) });
|
|
73
|
+
}
|
|
74
|
+
|
|
65
75
|
/** Generation-only process isolation; shared-v1 above retains its pre-P9 spawn and environment contract. */
|
|
66
76
|
export async function generationSyncGit(dir: string, args: string[], signal?: AbortSignal, ms = STEP_MS): Promise<{ code: number; out: string; err: string }> {
|
|
67
77
|
if (signal?.aborted) return { code: -1, out: "", err: "stopped" };
|
|
68
78
|
// A fixed child-only umask keeps Git-created metadata private without changing the daemon's process umask.
|
|
69
79
|
// Args are positional shell parameters, never interpolated or evaluated as shell code.
|
|
70
80
|
const group = signal !== undefined; // see `git` above: own process group when the caller has a deadline
|
|
71
|
-
const p =
|
|
81
|
+
const p = spawnGenerationGit(dir, args, group);
|
|
72
82
|
const send = (name: "SIGTERM" | "SIGKILL") => { if (group) { try { process.kill(-p.pid, name); return; } catch { /* no group left: fall through to the pid */ } } p.kill(name); };
|
|
73
83
|
const unregister = group ? register(p.pid) : () => {};
|
|
74
84
|
// SIGTERM first, so git removes its lock files; SIGKILL only if it is still there after a grace period.
|
|
@@ -89,6 +99,53 @@ export async function generationSyncGit(dir: string, args: string[], signal?: Ab
|
|
|
89
99
|
}
|
|
90
100
|
|
|
91
101
|
|
|
102
|
+
/** Exact Git object bytes. Bounds apply only here, never to full text history audits. */
|
|
103
|
+
export async function generationSyncGitBytes(dir: string, args: string[], signal?: AbortSignal, ms = STEP_MS, maxBytes = 2 * 1024 * 1024): Promise<{ code: number; out: Uint8Array; err: string }> {
|
|
104
|
+
const empty = () => new Uint8Array(0);
|
|
105
|
+
if (signal?.aborted) return { code: -1, out: empty(), err: "stopped" };
|
|
106
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0 || !Number.isFinite(ms) || ms <= 0) return { code: -1, out: empty(), err: "invalid git output bound or deadline" };
|
|
107
|
+
// A bound or deadline can stop this call even without a caller signal, so always own its group.
|
|
108
|
+
const p = spawnGenerationGit(dir, args, true), unregister = register(p.pid);
|
|
109
|
+
const send = (name: "SIGTERM" | "SIGKILL") => {
|
|
110
|
+
try { process.kill(-p.pid, name); } catch { try { p.kill(name); } catch { /* already exited */ } }
|
|
111
|
+
};
|
|
112
|
+
let hard: ReturnType<typeof setTimeout> | undefined, stopped = false, overflow = false;
|
|
113
|
+
const kill = () => { stopped = true; send("SIGTERM"); hard ??= setTimeout(() => send("SIGKILL"), 2_000); };
|
|
114
|
+
const timer = setTimeout(kill, ms);
|
|
115
|
+
signal?.addEventListener("abort", kill, { once: true });
|
|
116
|
+
// Drain both pipes concurrently, including after overflow. Waiting on exit before draining
|
|
117
|
+
// deadlocks a full pipe; cancelling a reader can leave inherited child pipes unaccounted for.
|
|
118
|
+
const collect = async (stream: ReadableStream<Uint8Array>, limit: number, refuse: boolean): Promise<Buffer> => {
|
|
119
|
+
const reader = stream.getReader(), chunks: Buffer[] = []; let size = 0;
|
|
120
|
+
try {
|
|
121
|
+
for (;;) {
|
|
122
|
+
const part = await reader.read(); if (part.done) break;
|
|
123
|
+
const bytes = part.value, remaining = limit - size;
|
|
124
|
+
if (refuse && bytes.byteLength > remaining) { overflow = true; chunks.length = 0; kill(); }
|
|
125
|
+
if (!refuse || !overflow) {
|
|
126
|
+
const keep = Math.min(remaining, bytes.byteLength);
|
|
127
|
+
if (keep) { chunks.push(Buffer.from(bytes.subarray(0, keep))); size += keep; }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return Buffer.concat(chunks);
|
|
131
|
+
} finally { reader.releaseLock(); }
|
|
132
|
+
};
|
|
133
|
+
const operations = [p.exited, collect(p.stdout, maxBytes, true), collect(p.stderr, 65_536, false)] as const;
|
|
134
|
+
try {
|
|
135
|
+
const [code, out, err] = await Promise.all(operations);
|
|
136
|
+
return overflow ? { code: -1, out: empty(), err: "git output exceeds byte bound" }
|
|
137
|
+
: stopped ? { code: -1, out: empty(), err: "stopped" } : { code, out, err: err.toString("utf8").trim() };
|
|
138
|
+
} catch {
|
|
139
|
+
kill(); await Promise.allSettled(operations);
|
|
140
|
+
return { code: -1, out: empty(), err: "git output unavailable" };
|
|
141
|
+
} finally {
|
|
142
|
+
// Reap descendants even if Git exited first while they retained its pipe handles.
|
|
143
|
+
if (stopped) send("SIGKILL");
|
|
144
|
+
unregister(); clearTimeout(timer); if (hard) clearTimeout(hard); signal?.removeEventListener("abort", kill);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
92
149
|
/**
|
|
93
150
|
* The `owner/repo` of a remote URL whose host is EXACTLY `github.com` (case-insensitive) over `https://`, `ssh://git@github.com/` or the scp form
|
|
94
151
|
* `[git@]github.com:`; anything else is undefined, so it is `unknown` and never asked. An unanchored match let `https://notgithub.com/acme/notes`,
|
|
@@ -161,18 +218,97 @@ export interface SyncOptions {
|
|
|
161
218
|
visibility?: (url: string) => Promise<Visibility>;
|
|
162
219
|
/** An extra screen over each outbound row's text, on top of the machine's configured policy (the CLI passes the repository-narrowed policy). */
|
|
163
220
|
screen?: RowScreen;
|
|
221
|
+
/** Host-only policy binding stamp, never authorization or a substitute for screening. */
|
|
222
|
+
policyVersion?: () => string;
|
|
164
223
|
/** A stopping hub aborts: the running git step is killed and the sync ends without touching the store. */
|
|
165
224
|
signal?: AbortSignal;
|
|
166
225
|
}
|
|
167
226
|
|
|
227
|
+
/** Metadata-only sharded sync epoch. Hash complete source bytes, never notes or unopened shards. */
|
|
228
|
+
export function shardSyncBinding(store: MemoryStore): string { return captureShardSyncBinding(store, true); }
|
|
229
|
+
/** Selection bytes may change only after this sync commits; source/policy identity remains pinned across that commit. */
|
|
230
|
+
export function shardSyncSourceBinding(store: MemoryStore): string { return captureShardSyncBinding(store, false); }
|
|
231
|
+
function captureShardSyncBinding(store: MemoryStore, selection: boolean): string {
|
|
232
|
+
const unavailable = (): never => { throw new Error("sharded sync binding unavailable"); };
|
|
233
|
+
if (!store.profile.sharded) return unavailable();
|
|
234
|
+
const uid = process.getuid?.(); if (uid === undefined) return unavailable();
|
|
235
|
+
const same = (a: BigIntStats, b: BigIntStats) => a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs;
|
|
236
|
+
const owned = (stat: BigIntStats, directory: boolean, privateMode = true) => {
|
|
237
|
+
if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile() || stat.nlink !== 1n)
|
|
238
|
+
|| stat.uid !== BigInt(uid) || privateMode && (stat.mode & 0o077n) !== 0n) unavailable();
|
|
239
|
+
};
|
|
240
|
+
const home = store.profile.home, digest = createHash("sha256"), directories = new Map<string, BigIntStats>(), checks: (() => void)[] = [];
|
|
241
|
+
const emit = (value: unknown) => { digest.update(JSON.stringify(value) + "\n"); };
|
|
242
|
+
const directory = (dir: string): void => {
|
|
243
|
+
if (directories.has(dir)) return;
|
|
244
|
+
const rel = relative(home, dir); if (rel === ".." || rel.startsWith(".." + sep)) unavailable();
|
|
245
|
+
if (dir !== home) directory(dirname(dir));
|
|
246
|
+
const before = lstatSync(dir, { bigint: true }); owned(before, true); directories.set(dir, before);
|
|
247
|
+
emit(["directory", dir, String(before.dev), String(before.ino)]);
|
|
248
|
+
checks.push(() => { const now = lstatSync(dir, { bigint: true }); owned(now, true); if (!same(before, now)) unavailable(); });
|
|
249
|
+
};
|
|
250
|
+
// Policy config keeps its established file modes inside this already-private owned home. Its complete bytes/inode still bind the epoch.
|
|
251
|
+
const source = (file: string, privateMode = true): void => {
|
|
252
|
+
directory(dirname(file));
|
|
253
|
+
let before: BigIntStats;
|
|
254
|
+
try { before = lstatSync(file, { bigint: true }); }
|
|
255
|
+
catch (error) {
|
|
256
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
257
|
+
emit(["source", file, "absent"]);
|
|
258
|
+
checks.push(() => { if (lstatSync(file, { bigint: true, throwIfNoEntry: false }) !== undefined) unavailable(); }); return;
|
|
259
|
+
}
|
|
260
|
+
owned(before, false, privateMode);
|
|
261
|
+
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
262
|
+
try {
|
|
263
|
+
const opened = fstatSync(fd, { bigint: true }); owned(opened, false, privateMode); if (!same(before, opened)) unavailable();
|
|
264
|
+
const content = createHash("sha256"), buffer = Buffer.allocUnsafe(65_536); let bytes = 0n;
|
|
265
|
+
for (;;) { const n = readSync(fd, buffer, 0, buffer.length, null); if (!n) break; content.update(buffer.subarray(0, n)); bytes += BigInt(n); }
|
|
266
|
+
const after = fstatSync(fd, { bigint: true }); owned(after, false, privateMode);
|
|
267
|
+
if (bytes !== before.size || !same(before, after) || !same(before, lstatSync(file, { bigint: true }))) unavailable();
|
|
268
|
+
emit(["source", file, "present", String(before.dev), String(before.ino), String(before.size), String(before.mtimeNs), String(before.ctimeNs), content.digest("hex")]);
|
|
269
|
+
} finally { closeSync(fd); }
|
|
270
|
+
checks.push(() => { const now = lstatSync(file, { bigint: true }); owned(now, false, privateMode); if (!same(before, now)) unavailable(); });
|
|
271
|
+
};
|
|
272
|
+
try {
|
|
273
|
+
directory(home);
|
|
274
|
+
for (const name of [...(selection ? ["profile.json", "shards.json"] : []), "aliases.jsonl", "amem.config.json", "machine.json"]) source(join(home, name), name !== "amem.config.json");
|
|
275
|
+
const targets = [...store.openedSyncTargets()].sort((a, b) => a.dir.localeCompare(b.dir));
|
|
276
|
+
for (const target of targets) {
|
|
277
|
+
directory(target.dir);
|
|
278
|
+
emit(["target", target.dir, target.profile.generation ?? null, target.machine.id, target.profile.shardUser ?? false, target.profile.shardKey ?? null]);
|
|
279
|
+
for (const name of ["project.json", "generation.json", "generation-seal.json", "machine.json"]) source(join(target.dir, name));
|
|
280
|
+
}
|
|
281
|
+
for (const check of checks) check();
|
|
282
|
+
return digest.digest("hex");
|
|
283
|
+
} catch { return unavailable(); }
|
|
284
|
+
}
|
|
285
|
+
|
|
168
286
|
export async function syncMemory(store: MemoryStore, opts: SyncOptions = {}): Promise<SyncResult> {
|
|
287
|
+
if (store.profile.sharded) {
|
|
288
|
+
try {
|
|
289
|
+
// Refresh only through the existing proved monotone profile rebind before pinning this attempt.
|
|
290
|
+
for (const target of store.openedSyncTargets()) rebindPublicPruneProfile(target.profile);
|
|
291
|
+
const binding = shardSyncBinding(store), policyVersion = opts.policyVersion?.();
|
|
292
|
+
const assertBinding = () => {
|
|
293
|
+
if (opts.policyVersion?.() !== policyVersion || shardSyncBinding(store) !== binding) throw new Error("binding changed");
|
|
294
|
+
};
|
|
295
|
+
assertBinding();
|
|
296
|
+
const screen = outboundScreen(store.profile.home, opts.screen);
|
|
297
|
+
const pass: RowScreen = row => { assertBinding(); const accepted = screen(row); assertBinding(); if (typeof accepted !== "boolean") throw new Error("invalid screen result"); return accepted; };
|
|
298
|
+
for (const target of store.openedSyncTargets()) {
|
|
299
|
+
const screened = screenOutboundRows(target, pass); assertBinding();
|
|
300
|
+
if (!screened.ok) return { ok: false, pushed: false, entries: 0, error: screened.reason };
|
|
301
|
+
}
|
|
302
|
+
const transport = await import("./shard-sync.ts"); assertBinding();
|
|
303
|
+
return transport.syncShardedMemory(store, opts);
|
|
304
|
+
} catch { return { ok: false, pushed: false, entries: 0, error: "sharded sync binding or policy unavailable or changed" }; }
|
|
305
|
+
}
|
|
169
306
|
// F4: before anything is staged or pushed, this machine's own long-term notes pass the person's configured policy (every profile: single log, generation, shards).
|
|
170
307
|
const pass = outboundScreen(store.profile.home, opts.screen);
|
|
171
308
|
for (const target of store.openedSyncTargets()) {
|
|
172
309
|
const screened = target.profile.kind === "generation" ? screenOutboundRows(target, pass) : screenOwnSegment(target.dir, target.machine.id, pass);
|
|
173
310
|
if (!screened.ok) return { ok: false, pushed: false, entries: 0, error: screened.reason };
|
|
174
311
|
}
|
|
175
|
-
if (store.profile.sharded) return (await import("./shard-sync.ts")).syncShardedMemory(store, opts);
|
|
176
312
|
if (store.profile.kind === "generation") return syncGenerationMemory(store, opts);
|
|
177
313
|
const dir = store.dir;
|
|
178
314
|
const fail = (error: string): SyncResult => ({ ok: false, pushed: false, entries: 0, error });
|
package/src/memory/vault.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { validVaultPath } from "./generation.ts";
|
|
2
2
|
import { createStandalonePolicy, type MemoryPolicy } from "./policy.ts";
|
|
3
3
|
import type { MemoryStore, View, VaultEntry } from "./store.ts";
|
|
4
|
+
import { label, PLAIN, singleLine, type Style } from "./style.ts";
|
|
4
5
|
|
|
5
6
|
export interface VaultSelection { project: string; policy: MemoryPolicy; limit?: number }
|
|
6
7
|
export type { VaultEntry } from "./store.ts";
|
|
@@ -29,6 +30,36 @@ export async function vaultCandidates(store: MemoryStore, options: VaultSelectio
|
|
|
29
30
|
.sort((a, b) => b.at.localeCompare(a.at) || a.id.localeCompare(b.id)).slice(0, options.limit ?? 50);
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
/** Human-readable list of screened vault candidates; raw note text is intentionally omitted. */
|
|
34
|
+
export function vaultCandidatesText(rows: readonly View[], s: Style = PLAIN): string {
|
|
35
|
+
return [
|
|
36
|
+
`amem vault-candidates: ${rows.length ? s.attention("attention") : s.ok("complete")}`,
|
|
37
|
+
`${label(s, rows.length ? "attention" : "skipped")} candidates: ${rows.length}`,
|
|
38
|
+
...rows.map(row => {
|
|
39
|
+
const title = singleLine(row.title ?? "(untitled)").slice(0, 120);
|
|
40
|
+
return ` ${label(s, "attention")} ${singleLine(row.project)}: ${row.id} ${row.kind} ${title}`;
|
|
41
|
+
}),
|
|
42
|
+
`Next action: ${rows.length ? "open the source repository, then review a candidate with amem show <id> before recording it in your vault" : "none; no screened durable candidates are available"}`,
|
|
43
|
+
].join("\n");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Human-readable cross-project list, preserving project grouping and omitting note bodies. */
|
|
47
|
+
export function vaultCandidatesAllText(result: {
|
|
48
|
+
crossedProjects: boolean;
|
|
49
|
+
projects: readonly { project: string; candidates: readonly View[] }[];
|
|
50
|
+
}, s: Style = PLAIN): string {
|
|
51
|
+
const count = result.projects.reduce((sum, group) => sum + group.candidates.length, 0);
|
|
52
|
+
return [
|
|
53
|
+
`amem vault-candidates: ${count ? s.attention("attention") : s.ok("complete")}${result.crossedProjects ? " (across projects)" : ""}`,
|
|
54
|
+
`${label(s, count ? "attention" : "skipped")} candidates: ${count}`,
|
|
55
|
+
...result.projects.flatMap(group => group.candidates.map(row => {
|
|
56
|
+
const title = singleLine(row.title ?? "(untitled)").slice(0, 120);
|
|
57
|
+
return ` ${label(s, "attention")} ${singleLine(group.project)}: ${row.id} ${row.kind} ${title}`;
|
|
58
|
+
})),
|
|
59
|
+
`Next action: ${count ? (result.crossedProjects ? "open the candidate's source repository, then review it with amem show <id> before recording it in your vault" : "open the source repository, then review a candidate with amem show <id> before recording it in your vault") : "none; no screened durable candidates are available"}`,
|
|
60
|
+
].join("\n");
|
|
61
|
+
}
|
|
62
|
+
|
|
32
63
|
/** Person-facing hosts authenticate intent before calling; no MCP vault-link tool is provided. */
|
|
33
64
|
export async function recordVaultLink(store: MemoryStore, id: string, path: string, options: VaultSelection): Promise<string> {
|
|
34
65
|
const policy = selection(options);
|