@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/metrics.ts
CHANGED
|
@@ -38,26 +38,38 @@ export function readMetrics(base: string, limit = METRICS_KEEP): MetricSample[]
|
|
|
38
38
|
|
|
39
39
|
export type CaptureDropReason = "spool_full" | "cwd_removed" | "undrainable_expired" | "scope_unknown" | "project_archived" | "project_forgotten";
|
|
40
40
|
const DROP_REASONS: readonly CaptureDropReason[] = ["spool_full", "cwd_removed", "undrainable_expired", "scope_unknown", "project_archived", "project_forgotten"];
|
|
41
|
-
/** Numeric notice only, bounded to the rolling metric tail; never an all-time counter. */
|
|
41
|
+
/** Numeric notice only, bounded to the rolling metric tail; never an all-time counter. Fails open to no counts: hooks never deny over metrics. */
|
|
42
42
|
export function captureDropSummary(base: string): Partial<Record<CaptureDropReason, number>> {
|
|
43
|
+
const read = readCaptureDrops(base);
|
|
44
|
+
return read.readable ? read.counts : {};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The same bounded read, but it says when an existing metrics file could not be read (a person-facing report must not show that as zero, #132).
|
|
49
|
+
* An absent file is readable with no counts. `reason` is a fixed phrase, never file content.
|
|
50
|
+
*/
|
|
51
|
+
export function readCaptureDrops(base: string): { readable: true; counts: Partial<Record<CaptureDropReason, number>>; lines: number; malformedLines: number } | { readable: false; reason: string } {
|
|
43
52
|
const counts: Partial<Record<CaptureDropReason, number>> = {};
|
|
44
|
-
let fd: number | undefined;
|
|
53
|
+
let fd: number | undefined, lines = 0, malformedLines = 0;
|
|
45
54
|
try {
|
|
46
|
-
fd = openSync(metricsPath(base), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
47
|
-
|
|
55
|
+
try { fd = openSync(metricsPath(base), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
|
56
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { readable: true, counts, lines: 0, malformedLines: 0 }; return { readable: false, reason: "metrics.jsonl exists but cannot be opened" }; }
|
|
57
|
+
const stat = fstatSync(fd); if (!stat.isFile()) return { readable: false, reason: "metrics.jsonl is not a plain file" };
|
|
48
58
|
const start = Math.max(0, stat.size - METRICS_MAX_BYTES), bytes = Buffer.alloc(stat.size - start);
|
|
49
59
|
const length = readSync(fd, bytes, 0, bytes.length, start);
|
|
50
60
|
let text = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, length));
|
|
51
61
|
if (start) text = text.slice(text.indexOf("\n") + 1);
|
|
52
|
-
|
|
62
|
+
const tail = text.split("\n").filter(line => line.trim() !== "").slice(-METRICS_KEEP);
|
|
63
|
+
lines = tail.length;
|
|
64
|
+
for (const line of tail) {
|
|
53
65
|
try {
|
|
54
66
|
const sample = JSON.parse(line) as MetricSample;
|
|
55
67
|
if (sample.kind !== "hook" || !DROP_REASONS.includes(sample.captureDropReason!) || !Number.isSafeInteger(sample.captureDrops) || sample.captureDrops! <= 0) continue;
|
|
56
68
|
const reason = sample.captureDropReason!;
|
|
57
69
|
counts[reason] = (counts[reason] ?? 0) + sample.captureDrops!;
|
|
58
|
-
} catch { /* A malformed metric is never a notice. */ }
|
|
70
|
+
} catch { malformedLines++; /* A malformed metric is never a notice; doctor reports how many were skipped. */ }
|
|
59
71
|
}
|
|
60
|
-
} catch {
|
|
72
|
+
} catch { return { readable: false, reason: "metrics.jsonl cannot be read or is not UTF-8" }; }
|
|
61
73
|
finally { if (fd !== undefined) closeSync(fd); }
|
|
62
|
-
return counts;
|
|
74
|
+
return { readable: true, counts, lines, malformedLines };
|
|
63
75
|
}
|
package/src/memory/migrate.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { legacyEntries, legacyHead, readLegacySnapshot, type LegacyCheckpoint }
|
|
|
5
5
|
import { acquireMutation } from "./mutation.ts";
|
|
6
6
|
import { canonicalPath, realPath } from "./platform.ts";
|
|
7
7
|
import { canonicalJSON, fsyncDirectory, generationDescriptor, generationDir, hashJSON, readProfileJSON, readSelector, resolveProfile, validGeneration, withProfileLock, writeProfileJSON, type BoundProfile } from "./generation.ts";
|
|
8
|
+
import { label, PLAIN, singleLine, type Style } from "./style.ts";
|
|
8
9
|
|
|
9
10
|
export interface SourcePrefix extends LegacyCheckpoint { tailBytes: number }
|
|
10
11
|
interface MigrationJournal {
|
|
@@ -104,6 +105,7 @@ function refuseShardSelection(memoryDir: string): void {
|
|
|
104
105
|
}
|
|
105
106
|
|
|
106
107
|
export async function migrate(options: MigrateOptions): Promise<MigrationResult> {
|
|
108
|
+
if (readSelector(options.memoryDir).selector?.schema === "agent-mem.profile/v2") throw new Error("public prune view is forward-only; deactivation is unavailable");
|
|
107
109
|
if (options.authorizedPerson !== true || options.tty !== true) throw new Error("migration requires an authorized person in a terminal");
|
|
108
110
|
if (!["dry-run", "apply", "resume", "deactivate"].includes(options.mode) || options.generation !== undefined && !validGeneration(options.generation)) throw new Error("invalid migration request");
|
|
109
111
|
const memoryDir = existsSync(options.memoryDir) ? realPath(options.memoryDir) : canonicalPath(options.memoryDir);
|
|
@@ -222,3 +224,32 @@ export async function migrate(options: MigrateOptions): Promise<MigrationResult>
|
|
|
222
224
|
} finally { releaseGeneration(); }
|
|
223
225
|
});
|
|
224
226
|
}
|
|
227
|
+
|
|
228
|
+
/** Render a migration result for a person; JSON output remains owned by the CLI. */
|
|
229
|
+
export function migrationText(result: MigrationResult, s: Style = PLAIN): string {
|
|
230
|
+
const active = result.profile.kind === "generation";
|
|
231
|
+
const needsResume = result.mode === "resume" && !active;
|
|
232
|
+
const state = needsResume ? "attention" : result.mode === "dry-run" ? "preview" : "complete";
|
|
233
|
+
const groups = (value: Record<string, number>) => Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, count]) => `${singleLine(key)}=${count}`).join(", ");
|
|
234
|
+
const countRows = result.counts ? [
|
|
235
|
+
["valid", result.counts.validRows], ["skipped", result.counts.skippedRows], ["tailBytes", result.counts.tailBytes],
|
|
236
|
+
["expired", result.counts.expiredRows], ["eligible", result.counts.eligibleRows],
|
|
237
|
+
].map(([key, value]) => `${label(s, "ok")} ${key} ${value}`) : [`${label(s, "skipped")} counts not returned for this mode`];
|
|
238
|
+
const operationCounts = result.counts ? groups(result.counts.byOp) : "", tierCounts = result.counts ? groups(result.counts.byTier) : "";
|
|
239
|
+
if (operationCounts) countRows.push(`${label(s, "ok")} operations ${operationCounts}`);
|
|
240
|
+
if (tierCounts) countRows.push(`${label(s, "ok")} tiers ${tierCounts}`);
|
|
241
|
+
let action = "none";
|
|
242
|
+
if (result.mode === "dry-run" && !active) action = "apply migration: amem migrate --apply";
|
|
243
|
+
else if (needsResume) action = "resume migration: amem migrate --resume";
|
|
244
|
+
else if (result.mode === "deactivate" && active) action = "deactivate generation: amem migrate --deactivate";
|
|
245
|
+
const lines = [
|
|
246
|
+
`${s.bold("amem migrate:")} ${state}${result.alreadyActive ? " (already active)" : ""}`,
|
|
247
|
+
`${label(s, "ok")} profile ${result.profile.kind}${result.profile.generation ? ` (generation ${result.profile.generation.slice(0, 8)})` : ""}`,
|
|
248
|
+
`${label(s, "ok")} home ${singleLine(result.profile.home)}`,
|
|
249
|
+
`${label(s, "ok")} current directory ${singleLine(result.profile.dir)}`,
|
|
250
|
+
...countRows,
|
|
251
|
+
`${label(s, "ok")} effect ${singleLine(result.consequence)}`,
|
|
252
|
+
`Next action: ${action}`,
|
|
253
|
+
];
|
|
254
|
+
return lines.join("\n");
|
|
255
|
+
}
|
package/src/memory/mutation.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import { chmodSync, lstatSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { realPath } from "./platform.ts";
|
|
4
5
|
|
|
5
6
|
export const MUTATION_BUSY_TIMEOUT_MS = 3000;
|
|
6
7
|
// ponytail: older hubs (before consuming this agent-mem), raw/legacy log writers and any segment editor
|
|
@@ -14,12 +15,31 @@ export function mutationDatabase(dir: string, busyTimeoutMs = MUTATION_BUSY_TIME
|
|
|
14
15
|
return db;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
declare const mutationBrand: unique symbol;
|
|
19
|
+
/** Callable for compatibility; authority exists only in the module-private live registry. */
|
|
20
|
+
export interface MutationHandle { (): void; readonly [mutationBrand]: true }
|
|
21
|
+
const mutations = new WeakMap<MutationHandle, { dir: string; dev: number; ino: number; db: Database; active: boolean }>();
|
|
22
|
+
export function assertMutationHandle(handle: MutationHandle, dir: string): void {
|
|
23
|
+
const state = mutations.get(handle), stat = lstatSync(dir);
|
|
24
|
+
if (!state || !state.active || !state.db.inTransaction || stat.isSymbolicLink() || !stat.isDirectory()
|
|
25
|
+
|| realPath(dir) !== state.dir || stat.dev !== state.dev || stat.ino !== state.ino) throw new Error("mutation handle unavailable or directory changed");
|
|
26
|
+
}
|
|
27
|
+
/** Sync holds a separate connection across awaits; an unrelated call cannot bypass it. */
|
|
28
|
+
export function acquireMutation(dir: string): MutationHandle | undefined {
|
|
29
|
+
const stat = lstatSync(dir);
|
|
30
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error("mutation directory unavailable");
|
|
31
|
+
const canonical = realPath(dir), db = mutationDatabase(canonical);
|
|
20
32
|
try { db.run("BEGIN IMMEDIATE"); }
|
|
21
33
|
catch { db.close(); return undefined; }
|
|
22
|
-
|
|
34
|
+
const release = (() => {
|
|
35
|
+
const state = mutations.get(release)!;
|
|
36
|
+
if (!state.active) return;
|
|
37
|
+
state.active = false;
|
|
38
|
+
try { db.run("ROLLBACK"); } finally { db.close(); }
|
|
39
|
+
}) as MutationHandle;
|
|
40
|
+
mutations.set(release, { dir: canonical, dev: stat.dev, ino: stat.ino, db, active: true });
|
|
41
|
+
try { assertMutationHandle(release, dir); } catch (error) { release(); throw error; }
|
|
42
|
+
return release;
|
|
23
43
|
}
|
|
24
44
|
|
|
25
45
|
/** Short local admission fence, never held across sync/network or data mutations. */
|
package/src/memory/note-add.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { KINDS, type Kind, type MemoryStore, type NoteInput, type Scope, type Tier } from "./store.ts";
|
|
2
|
+
import { cliAttribution } from "./cli-attribution.ts";
|
|
2
3
|
import type { StandalonePolicy } from "./policy.ts";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
|
-
* F6 (agent-mem#40):
|
|
6
|
-
* person (`peer: "person"`, `source: "person"`), which is why the command is person-only like every ordinary command (an agent marker is refused before it runs).
|
|
6
|
+
* F6 (agent-mem#40): note authoring writes one curated note through the configured policy. The invocation peer is recorded for attribution; source remains `person`.
|
|
7
7
|
*
|
|
8
8
|
* A `fail` note carries structured fields. Its body uses the same labelled lines an accepted `fail` candidate produces, so a note written here and one accepted
|
|
9
9
|
* from `amem_remember` read alike and `fail` search and the plan guard see one shape.
|
|
@@ -59,7 +59,7 @@ export function buildNote(input: NoteAddInput, project: string): NoteInput {
|
|
|
59
59
|
if (text.includes("\0")) refuse("the note text holds a NUL byte");
|
|
60
60
|
}
|
|
61
61
|
if (text.length > NOTE_MAX_CHARS) refuse(`the note is ${text.length} characters; the limit is ${NOTE_MAX_CHARS}`);
|
|
62
|
-
return { op: "note", project, scope: input.scope, tier: input.tier, kind: input.kind, peer: "person", source: "person", ...(title ? { title } : {}), text, about: [] };
|
|
62
|
+
return { op: "note", project, scope: input.scope, tier: input.tier, kind: input.kind, peer: cliAttribution()?.peer ?? "person", source: "person", ...(title ? { title } : {}), text, about: [] };
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/**
|
|
@@ -68,7 +68,7 @@ export function buildNote(input: NoteAddInput, project: string): NoteInput {
|
|
|
68
68
|
*/
|
|
69
69
|
export function noteAdd(store: MemoryStore, project: string, input: NoteAddInput, policy: StandalonePolicy): NoteAddResult {
|
|
70
70
|
const note = buildNote(input, project);
|
|
71
|
-
if (!policy.screenStandalone({ ...(note.title ? { title: note.title } : {}), text: note.text, about: note.about
|
|
71
|
+
if (!policy.screenStandalone({ ...(note.title ? { title: note.title } : {}), text: note.text, about: note.about, peer: note.peer })) refuse("the note is refused by the configured privacy policy (secrets, personal data, deny paths and the patterns in amem.config.json); edit it or the policy and try again");
|
|
72
72
|
return { id: store.note(note, input.tier === "short") };
|
|
73
73
|
}
|
|
74
74
|
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/** Private local publication authority, never a transport object or a native-row rewrite. */
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { closeSync, constants, fstatSync, fsyncSync, linkSync, lstatSync, openSync, readFileSync, readSync, readdirSync, unlinkSync, writeFileSync, type Stats } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { canonicalJSON, fsyncDirectory, type BoundProfile } from "./generation.ts";
|
|
6
|
+
import { assertMutationHandle, type MutationHandle } from "./mutation.ts";
|
|
7
|
+
import { realPath } from "./platform.ts";
|
|
8
|
+
import { validatePruneRow, type PruneRow } from "./prune.ts";
|
|
9
|
+
import type { PublicPruneLineage } from "./public-prune-witness.ts";
|
|
10
|
+
import { resolveShardProfile } from "./shard.ts";
|
|
11
|
+
|
|
12
|
+
export interface PublisherBoundaryV1 {
|
|
13
|
+
schema: "agent-mem.prune-publisher-boundary/v1";
|
|
14
|
+
generation: string; sealHash: string; machine: string; end: number; prefixSha256: string;
|
|
15
|
+
}
|
|
16
|
+
const fail = (): never => { throw new Error("prune publisher boundary unavailable or changed"); };
|
|
17
|
+
function guard(value: unknown): asserts value { if (!value) fail(); }
|
|
18
|
+
function owned(stat: Stats, directory = false, links = 1): void {
|
|
19
|
+
guard(!stat.isSymbolicLink() && (directory ? stat.isDirectory() : stat.isFile() && stat.nlink === links)
|
|
20
|
+
&& stat.uid === process.getuid?.() && (stat.mode & 0o077) === 0);
|
|
21
|
+
}
|
|
22
|
+
function same(a: Stats, b: Stats): boolean { return a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs; }
|
|
23
|
+
function bytes(file: string, limit: number, links = 1): Buffer {
|
|
24
|
+
const before = lstatSync(file); owned(before, false, links); guard(before.size <= limit);
|
|
25
|
+
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
26
|
+
try {
|
|
27
|
+
const opened = fstatSync(fd); owned(opened, false, links); guard(same(before, opened));
|
|
28
|
+
const value = readFileSync(fd); guard(value.length === before.size && same(opened, fstatSync(fd)) && same(opened, lstatSync(file))); return value;
|
|
29
|
+
} finally { closeSync(fd); }
|
|
30
|
+
}
|
|
31
|
+
function context(profile: BoundProfile, mutation: MutationHandle): { machine: string; sealHash: string; log: string; file: string; local: string } {
|
|
32
|
+
assertMutationHandle(mutation, profile.dir);
|
|
33
|
+
guard(profile.kind === "generation" && profile.sharded && profile.foreignPruneFormat === "agent-mem.public-prune-witness/v1");
|
|
34
|
+
const current = resolveShardProfile(profile.home, profile.shardUser ? { user: true } : { project: profile.shardKey });
|
|
35
|
+
guard(current.generation === profile.generation && current.foreignPruneFormat === profile.foreignPruneFormat
|
|
36
|
+
&& current.selectorRevision === profile.selectorRevision && realPath(current.dir) === realPath(profile.dir));
|
|
37
|
+
for (const dir of [profile.home, profile.dir, join(profile.dir, "local"), join(profile.dir, "log")]) owned(lstatSync(dir), true);
|
|
38
|
+
const rootMachine = JSON.parse(bytes(join(profile.home, "machine.json"), 4096).toString("utf8")) as { id?: unknown };
|
|
39
|
+
const shardMachine = JSON.parse(bytes(join(profile.dir, "machine.json"), 4096).toString("utf8")) as { id?: unknown };
|
|
40
|
+
guard(typeof rootMachine.id === "string" && /^[a-f0-9]{16}$/.test(rootMachine.id) && rootMachine.id === shardMachine.id);
|
|
41
|
+
// resolveShardProfile checks descriptor/sourceDigest; hash the original complete seal bytes too.
|
|
42
|
+
const sealHash = createHash("sha256").update(bytes(join(profile.dir, "generation-seal.json"), 4096)).digest("hex");
|
|
43
|
+
bytes(join(profile.dir, "generation.json"), 4096); bytes(join(profile.dir, "migration-sources.json"), 262144);
|
|
44
|
+
const machine = rootMachine.id, local = join(profile.dir, "local");
|
|
45
|
+
return { machine, sealHash, local, file: join(local, `publisher-boundary-${machine}.json`), log: join(profile.dir, "log", `${machine}.jsonl`) };
|
|
46
|
+
}
|
|
47
|
+
/** Streaming complete prefix and bounded line scan; no lifetime byte/row omission. */
|
|
48
|
+
function inspect(log: string, end?: number, rows?: ReadonlyMap<string, { row: PruneRow; canonical: string }>): { end: number; hash: string; found: Set<string> } {
|
|
49
|
+
const before = lstatSync(log, { throwIfNoEntry: false });
|
|
50
|
+
if (!before) { guard(end === undefined || end === 0); return { end: 0, hash: createHash("sha256").digest("hex"), found: new Set() }; }
|
|
51
|
+
owned(before); const bound = end ?? before.size; guard(Number.isSafeInteger(bound) && bound >= 0 && bound <= before.size);
|
|
52
|
+
const fd = openSync(log, constants.O_RDONLY | constants.O_NOFOLLOW), digest = createHash("sha256"), chunk = Buffer.alloc(65536);
|
|
53
|
+
let position = 0, lineStart = 0, line: number[] = [], last = -1, boundaryNewline = bound === 0;
|
|
54
|
+
const found = new Set<string>(), older = new Set<string>();
|
|
55
|
+
try {
|
|
56
|
+
guard(same(before, fstatSync(fd)));
|
|
57
|
+
while (position < before.size) {
|
|
58
|
+
const count = readSync(fd, chunk, 0, Math.min(chunk.length, before.size - position), position); guard(count > 0);
|
|
59
|
+
if (position < bound) digest.update(chunk.subarray(0, Math.min(count, bound - position)));
|
|
60
|
+
for (let i = 0; i < count; i++) {
|
|
61
|
+
const byte = chunk[i]!; last = byte;
|
|
62
|
+
if (position + i + 1 === bound) boundaryNewline = byte === 10;
|
|
63
|
+
if (rows) {
|
|
64
|
+
if (byte === 10) {
|
|
65
|
+
guard(line.length > 0);
|
|
66
|
+
const value: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Uint8Array.from(line)));
|
|
67
|
+
const id = value && typeof value === "object" && "id" in value && typeof value.id === "string" ? value.id : undefined;
|
|
68
|
+
const candidate = id === undefined ? undefined : rows.get(id);
|
|
69
|
+
const canonical = lineStart >= bound ? canonicalJSON(value) : undefined;
|
|
70
|
+
if (candidate) {
|
|
71
|
+
if (lineStart < bound) older.add(candidate.row.id);
|
|
72
|
+
else if (canonical === candidate.canonical) { validatePruneRow(value, candidate.row.generation, "synced"); found.add(candidate.row.id); }
|
|
73
|
+
}
|
|
74
|
+
line = []; lineStart = position + i + 1;
|
|
75
|
+
} else { line.push(byte); guard(line.length <= 1024 * 1024); }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
position += count;
|
|
79
|
+
if (!rows && position === before.size) break;
|
|
80
|
+
}
|
|
81
|
+
guard(boundaryNewline && (before.size === 0 || last === 10) && same(before, fstatSync(fd)) && same(before, lstatSync(log)));
|
|
82
|
+
for (const id of older) found.delete(id);
|
|
83
|
+
return { end: bound, hash: digest.digest("hex"), found };
|
|
84
|
+
} finally { closeSync(fd); }
|
|
85
|
+
}
|
|
86
|
+
function decode(value: Buffer): PublisherBoundaryV1 {
|
|
87
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(value), v = JSON.parse(text) as PublisherBoundaryV1;
|
|
88
|
+
guard(v && Object.keys(v).sort().join(",") === "end,generation,machine,prefixSha256,schema,sealHash"
|
|
89
|
+
&& v.schema === "agent-mem.prune-publisher-boundary/v1" && /^[a-f0-9]{32}$/.test(v.generation)
|
|
90
|
+
&& /^[a-f0-9]{16}$/.test(v.machine) && /^[a-f0-9]{64}$/.test(v.sealHash) && /^[a-f0-9]{64}$/.test(v.prefixSha256)
|
|
91
|
+
&& Number.isSafeInteger(v.end) && v.end >= 0 && text === canonicalJSON(v) + "\n"); return v;
|
|
92
|
+
}
|
|
93
|
+
/** Recover only an exact private temp/final inode pair while holding the real kernel fence. */
|
|
94
|
+
function recover(file: string, local: string): void {
|
|
95
|
+
const final = lstatSync(file, { throwIfNoEntry: false }); if (!final || final.nlink === 1) return;
|
|
96
|
+
owned(final, false, 2); decode(bytes(file, 4096, 2));
|
|
97
|
+
const candidates = readdirSync(local).filter(name => /^\.publisher-boundary-[a-f0-9]{32}\.tmp$/.test(name))
|
|
98
|
+
.map(name => join(local, name)).filter(name => { const stat = lstatSync(name); return stat.dev === final.dev && stat.ino === final.ino; });
|
|
99
|
+
guard(candidates.length === 1); const temp = candidates[0]!; owned(lstatSync(temp), false, 2);
|
|
100
|
+
guard(same(final, lstatSync(file)) && same(final, lstatSync(temp))); unlinkSync(temp); fsyncDirectory(local);
|
|
101
|
+
}
|
|
102
|
+
export function assertPublisherBoundary(profile: BoundProfile, mutation: MutationHandle): PublisherBoundaryV1 {
|
|
103
|
+
const ctx = context(profile, mutation); recover(ctx.file, ctx.local);
|
|
104
|
+
const value = decode(bytes(ctx.file, 4096));
|
|
105
|
+
guard(value.generation === profile.generation && value.machine === ctx.machine && value.sealHash === ctx.sealHash);
|
|
106
|
+
guard(inspect(ctx.log, value.end).hash === value.prefixSha256); assertMutationHandle(mutation, profile.dir); return value;
|
|
107
|
+
}
|
|
108
|
+
/** Explicit sync-only admission: existing complete own log is a conservative migration upper bound. */
|
|
109
|
+
export function ensurePublisherBoundary(profile: BoundProfile, mutation: MutationHandle): PublisherBoundaryV1 {
|
|
110
|
+
const ctx = context(profile, mutation); recover(ctx.file, ctx.local);
|
|
111
|
+
if (lstatSync(ctx.file, { throwIfNoEntry: false })) return assertPublisherBoundary(profile, mutation);
|
|
112
|
+
const prefix = inspect(ctx.log), value: PublisherBoundaryV1 = { schema: "agent-mem.prune-publisher-boundary/v1", generation: profile.generation!,
|
|
113
|
+
sealHash: ctx.sealHash, machine: ctx.machine, end: prefix.end, prefixSha256: prefix.hash };
|
|
114
|
+
const temp = join(ctx.local, `.publisher-boundary-${randomBytes(16).toString("hex")}.tmp`), fd = openSync(temp, "wx", 0o600);
|
|
115
|
+
try { writeFileSync(fd, canonicalJSON(value) + "\n"); fsyncSync(fd); } finally { closeSync(fd); }
|
|
116
|
+
// A crash leaves either a harmless unregistered temp or an exact recoverable pair.
|
|
117
|
+
linkSync(temp, ctx.file); fsyncDirectory(ctx.local); unlinkSync(temp); fsyncDirectory(ctx.local);
|
|
118
|
+
return assertPublisherBoundary(profile, mutation);
|
|
119
|
+
}
|
|
120
|
+
/** Verify one candidate set with one complete JSONL parse, independent of the candidate count. */
|
|
121
|
+
export function publisherPruneLineages(profile: BoundProfile, mutation: MutationHandle, rows: readonly PruneRow[]): Map<string, PublicPruneLineage> {
|
|
122
|
+
const boundary = assertPublisherBoundary(profile, mutation), candidates = new Map<string, { row: PruneRow; canonical: string }>();
|
|
123
|
+
for (const row of rows) {
|
|
124
|
+
if (row.machine !== boundary.machine || row.generation !== boundary.generation || row.placement !== "synced") continue;
|
|
125
|
+
validatePruneRow(row, boundary.generation, "synced");
|
|
126
|
+
const canonical = canonicalJSON(row), previous = candidates.get(row.id);
|
|
127
|
+
guard(!previous || previous.canonical === canonical); candidates.set(row.id, { row, canonical });
|
|
128
|
+
}
|
|
129
|
+
const result = new Map<string, PublicPruneLineage>();
|
|
130
|
+
if (!candidates.size) return result;
|
|
131
|
+
const ctx = context(profile, mutation), proof = inspect(ctx.log, boundary.end, candidates);
|
|
132
|
+
guard(proof.hash === boundary.prefixSha256); assertPublisherBoundary(profile, mutation);
|
|
133
|
+
for (const id of proof.found) result.set(id, { kind: "post-seal", seal: boundary.sealHash, end: boundary.end });
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
/** Compatibility entry point; batch callers avoid rescanning once per original. */
|
|
137
|
+
export function publisherPruneLineage(profile: BoundProfile, mutation: MutationHandle, row: PruneRow): PublicPruneLineage | undefined {
|
|
138
|
+
return publisherPruneLineages(profile, mutation, [row]).get(row.id);
|
|
139
|
+
}
|
package/src/memory/prune.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { canonicalJSON, hashJSON, validGeneration } from "./generation.ts";
|
|
2
2
|
import { entryId, type Entry, type NoteEntry } from "./store.ts";
|
|
3
|
+
import { label, PLAIN, singleLine, type Style } from "./style.ts";
|
|
3
4
|
|
|
4
5
|
export const PRUNE_ROW_MAX_BYTES = 16_384, PRUNE_PLAN_MAX_BYTES = 262_144;
|
|
5
6
|
export const PRUNE_ROW_MAX_TARGETS = 32, PRUNE_PLAN_MAX_ROWS = 16, PRUNE_PLAN_MAX_TARGETS = 256;
|
|
@@ -128,7 +129,7 @@ export function reducePruneRows(rows: readonly { row: unknown; placement: PruneP
|
|
|
128
129
|
}
|
|
129
130
|
function authenticate(port: PruneStorePort, auth: PruneAuthorization): void {
|
|
130
131
|
guard(validGeneration(port.generation) && /^[a-f0-9]{16}$/.test(port.machine) && revision(port.selectorRevision()));
|
|
131
|
-
guard(auth.actor === "person" && typeof auth.authorize === "function" && auth.authorize() === true && typeof auth.project === "string" && auth.project.length > 0);
|
|
132
|
+
guard((auth.actor === "person" || auth.actor === "agent") && typeof auth.authorize === "function" && auth.authorize() === true && typeof auth.project === "string" && auth.project.length > 0);
|
|
132
133
|
}
|
|
133
134
|
function policyRevision(auth: PruneAuthorization): string {
|
|
134
135
|
if (!auth.policy) return "local-only";
|
|
@@ -257,3 +258,29 @@ export function applyPrune(port: PruneStorePort, input: unknown, auth: PruneAuth
|
|
|
257
258
|
});
|
|
258
259
|
});
|
|
259
260
|
}
|
|
261
|
+
|
|
262
|
+
/** Human-readable preview or apply result. A preview is descriptive output, not a serialized sealed plan. */
|
|
263
|
+
export function pruneText(result: PrunePlan | { completed: string[] }, s: Style = PLAIN): string {
|
|
264
|
+
if ("completed" in result) {
|
|
265
|
+
return [
|
|
266
|
+
`amem prune: ${s.ok("complete")}`,
|
|
267
|
+
`${label(s, result.completed.length ? "ok" : "skipped")} completed prune rows: ${result.completed.length}`,
|
|
268
|
+
...result.completed.map(id => ` ${label(s, "ok")} ${id}`),
|
|
269
|
+
"Next action: none",
|
|
270
|
+
].join("\n");
|
|
271
|
+
}
|
|
272
|
+
const targetIds = result.rows.flatMap(row => row.payload.targets.map(target => target.id));
|
|
273
|
+
const next = targetIds.length
|
|
274
|
+
? "save this preview with --json, then amem prune --apply --plan <file>; keep --user if used in the preview"
|
|
275
|
+
: "none; the sealed plan contains no prune rows";
|
|
276
|
+
return [
|
|
277
|
+
`amem prune: ${s.attention("preview")}`,
|
|
278
|
+
`${label(s, "ok")} project: ${singleLine(result.project)}`,
|
|
279
|
+
...(result.rows.length ? [`${label(s, "ok")} cutoff: ${result.rows[0]!.payload.cutoff}`] : []),
|
|
280
|
+
`${label(s, targetIds.length ? "attention" : "skipped")} selection: eligible ${result.counts.eligible}, selected ${result.counts.selected}, local ${result.counts.local}, synced ${result.counts.synced}`,
|
|
281
|
+
...(result.hasMore ? ["more eligible entries remain beyond this plan"] : []),
|
|
282
|
+
...result.rows.flatMap(row => row.payload.targets.map(target => ` ${label(s, "attention")} ${row.placement}: ${target.id} (expired ${target.expiredAt})`)),
|
|
283
|
+
`Next action: ${next}`,
|
|
284
|
+
...(targetIds.length ? ["The readable preview is not a sealed JSON plan."] : []),
|
|
285
|
+
].join("\n");
|
|
286
|
+
}
|