@staix/agent-mem 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Curated memory with append-only JSONL logs, a derived SQLite index, short- and long-term tiers, a typed graph, goals with leases, and git sync between machines. Extracted from [agent-hub](https://github.com/STAIxBWLB/agent-hub), with compatible storage and entry IDs.
4
4
 
5
- Version: 0.1.1. Requires Bun 1.4.2 or later; no runtime dependencies.
5
+ Version: 0.2.0. Requires Bun 1.4.2 or later; no runtime dependencies.
6
6
 
7
7
  ## Install
8
8
 
@@ -46,6 +46,29 @@ amem sync --remote <private-git-url> --policy-free
46
46
 
47
47
  Use `ahub memory` for the policy-applying path. `--policy-free` does not bypass the secret screen or the sync remote's privacy checks. Forgetting a shared note appends an operation; its previous content remains in the remote's git history.
48
48
 
49
+ ## Candidate review
50
+
51
+ `amem review` lists this project's pending machine-local candidates. They are proposals, not memory notes and not evidence. Use JSON to inspect the complete candidate payload before accepting or rejecting its full 64-character ID:
52
+
53
+ ```sh
54
+ amem review --json
55
+ amem review list --kind correction --status all --limit 50 --json
56
+ amem review accept <full-candidate-id> --policy-free --json
57
+ amem review reject <full-candidate-id> --json
58
+ ```
59
+
60
+ All review commands remain person-only; acceptance and rejection require a terminal on stdin. Acceptance additionally requires `--policy-free`, acknowledging that the standalone CLI cannot apply a host's privacy policy; use `ahub memory` for the host policy path. The CLI refuses secret-looking candidate input or proposed note content. Listing does not open the ordinary memory index, and acceptance defers unrelated held-note promotions.
61
+
62
+ Candidates live in `candidates.db` beside the memory logs. This sidecar is machine-local: it is never synced, imported, or read by recall, briefs, ask or graph evidence. Hosts must supply an explicit public privacy verdict before putting candidates or counters; private, PII and unknown verdicts are refused. Library APIs are available, but this phase adds no automatic candidate producer or telemetry consumer. Candidate retention is 500 per project and 30 days; counters retain 10,000 per project and 90 days. Candidate payloads are capped at 16 KiB.
63
+
64
+ Acceptance creates ordinary v1 memory data. A correction retains its target's scope, project and storage reach, writes the corrected note and applies the existing `supersede` operation; its exact current target revision must match. A local target stays short-term; a synced target stays synced, including a demoted target whose effective tier is short. A fail becomes a long-term project note. The accepted text binds the candidate ID, target revision and target ID; a fail proposal without a target records `none` for both target fields. Both paths require the actual MemoryStore promotion screen to return `promote` at acceptance time; `keep`, `skip` or an absent screen refuses acceptance without falling back to short-term storage. Accepted v1 notes and operations follow the existing storage and sync rules; the candidate/counter sidecar remains local and outside evidence. No new v1 operation is introduced. Decay candidates are listing-only and cannot be accepted; a person may separately choose `ahub memory demote` or `forget`. The sidecar receipt, note append and correction's supersede append are separate durable writes. An interrupted acceptance can leave a note without its supersede; it is held for explicit inspection and never replayed automatically.
65
+
66
+ The library entry exports `CandidateStore({ home?, now? })`, `put(input, verdict)`, `recordCounter(key, values, verdict)`, and `accept(id, { project, actor, verdict, store, policy })`. `home` retains the parent-home convention (`<home>/memory`); the default uses `memoryHome()`. The host must authenticate person intent, bind the current project and privacy verdict, and supply a current acceptance policy plus the ordinary store's actual promotion screen. An `actor` string or caller-supplied public verdict alone is not authentication. Counter writes are idempotent snapshots for the same contextual key, not increments: observed `0` stays zero, while `null` means unknown (a later unknown value does not erase an observed value).
67
+
68
+ Open a review-only `MemoryStore` with `deferPromotions: true` to prevent unrelated automatic promotions during construction and reads. Explicit candidate acceptance still runs its promotion screen. This package registers no capture observer or host candidate tools; capture exclusion and authenticated producers must be established by the host before those integrations are enabled.
69
+
70
+ Acceptance normalizes its note once, including timestamp, content ID and short-term expiry, then screens and appends that exact body. The final target-revision check and correction appends hold the same machine-local `mutation-lock.db` writer transaction used by this version's ordinary memory, import and sync writers. This lock is independent of the derived index, so an ordinary write still persists when SQLite is busy. External or older writers that edit segments without this lock do not participate in serialization. The logs remain authoritative after an interrupted acceptance; the next read refreshes the derived index.
71
+
49
72
  ## Shell completion
50
73
 
51
74
  Generate completion for `zsh`, `bash`, or `fish` with `amem completion <shell>`. Generation prints a script to stdout; it does not install files or edit shell configuration. The following commands are for a person's terminal.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/agent-mem",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Curated, shared memory for coding agents: append-only logs, a derived index, tiers and a typed graph.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/cli.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { MemoryStore, looksSecret } from "./memory/store.ts";
3
3
  import { rawMemoryCommand, rawGoalCommand } from "./memory/commands.ts";
4
+ import { memoryProjectKey } from "./memory/project.ts";
5
+ import { CandidateStore, type CandidateFilter } from "./memory/candidates.ts";
4
6
 
5
7
  import { commandHelp, parseCommand } from "./commands-table.ts";
6
8
  import { completionScript } from "./completion.ts";
@@ -31,12 +33,39 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
31
33
  throw error;
32
34
  }
33
35
  const { definition, options, rawArgs: args } = parsed;
34
- const goal = parsed.path[0] === "goal", sub = goal ? parsed.path[1]! : parsed.path[0]!;
36
+ const goal = parsed.path[0] === "goal", review = parsed.path[0] === "review", sub = goal || review ? parsed.path[1]! : parsed.path[0]!;
35
37
  const policyFree = options["--policy-free"] === true;
36
38
  const mutating = definition.mutates === true || definition.mutates === "apply" && options["--apply"] === true || definition.mutates === "label" && options["--label"] !== undefined;
37
39
  if (mutating && !(context.stdinTTY ?? process.stdin.isTTY)) throw new Error("mutating commands require a terminal on stdin");
38
40
  if (definition.policyFree && !policyFree) {
39
- throw new Error(`${sub} requires --policy-free; use ahub memory ${sub} to apply the host's privacy policy`);
41
+ throw new Error(review ? "review accept requires --policy-free; use ahub memory to apply the host's privacy policy" : `${sub} requires --policy-free; use ahub memory ${sub} to apply the host's privacy policy`);
42
+ }
43
+ if (review) {
44
+ const id = parsed.positionals[0];
45
+ if (sub !== "list" && (!id || !/^[a-f0-9]{64}$/.test(id))) throw new Error("review accept/reject requires a full 64-character candidate id");
46
+ const project = memoryProjectKey(context.cwd ?? process.cwd()), candidates = new CandidateStore();
47
+ try {
48
+ if (sub === "list") {
49
+ const rows = candidates.list({ project, ...(options["--status"] ? { status: options["--status"] as CandidateFilter["status"] } : {}),
50
+ ...(options["--kind"] ? { kind: options["--kind"] as CandidateFilter["kind"] } : {}), ...(options["--limit"] ? { limit: Number(options["--limit"]) } : {}) });
51
+ return options["--json"] ? JSON.stringify(rows, null, 2) : rows.length ? rows.map(candidate => `${candidate.id} ${candidate.input.kind} ${candidate.status} ${candidate.createdAt}`).join("\n") : "no candidates waiting for review";
52
+ }
53
+ if (sub === "reject") {
54
+ candidates.reject(id!, { project, actor: "person", verdict: "public" });
55
+ return options["--json"] ? JSON.stringify({ candidateId: id, status: "rejected" }, null, 2) : `rejected candidate ${id}`;
56
+ }
57
+ const candidate = candidates.get(id!, project);
58
+ if (!candidate) throw new Error("candidate unavailable");
59
+ if (candidate.input.kind === "decay") throw new Error("decay candidates are listing-only; use ahub memory demote or forget after review");
60
+ // Candidate acceptance uses the actual secret screen; the core defers unrelated automatic promotions.
61
+ const store = new MemoryStore({ deferPromotions: true, screen: note => looksSecret(`${note.title ?? ""}\n${note.text}`) ? "keep" : "promote" });
62
+ try {
63
+ const result = candidates.accept(id!, { project, actor: "person", verdict: "public", store,
64
+ policy: (proposal, _target, note) => !looksSecret(JSON.stringify(proposal.input)) && !looksSecret(JSON.stringify(note)),
65
+ });
66
+ return options["--json"] ? JSON.stringify(result, null, 2) : `accepted candidate ${id} as ordinary v1 note ${result.noteId}`;
67
+ } finally { store.close(); }
68
+ } finally { candidates.close(); }
40
69
  }
41
70
  // ponytail: board task links need the host's authenticated task boundary; add a host bridge before supporting them here.
42
71
  if (goal && sub === "link") throw new Error("goal link needs host integration; use ahub goal link");
@@ -47,6 +47,14 @@ export const COMMANDS: Record<string, CommandDefinition> = {
47
47
  done: { description: "Complete a goal", args: [{ name: "id" }], mutates: true },
48
48
  take: { description: "Take a goal lease", options: { "--force": flag("Override the prior lease") }, args: [{ name: "id" }], mutates: true },
49
49
  } },
50
+ review: { description: "Review machine-local non-evidentiary candidates", options: json, defaultCommand: "list", commands: {
51
+ list: { description: "List review candidates", options: {
52
+ "--status": { description: "Candidate status", value: ["pending", "accepting", "accepted", "rejected", "all"] },
53
+ "--kind": { description: "Candidate kind", value: ["correction", "fail", "decay"] }, "--limit": text("Maximum candidates (1..500)"),
54
+ } },
55
+ accept: { description: "Accept one candidate through the current memory policy", options: policy, args: [{ name: "full-candidate-id" }], mutates: true, policyFree: true },
56
+ reject: { description: "Reject one candidate", args: [{ name: "full-candidate-id" }], mutates: true },
57
+ } },
50
58
  completion: { description: "Print static shell completion", args: [{ name: "shell", values: ["zsh", "bash", "fish"] }] },
51
59
  help: { description: "Show help" }, version: { description: "Show version" },
52
60
  };
package/src/index.ts CHANGED
@@ -5,3 +5,4 @@ export * from "./memory/project.ts";
5
5
  export * from "./memory/home.ts";
6
6
  export * from "./memory/platform.ts";
7
7
  export * from "./memory/commands.ts";
8
+ export * from "./memory/candidates.ts";
@@ -0,0 +1,196 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { createHash } from "node:crypto";
3
+ import { chmodSync, mkdirSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { memoryHome } from "./home.ts";
6
+ import { realPath } from "./platform.ts";
7
+ import { MemoryStore, type NoteInput, type View } from "./store.ts";
8
+
9
+ export const CANDIDATE_DB_BUSY_TIMEOUT_MS = 3000;
10
+ export const CANDIDATE_RETENTION = { count: 500, ageMs: 30 * 86400_000 } as const;
11
+ export const COUNTER_RETENTION = { count: 10_000, ageMs: 90 * 86400_000 } as const;
12
+ export type CandidateVerdict = "public" | "private" | "pii" | "unknown";
13
+ export interface CandidateTarget { id: string; revision: string }
14
+ export type CandidateInput = { project: string } & (
15
+ | { kind: "correction"; target: CandidateTarget; proposedText: string; reason: string; noteKind?: "decision" | "contract" | "fail" }
16
+ | { kind: "fail"; target?: CandidateTarget; approach: string; symptom: string; cause: string; evidenceRefs: string[]; retryWhen: string }
17
+ | { kind: "decay"; target: CandidateTarget; reason: string }
18
+ );
19
+ export type CandidateStatus = "pending" | "accepting" | "accepted" | "rejected";
20
+ export interface Candidate { id: string; createdAt: string; status: CandidateStatus; input: CandidateInput; acceptedNoteId: string | null }
21
+ export interface CandidateFilter { project: string; status?: CandidateStatus | "all"; kind?: CandidateInput["kind"]; limit?: number }
22
+ export interface CounterKey { entryId: string; project: string; task?: string | null; turn?: string | null; session?: string | null; deliveryId: string }
23
+ export interface CounterValues { rendered?: number | null; delivered?: number | null; cited?: number | null }
24
+ export interface Counter extends CounterValues { id: string; key: CounterKey; createdAt: string; rendered: number | null; delivered: number | null; cited: number | null }
25
+ export interface CandidateAcceptance {
26
+ project: string;
27
+ /** The host binds this to authenticated person intent; it is not an authentication token. */
28
+ actor: "person" | "agent";
29
+ verdict: CandidateVerdict;
30
+ store: MemoryStore;
31
+ policy: (candidate: Candidate, target: View | undefined, note: NoteInput) => boolean;
32
+ }
33
+ export interface CandidateAcceptanceResult { candidateId: string; noteId: string; alreadyAccepted: boolean }
34
+
35
+ function canonical(value: unknown): string {
36
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
37
+ if (value && typeof value === "object") return `{${Object.entries(value).filter(([, v]) => v !== undefined).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`;
38
+ return JSON.stringify(value);
39
+ }
40
+ const hash = (value: unknown) => createHash("sha256").update(canonical(value)).digest("hex");
41
+ const text = (value: unknown, max = 4000): value is string => typeof value === "string" && value.trim().length > 0 && value.length <= max && !value.includes("\0");
42
+ const entryId = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9]{32}$/.test(value);
43
+ const candidateId = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
44
+ const publicOnly = (verdict: CandidateVerdict) => { if (verdict !== "public") throw new Error("candidate data refused by host privacy verdict"); };
45
+ const personOnly = (actor: string) => { if (actor !== "person") throw new Error("candidate acceptance and rejection require a person"); };
46
+ const validTarget = (value: CandidateTarget | undefined) => value !== undefined && entryId(value.id) && candidateId(value.revision);
47
+ function validate(input: CandidateInput): void {
48
+ if (!input || !text(input.project, 400)) throw new Error("invalid candidate project");
49
+ if (input.kind === "correction") {
50
+ if (!validTarget(input.target) || !text(input.proposedText) || !text(input.reason) || input.noteKind !== undefined && !["decision", "contract", "fail"].includes(input.noteKind)) throw new Error("invalid correction candidate");
51
+ } else if (input.kind === "fail") {
52
+ if (input.target !== undefined && !validTarget(input.target) || ![input.approach, input.symptom, input.cause, input.retryWhen].every(x => text(x)) || !Array.isArray(input.evidenceRefs) || input.evidenceRefs.length < 1 || input.evidenceRefs.length > 16 || !input.evidenceRefs.every(x => text(x, 400))) throw new Error("invalid fail candidate");
53
+ } else if (input.kind === "decay") {
54
+ if (!validTarget(input.target) || !text(input.reason)) throw new Error("invalid decay candidate");
55
+ } else throw new Error("invalid candidate kind");
56
+ if (Buffer.byteLength(canonical(input), "utf8") > 16_384) throw new Error("candidate payload exceeds 16 KiB");
57
+ }
58
+
59
+ /** Includes current lifecycle state, so a changed promotion, supersession or demotion makes a proposal stale. */
60
+ export function candidateTargetRevision(target: View): string {
61
+ return hash({ ...target, about: [...target.about].sort(), supersedes: [...target.supersedes].sort() });
62
+ }
63
+
64
+ function counterKey(key: CounterKey): CounterKey {
65
+ if (!key || !entryId(key.entryId) || !text(key.project, 400) || !text(key.deliveryId, 200)) throw new Error("invalid counter key");
66
+ for (const value of [key.task, key.turn, key.session]) if (value !== undefined && value !== null && !text(value, 400)) throw new Error("invalid counter context");
67
+ return { entryId: key.entryId, project: key.project, task: key.task ?? null, turn: key.turn ?? null, session: key.session ?? null, deliveryId: key.deliveryId };
68
+ }
69
+
70
+ /** Explicitly opened machine-local data. Importing this module never opens a store or registers a consumer. */
71
+ export class CandidateStore {
72
+ readonly dir: string;
73
+ readonly path: string;
74
+ private readonly db: Database;
75
+ private readonly now: () => number;
76
+ constructor(options: { home?: string; now?: () => number } = {}) {
77
+ this.dir = options.home === undefined ? memoryHome() : join(options.home, "memory");
78
+ this.now = options.now ?? Date.now;
79
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 }); chmodSync(this.dir, 0o700);
80
+ this.path = join(this.dir, "candidates.db");
81
+ this.db = new Database(this.path, { create: true });
82
+ this.db.run(`PRAGMA busy_timeout = ${CANDIDATE_DB_BUSY_TIMEOUT_MS}`);
83
+ chmodSync(this.path, 0o600); this.db.run("PRAGMA journal_mode = WAL");
84
+ this.db.run("CREATE TABLE IF NOT EXISTS candidates (id TEXT PRIMARY KEY, project TEXT NOT NULL, kind TEXT NOT NULL, payload TEXT NOT NULL, at INTEGER NOT NULL, status TEXT NOT NULL, note_id TEXT)");
85
+ this.db.run("CREATE TABLE IF NOT EXISTS counters (id TEXT PRIMARY KEY, project TEXT NOT NULL, payload TEXT NOT NULL, at INTEGER NOT NULL, rendered INTEGER, delivered INTEGER, cited INTEGER)");
86
+ this.prune();
87
+ }
88
+ close(): void { this.db.close(); }
89
+ private prune(): void {
90
+ this.db.transaction(() => {
91
+ for (const [table, retention] of [["candidates", CANDIDATE_RETENTION], ["counters", COUNTER_RETENTION]] as const) {
92
+ this.db.query(`DELETE FROM ${table} WHERE at <= ?`).run(this.now() - retention.ageMs);
93
+ this.db.query(`DELETE FROM ${table} WHERE rowid IN (SELECT rowid FROM (SELECT rowid, row_number() OVER (PARTITION BY project ORDER BY at DESC, rowid DESC) AS rank FROM ${table}) WHERE rank > ?)`).run(retention.count);
94
+ }
95
+ }).immediate();
96
+ }
97
+ private decode(row: { id: string; payload: string; at: number; status: CandidateStatus; note_id: string | null }): Candidate {
98
+ const input = JSON.parse(row.payload) as CandidateInput; validate(input);
99
+ if (hash({ schema: "agent-mem.candidate/v1", input }) !== row.id) throw new Error("candidate content hash mismatch");
100
+ return { id: row.id, createdAt: new Date(row.at).toISOString(), status: row.status, input, acceptedNoteId: row.note_id };
101
+ }
102
+ put(input: CandidateInput, verdict: CandidateVerdict): string {
103
+ publicOnly(verdict); validate(input); this.prune();
104
+ const payload = canonical(input), id = hash({ schema: "agent-mem.candidate/v1", input });
105
+ this.db.query("INSERT OR IGNORE INTO candidates (id, project, kind, payload, at, status) VALUES (?, ?, ?, ?, ?, 'pending')").run(id, input.project, input.kind, payload, this.now());
106
+ this.prune(); return id;
107
+ }
108
+ get(id: string, project: string): Candidate | undefined {
109
+ if (!candidateId(id) || !text(project, 400)) throw new Error("invalid candidate identity");
110
+ this.prune();
111
+ const row = this.db.query("SELECT * FROM candidates WHERE id = ? AND project = ?").get(id, project) as Parameters<CandidateStore["decode"]>[0] | null;
112
+ return row ? this.decode(row) : undefined;
113
+ }
114
+ list(filter: CandidateFilter): Candidate[] {
115
+ if (!text(filter.project, 400) || filter.status !== undefined && !["pending", "accepting", "accepted", "rejected", "all"].includes(filter.status) || filter.kind !== undefined && !["correction", "fail", "decay"].includes(filter.kind)) throw new Error("invalid candidate filter");
116
+ const limit = filter.limit ?? CANDIDATE_RETENTION.count;
117
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > CANDIDATE_RETENTION.count) throw new Error("candidate limit must be 1..500");
118
+ this.prune();
119
+ const where = ["project = ?"], bind: (string | number)[] = [filter.project];
120
+ if (filter.status === undefined) where.push("status IN ('pending', 'accepting')"); else if (filter.status !== "all") { where.push("status = ?"); bind.push(filter.status); }
121
+ if (filter.kind) { where.push("kind = ?"); bind.push(filter.kind); }
122
+ return (this.db.query(`SELECT * FROM candidates WHERE ${where.join(" AND ")} ORDER BY at DESC, rowid DESC LIMIT ?`).all(...bind, limit) as Parameters<CandidateStore["decode"]>[0][]).map(row => this.decode(row));
123
+ }
124
+ recordCounter(key: CounterKey, values: CounterValues, verdict: CandidateVerdict): string {
125
+ publicOnly(verdict); const normalized = counterKey(key);
126
+ for (const value of [values.rendered, values.delivered, values.cited]) if (value !== undefined && value !== null && (!Number.isSafeInteger(value) || value < 0)) throw new Error("counter values must be nonnegative integers or unknown");
127
+ this.prune(); const id = hash({ schema: "agent-mem.counter/v1", key: normalized });
128
+ this.db.query(`INSERT INTO counters (id, project, payload, at, rendered, delivered, cited) VALUES (?, ?, ?, ?, ?, ?, ?)
129
+ ON CONFLICT(id) DO UPDATE SET rendered = coalesce(excluded.rendered, counters.rendered), delivered = coalesce(excluded.delivered, counters.delivered), cited = coalesce(excluded.cited, counters.cited)`)
130
+ .run(id, normalized.project, canonical(normalized), this.now(), values.rendered ?? null, values.delivered ?? null, values.cited ?? null);
131
+ this.prune(); return id;
132
+ }
133
+ counters(project: string, entry?: string): Counter[] {
134
+ if (!text(project, 400) || entry !== undefined && !entryId(entry)) throw new Error("invalid counter filter");
135
+ this.prune();
136
+ const rows = this.db.query("SELECT * FROM counters WHERE project = ? ORDER BY at DESC, rowid DESC").all(project) as { id: string; payload: string; at: number; rendered: number | null; delivered: number | null; cited: number | null }[];
137
+ return rows.map(row => ({ id: row.id, key: JSON.parse(row.payload) as CounterKey, createdAt: new Date(row.at).toISOString(), rendered: row.rendered, delivered: row.delivered, cited: row.cited })).filter(row => !entry || row.key.entryId === entry);
138
+ }
139
+ reject(id: string, context: { project: string; actor: "person" | "agent"; verdict: CandidateVerdict }): void {
140
+ personOnly(context.actor); publicOnly(context.verdict);
141
+ this.db.transaction(() => {
142
+ const candidate = this.get(id, context.project);
143
+ if (!candidate) throw new Error("candidate unavailable");
144
+ if (candidate.status === "accepted" || candidate.status === "accepting") throw new Error("candidate acceptance already started; inspect its ordinary note before any further action");
145
+ this.db.query("UPDATE candidates SET status = 'rejected' WHERE id = ? AND project = ? AND status = 'pending'").run(id, context.project);
146
+ }).immediate();
147
+ }
148
+ accept(id: string, context: CandidateAcceptance): CandidateAcceptanceResult {
149
+ personOnly(context.actor); publicOnly(context.verdict);
150
+ if (realPath(context.store.dir) !== realPath(this.dir) || typeof context.policy !== "function") throw new Error("candidate acceptance requires its memory store and a current host policy");
151
+ return context.store.withDeferredPromotions(() => this.acceptDeferred(id, context));
152
+ }
153
+ private acceptDeferred(id: string, context: CandidateAcceptance): CandidateAcceptanceResult {
154
+ const candidate = this.get(id, context.project);
155
+ if (!candidate) throw new Error("candidate unavailable");
156
+ if (candidate.status === "accepted") return { candidateId: id, noteId: candidate.acceptedNoteId!, alreadyAccepted: true };
157
+ if (candidate.status !== "pending") throw new Error("candidate is rejected or acceptance was interrupted; no automatic replay");
158
+ if (candidate.input.kind === "decay") throw new Error("decay candidates are listing-only; no automatic mutation is available");
159
+ const resolve = () => {
160
+ const target = candidate.input.target;
161
+ if (!target) return undefined;
162
+ const current = context.store.get(target.id, context.project);
163
+ if (!current || current.id !== target.id || candidateTargetRevision(current) !== target.revision) throw new Error("candidate target revision is stale");
164
+ return current;
165
+ };
166
+ const target = resolve(), data = candidate.input;
167
+ const body = data.kind === "correction" ? `${data.proposedText}\n\nReason: ${data.reason}`
168
+ : `Approach: ${data.approach}\nSymptom: ${data.symptom}\nCause: ${data.cause}\nEvidence: ${data.evidenceRefs.join(", ")}\nRetry when: ${data.retryWhen}`;
169
+ const note: NoteInput = { op: "note", project: data.kind === "correction" ? target!.project : context.project,
170
+ scope: data.kind === "correction" ? target!.scope : "project",
171
+ // A demoted synced target is logically short, but replacing it locally would narrow its reach (X04).
172
+ tier: data.kind === "correction" && target!.local ? "short" : "long",
173
+ kind: data.kind === "fail" ? "fail" : data.noteKind ?? (target?.kind === "contract" || target?.kind === "fail" ? target.kind : "decision"), peer: "person", source: "person", about: [...target?.about ?? []],
174
+ text: `Accepted candidate: ${id}\nTarget revision: ${data.target?.revision ?? "none"}\nTarget entry: ${data.target?.id ?? "none"}\n\n${body}` };
175
+ if (note.text.length > 4000) throw new Error("accepted candidate text exceeds the v1 note limit");
176
+ if (context.policy(structuredClone(candidate), target && structuredClone(target), structuredClone(note)) !== true) throw new Error("candidate refused by current host policy");
177
+ return context.store.withAcceptedNote(note, append => {
178
+ resolve(); // Recheck under the same mutation lock as the note and supersede appends.
179
+ const started = this.db.transaction(() => {
180
+ const current = this.get(id, context.project);
181
+ if (!current) throw new Error("candidate unavailable");
182
+ if (current.status === "accepted") return current.acceptedNoteId!;
183
+ if (current.status !== "pending") throw new Error("candidate is rejected or acceptance was interrupted; no automatic replay");
184
+ this.db.query("UPDATE candidates SET status = 'accepting' WHERE id = ? AND project = ?").run(id, context.project);
185
+ return undefined;
186
+ }).immediate();
187
+ if (started) return { candidateId: id, noteId: started, alreadyAccepted: true };
188
+ // ponytail: the sidecar receipt and v1 append are not one transaction. An interrupted 'accepting' row refuses
189
+ // automatic replay; add explicit person-audited recovery before offering background acceptance retries.
190
+ const noteId = append();
191
+ if (data.kind === "correction" && !context.store.supersede(target!.id, noteId, "person", context.project)) throw new Error("candidate replacement refused; inspect the accepted note before recovery");
192
+ this.db.query("UPDATE candidates SET status = 'accepted', note_id = ? WHERE id = ? AND project = ?").run(noteId, id, context.project);
193
+ return { candidateId: id, noteId, alreadyAccepted: false };
194
+ });
195
+ }
196
+ }
@@ -55,7 +55,9 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
55
55
  // Screened first, then cut to the limit: withheld entries never crowd out readable ones.
56
56
  const readable = opts.readable(all), rows = readable.slice(0, limit ? Number(limit) : 50);
57
57
  if (json) return JSON.stringify(rows, null, 2);
58
- const c = store.counts(project);
58
+ // Preserve project-wide header semantics, but count only rows the host policy lets this reader see.
59
+ const counted = opts.readable(store.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true }));
60
+ const c = { machine: store.machine, long: counted.filter(v => v.tier === "long").length, short: counted.filter(v => v.tier === "short").length };
59
61
  const withheld = all.length - readable.length;
60
62
  return [`hub memory for ${project} on machine ${c.machine.label}: ${c.long} long-term, ${c.short} short-term`, ...(rows.length ? rows.map(one) : ["(nothing)"]),
61
63
  ...(withheld ? [`(${withheld} withheld: they match a PII pattern; the dashboard shows them)`] : [])].join("\n");
@@ -0,0 +1,22 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { chmodSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ export const MUTATION_BUSY_TIMEOUT_MS = 3000;
6
+ // ponytail: older hubs (before consuming this agent-mem), raw/legacy log writers and any segment editor
7
+ // that bypasses this lock are not serialized. Require every writer to take it, or make segment appends API-only.
8
+ /** An empty, machine-local SQLite file used only for crash-safe writer exclusion, never for memory data. */
9
+ export function mutationDatabase(dir: string): Database {
10
+ const file = join(dir, "mutation-lock.db"), db = new Database(file, { create: true });
11
+ chmodSync(file, 0o600);
12
+ db.run(`PRAGMA busy_timeout = ${MUTATION_BUSY_TIMEOUT_MS}`);
13
+ return db;
14
+ }
15
+
16
+ /** Sync holds a separate connection across awaits; an unrelated call on the same MemoryStore cannot bypass it. */
17
+ export function acquireMutation(dir: string): (() => void) | undefined {
18
+ const db = mutationDatabase(dir);
19
+ try { db.run("BEGIN IMMEDIATE"); }
20
+ catch { db.close(); return undefined; }
21
+ return () => { try { db.run("ROLLBACK"); } finally { db.close(); } };
22
+ }
@@ -3,6 +3,7 @@ import { createHash, randomBytes } from "node:crypto";
3
3
  import { appendFileSync, chmodSync, closeSync, existsSync, fstatSync, linkSync, 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
+ import { mutationDatabase } from "./mutation.ts";
6
7
  import { memoryHome } from "./home.ts";
7
8
 
8
9
  /**
@@ -173,15 +174,17 @@ export type PromotionScreen = (v: View) => "promote" | "keep" | "skip";
173
174
  * `screen`: applied to every promotion. Without one, automatic promotions (a settlement, a held note whose replacement
174
175
  * went) wait for a process that has one; a person's explicit promote goes through.
175
176
  */
176
- export interface StoreOptions { home?: string; ttlMs?: number; now?: () => number; log?: (line: string) => void; screen?: PromotionScreen }
177
+ export interface StoreOptions { home?: string; ttlMs?: number; now?: () => number; log?: (line: string) => void; screen?: PromotionScreen; deferPromotions?: boolean }
177
178
 
178
179
  export class MemoryStore {
179
180
  readonly dir: string;
180
181
  readonly machine: Machine;
181
182
  private readonly db: Database;
183
+ private readonly mutationDb: Database;
182
184
  private readonly ttl: number;
183
185
  private readonly now: () => number;
184
186
  private readonly log: (line: string) => void;
187
+ private readonly deferPromotions: boolean;
185
188
 
186
189
  constructor(opts: StoreOptions = {}) {
187
190
  this.dir = opts.home === undefined ? memoryHome() : join(opts.home, "memory");
@@ -193,6 +196,7 @@ export class MemoryStore {
193
196
  this.now = opts.now ?? Date.now;
194
197
  this.log = opts.log ?? (() => {});
195
198
  this.screen = opts.screen;
199
+ this.deferPromotions = opts.deferPromotions ?? false;
196
200
  const db = new Database(join(this.dir, "index.db"), { create: true });
197
201
  db.run(`PRAGMA busy_timeout = ${MEMORY_DB_BUSY_TIMEOUT_MS}`);
198
202
  db.run("PRAGMA journal_mode = WAL");
@@ -210,11 +214,51 @@ export class MemoryStore {
210
214
  db.run("CREATE TABLE IF NOT EXISTS edges (src TEXT NOT NULL, dst TEXT NOT NULL, PRIMARY KEY (src, dst))");
211
215
  db.run("CREATE INDEX IF NOT EXISTS edges_dst ON edges (dst)");
212
216
  this.db = db;
217
+ this.mutationDb = mutationDatabase(this.dir);
213
218
  try { chmodSync(join(this.dir, "index.db"), 0o600); } catch { /* best effort */ }
214
219
  this.refresh();
215
220
  }
216
221
 
217
- close(): void { this.db.close(); }
222
+ close(): void { this.db.close(); this.mutationDb.close(); }
223
+
224
+ /** A synchronous host-authorized mutation whose reads must not implicitly promote unrelated notes. */
225
+ withDeferredPromotions<T>(work: () => T): T {
226
+ const previous = this.promoting;
227
+ this.promoting = true;
228
+ try { return work(); } finally { this.promoting = previous; }
229
+ }
230
+
231
+ private withMutation<T>(work: () => T): T {
232
+ return this.mutationDb.transaction(work).immediate();
233
+ }
234
+
235
+ /** Serialize current-store writers, screen one normalized body, and expose a one-shot append of that body. */
236
+ withAcceptedNote<T>(note: NoteInput, work: (append: () => string) => T): T {
237
+ try { return this.withMutation(() => {
238
+ // Ordinary writes may defer indexing, but accepting an exact target revision must have a current index.
239
+ this.db.transaction(() => this.refresh()).immediate();
240
+ const at = new Date(this.now()).toISOString();
241
+ const normalized = structuredClone({ ...note, text: note.text.slice(0, MAX_TEXT),
242
+ ...(note.title ? { title: note.title.slice(0, MAX_TITLE) } : {}),
243
+ ...(note.tier === "short" && !note.expires ? { expires: new Date(Date.parse(at) + this.ttl).toISOString() } : {}) });
244
+ const body = { schema: MEMORY_SCHEMA, at, machine: this.machine.id, ...normalized } as const;
245
+ const view: View = { ...normalized, id: entryId(body), at, machine: body.machine, title: normalized.title ?? null,
246
+ task: normalized.task ?? null, about: [...normalized.about], supersededBy: null,
247
+ supersedes: normalized.supersedes ? [normalized.supersedes] : [], promotedFrom: normalized.promotedFrom ?? null,
248
+ expiresAt: normalized.expires ?? null, local: normalized.tier === "short" };
249
+ if (this.screen?.(view) !== "promote") throw new Error("candidate refused by current promotion screen");
250
+ let appended = false;
251
+ return work(() => {
252
+ if (appended) throw new Error("accepted note already appended");
253
+ appended = true;
254
+ return this.appendBody(body, normalized.tier === "short");
255
+ });
256
+ }); } catch (error) {
257
+ // A callback can fail after a durable JSONL append; ensure the derived index catches up.
258
+ this.stale = true;
259
+ throw error;
260
+ }
261
+ }
218
262
 
219
263
  /** When a short-term entry written now leaves the index. */
220
264
  private until(): string { return new Date(this.now() + this.ttl).toISOString(); }
@@ -223,12 +267,18 @@ export class MemoryStore {
223
267
  private append(entry: NoteInput | OpInput | GoalInput, local: boolean, at = new Date(this.now()).toISOString(), machine = this.machine.id): string {
224
268
  const capped = entry.op === "note" ? { ...entry, text: entry.text.slice(0, MAX_TEXT), ...(entry.title ? { title: entry.title.slice(0, MAX_TITLE) } : {}) } : entry;
225
269
  const body = { schema: MEMORY_SCHEMA, at, machine, ...capped } as Omit<Entry, "id">;
226
- const id = entryId(body);
227
- appendFileSync(join(this.dir, local ? "local" : "log", `${this.machine.id}.jsonl`), JSON.stringify({ ...body, id }) + "\n", { mode: 0o600 });
228
- // The line is written, so the entry is saved: an index that cannot be updated now (a busy database) catches up on the
229
- // next read, and the caller is never told it failed.
230
- try { this.refresh(); } catch (e) { this.stale = true; this.log(`hub memory index not updated yet: ${(e as Error).message}`); }
231
- return id;
270
+ return this.appendBody(body, local);
271
+ }
272
+
273
+ private appendBody(body: Omit<Entry, "id">, local: boolean): string {
274
+ // Lock the authoritative log independently: a busy derived index must never prevent an ordinary append.
275
+ return this.withMutation(() => {
276
+ const id = entryId(body);
277
+ appendFileSync(join(this.dir, local ? "local" : "log", `${this.machine.id}.jsonl`), JSON.stringify({ ...body, id }) + "\n", { mode: 0o600 });
278
+ // The line is durable even if indexing fails; the next refresh catches up from the authoritative log.
279
+ try { this.refresh(); } catch (e) { this.stale = true; this.log(`hub memory index not updated yet: ${(e as Error).message}`); }
280
+ return id;
281
+ });
232
282
  }
233
283
 
234
284
  /** A note: short-term ones stay on this machine, long-term ones go to the synced log. The caller has screened it. */
@@ -244,11 +294,13 @@ export class MemoryStore {
244
294
  // A fixed machine: the same observation imported on two machines is one entry once they sync.
245
295
  const body = { schema: MEMORY_SCHEMA, at, machine: "claude-mem", ...n, text: n.text.slice(0, MAX_TEXT), ...(n.title ? { title: n.title.slice(0, MAX_TITLE) } : {}) } as Omit<Entry, "id">;
246
296
  const id = entryId(body);
247
- // The source tuple also recognizes legacy ids whose nodes depended on the checkout or extractor version.
248
- const known = this.db.query("SELECT id FROM entries WHERE id = ? OR (source = 'claude-mem' AND project = ? AND at = ? AND text = ?)").get(id, n.project, at, n.text.slice(0, MAX_TEXT)) as { id: string } | null;
249
- if (known) return { id: known.id, added: false };
250
- appendFileSync(join(this.dir, n.tier === "short" ? "local" : "log", `${this.machine.id}.jsonl`), JSON.stringify({ ...body, id }) + "\n", { mode: 0o600 });
251
- return { id, added: true };
297
+ return this.withMutation(() => {
298
+ // The source tuple also recognizes legacy ids whose nodes depended on the checkout or extractor version.
299
+ const known = this.db.query("SELECT id FROM entries WHERE id = ? OR (source = 'claude-mem' AND project = ? AND at = ? AND text = ?)").get(id, n.project, at, n.text.slice(0, MAX_TEXT)) as { id: string } | null;
300
+ if (known) return { id: known.id, added: false };
301
+ appendFileSync(join(this.dir, n.tier === "short" ? "local" : "log", `${this.machine.id}.jsonl`), JSON.stringify({ ...body, id }) + "\n", { mode: 0o600 });
302
+ return { id, added: true };
303
+ });
252
304
  }
253
305
 
254
306
  /** A person's or the hub's op on an entry; it goes where the entry lives, so forgetting a long-term entry syncs. */
@@ -368,7 +420,7 @@ export class MemoryStore {
368
420
  * settlement a crash cut short, or a replacement that was forgotten or expired).
369
421
  */
370
422
  private promoteHeld(): void {
371
- if (this.promoting) return;
423
+ if (this.promoting || this.deferPromotions) return;
372
424
  this.promoting = true;
373
425
  try {
374
426
  const settled = new Set((this.db.query("SELECT target FROM ops WHERE op = 'settle'").all() as { target: string }[]).map((r) => r.target));
@@ -487,11 +539,20 @@ export class MemoryStore {
487
539
 
488
540
  /**
489
541
  * Every live note with its derived state; an expired one is left out as soon as its deadline passes, before any
490
- * prune. ponytail: computed in memory over all rows; push the filters into SQL when the store grows.
542
+ * prune. Lifecycle relations still fold globally; SQL selects full rows before View/JSON materialization.
543
+ * ponytail: lifecycle metadata is still scanned on every read; measure before adding a revision cache.
491
544
  */
492
- private views(expired = false): View[] {
493
- const now = this.now();
494
- const rows = this.db.query("SELECT * FROM entries").all() as Record<string, string | null>[];
545
+ private views(expired = false, selection?: Filter, expiredOnly = false): View[] {
546
+ // Metadata, ops and candidate bodies must describe one WAL snapshot, even if another process indexes a copy.
547
+ return this.db.transaction(() => this.viewRows(expired, selection, expiredOnly)).deferred();
548
+ }
549
+
550
+ private viewRows(expired: boolean, selection: Filter | undefined, expiredOnly: boolean): View[] {
551
+ const now = this.now(), selecting = selection !== undefined || expiredOnly;
552
+ // Relations may cross projects and promoted copies: fold their small metadata globally, never filtered bodies.
553
+ const rows = this.db.query(selecting
554
+ ? "SELECT id, at, tier, kind, task, expires, supersedes, promoted_from FROM entries"
555
+ : "SELECT * FROM entries").all() as Record<string, string | null>[];
495
556
  const ops = this.db.query("SELECT op, at, target, by FROM ops ORDER BY at, id").all() as { op: string; at: string; target: string; by: string | null }[];
496
557
  const forgotten = new Set(ops.filter((o) => o.op === "forget").map((o) => o.target));
497
558
  // Deadlines were fixed when the entries were written, so every process (whatever its own TTL) prunes the same rows.
@@ -550,8 +611,37 @@ export class MemoryStore {
550
611
  }
551
612
  const supersedes = new Map<string, string[]>(); // the reverse: what each newer entry replaced
552
613
  for (const [old, newer] of superseded) supersedes.set(newer, [...(supersedes.get(newer) ?? []), old]);
614
+ let candidates = rows;
615
+ if (selecting) {
616
+ const hidden: string[] = [], expiredIds: string[] = [];
617
+ for (const r of rows) {
618
+ const id = r.id!;
619
+ if (forgotten.has(id) || (r.promoted_from && forgotten.has(r.promoted_from)) || promoted.has(id)) { hidden.push(id); continue; }
620
+ const at = expiry(r);
621
+ if (at !== null && Date.parse(at) <= now) expiredIds.push(id);
622
+ }
623
+ const where: string[] = [], bind: string[] = [];
624
+ const ids = (values: Iterable<string>, exclude: boolean) => {
625
+ const entries = [...values];
626
+ if (entries.length || !exclude) { where.push(`id ${exclude ? "NOT IN" : "IN"} (SELECT value FROM json_each(?))`); bind.push(JSON.stringify(entries)); }
627
+ };
628
+ ids(hidden, true);
629
+ if (expiredOnly) ids(expiredIds, false); else if (!expired) ids(expiredIds, true);
630
+ if (selection) {
631
+ if (selection.project) { where.push("(project = ? OR scope = 'shared')"); bind.push(selection.project); }
632
+ if (selection.tier) {
633
+ 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); }
634
+ else { where.push("tier = ?"); bind.push(selection.tier); }
635
+ }
636
+ for (const key of ["kind", "peer", "machine"] as const) if (selection[key]) { where.push(`${key} = ?`); bind.push(selection[key]!); }
637
+ if (!selection.includeSuperseded) ids(superseded.keys(), true);
638
+ }
639
+ // No SQL LIMIT: a host may screen the complete matching set before its own caps or visible counts.
640
+ // Date parsing, Unicode query matching, locale order and slice semantics stay in JavaScript.
641
+ candidates = this.db.query(`SELECT * FROM entries${where.length ? ` WHERE ${where.join(" AND ")}` : ""}`).all(...bind) as Record<string, string | null>[];
642
+ }
553
643
  const out: View[] = [];
554
- for (const r of rows) {
644
+ for (const r of candidates) {
555
645
  const id = r.id!;
556
646
  // A forgotten entry is gone, and so is its promoted copy's source or copy: forgetting either forgets both.
557
647
  if (forgotten.has(id) || (r.promoted_from && forgotten.has(r.promoted_from)) || promoted.has(id)) continue;
@@ -571,7 +661,7 @@ export class MemoryStore {
571
661
  */
572
662
  prune(): number {
573
663
  const now = this.now();
574
- const expired = this.views(true).filter((v) => v.expiresAt !== null && Date.parse(v.expiresAt) <= now);
664
+ const expired = this.views(true, undefined, true).filter((v) => v.expiresAt !== null && Date.parse(v.expiresAt) <= now);
575
665
  // A copy that expires (it was demoted) takes its whole hidden promotion ancestry with it, or an ancestor would reappear.
576
666
  const parent = new Map((this.db.query("SELECT id, promoted_from FROM entries WHERE promoted_from IS NOT NULL").all() as { id: string; promoted_from: string }[]).map((r) => [r.id, r.promoted_from]));
577
667
  const gone = new Set<string>();
@@ -591,9 +681,9 @@ export class MemoryStore {
591
681
  * What a read sees: rows that expired and promotions that became due while the store sat idle are settled first, so a
592
682
  * read, and the index after it, never differ from a rebuild.
593
683
  */
594
- private live(): View[] {
684
+ private live(selection?: Filter): View[] {
595
685
  this.ensureFresh();
596
- this.prune(); this.promoteHeld(); return this.views();
686
+ this.prune(); this.promoteHeld(); return this.views(false, selection);
597
687
  }
598
688
  /** A write whose index update failed: the next read refreshes first. */
599
689
  private stale = false;
@@ -601,7 +691,7 @@ export class MemoryStore {
601
691
  /** Entries visible from `project` (its own, and `shared` ones of any project), newest first, filtered. */
602
692
  list(f: Filter = {}): View[] {
603
693
  const q = f.q?.toLowerCase().split(/\s+/).filter(Boolean) ?? [];
604
- return this.live().filter((v) => (!f.project || v.project === f.project || v.scope === "shared") && (!f.tier || v.tier === f.tier) && (!f.kind || v.kind === f.kind)
694
+ return this.live(f).filter((v) => (!f.project || v.project === f.project || v.scope === "shared") && (!f.tier || v.tier === f.tier) && (!f.kind || v.kind === f.kind)
605
695
  && (!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)
606
696
  && q.every((w) => `${v.title ?? ""}\n${v.text}\n${v.about.join(" ")}`.toLowerCase().includes(w)))
607
697
  .sort((a, b) => b.at.localeCompare(a.at) || a.id.localeCompare(b.id)).slice(0, f.limit ?? 200);
@@ -1,6 +1,7 @@
1
1
  import { closeSync, existsSync, openSync, readFileSync, rmSync, writeFileSync, writeSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { memoryGitArgv, memoryGitEnv, processLiveness, processSignature } from "./platform.ts";
4
+ import { acquireMutation } from "./mutation.ts";
4
5
  import type { MemoryStore } from "./store.ts";
5
6
 
6
7
  /**
@@ -78,7 +79,10 @@ export async function syncMemory(store: MemoryStore, opts: SyncOptions = {}): Pr
78
79
  if (!opts.remote && !existsSync(join(dir, ".git"))) return fail(noRemote); // never set up: nothing to do, nothing created
79
80
  const release = lock(dir);
80
81
  if (!release) return fail("another sync is running on this machine");
82
+ let releaseMutation: (() => void) | undefined;
81
83
  try {
84
+ releaseMutation = acquireMutation(dir);
85
+ if (!releaseMutation) return fail("another memory mutation is running on this machine");
82
86
  // Holding sync.lock, no other git runs in this repository: what a killed one left behind is stale.
83
87
  rmSync(join(dir, ".git", "index.lock"), { force: true });
84
88
  if (existsSync(join(dir, ".git", "MERGE_HEAD"))) await git(dir, ["merge", "--abort"]);
@@ -136,9 +140,11 @@ export async function syncMemory(store: MemoryStore, opts: SyncOptions = {}): Pr
136
140
  pushed = true;
137
141
  }
138
142
  if (opts.signal?.aborted) return fail("stopped");
139
- store.refresh();
140
- return { ok: true, pushed, entries: store.counts().long, ...(warning ? { warning } : {}) };
143
+ return store.withDeferredPromotions(() => {
144
+ store.refresh();
145
+ return { ok: true, pushed, entries: store.counts().long, ...(warning ? { warning } : {}) };
146
+ });
141
147
  } catch (e) {
142
148
  return fail((e as Error).message.slice(0, 200));
143
- } finally { release(); }
149
+ } finally { try { releaseMutation?.(); } finally { release(); } }
144
150
  }