@staix/agent-mem 0.6.0 → 0.8.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/marketplace.json +13 -0
- package/.claude-plugin/plugin.json +12 -2
- package/README.md +143 -18
- package/package.json +1 -1
- package/src/cli.ts +537 -39
- package/src/commands-table.ts +60 -16
- package/src/hook-spool.ts +24 -2
- package/src/hooks.ts +9 -5
- package/src/index.ts +20 -0
- package/src/ingest.ts +3 -2
- package/src/mcp-memory.ts +168 -33
- package/src/mcp.ts +43 -7
- package/src/memory/aliases.ts +4 -0
- package/src/memory/bridge.ts +8 -2
- package/src/memory/candidate-pending.ts +119 -0
- package/src/memory/candidates.ts +228 -47
- package/src/memory/capacity-split.ts +76 -0
- package/src/memory/capacity.ts +139 -11
- package/src/memory/capture-input.ts +8 -3
- package/src/memory/capture.ts +30 -5
- package/src/memory/catalog.ts +43 -10
- package/src/memory/commands.ts +98 -44
- package/src/memory/config.ts +167 -0
- package/src/memory/consolidate.ts +78 -0
- package/src/memory/consolidation-clusters.ts +56 -0
- package/src/memory/consolidation-model.ts +97 -0
- package/src/memory/cross-project.ts +75 -0
- package/src/memory/curated-read.ts +29 -0
- package/src/memory/decay-review.ts +136 -0
- package/src/memory/doctor.ts +415 -0
- package/src/memory/editor.ts +58 -0
- package/src/memory/eval-fixture.ts +145 -0
- package/src/memory/eval-set-ko.json +90 -0
- package/src/memory/eval-set.json +443 -0
- package/src/memory/eval.ts +152 -0
- package/src/memory/evidence.ts +76 -0
- package/src/memory/external-import.ts +267 -0
- package/src/memory/gc.ts +97 -0
- package/src/memory/generation-index.ts +22 -17
- package/src/memory/generation-sync.ts +3 -0
- package/src/memory/goal-promotion.ts +71 -0
- package/src/memory/home.ts +15 -4
- package/src/memory/intent-brief.ts +89 -0
- package/src/memory/lifecycle-log.ts +54 -0
- package/src/memory/lifecycle.ts +61 -0
- package/src/memory/metrics.ts +2 -2
- package/src/memory/note-add.ts +100 -0
- package/src/memory/path-glob.ts +94 -0
- package/src/memory/plan-guard.ts +47 -0
- package/src/memory/platform.ts +12 -1
- package/src/memory/policy.ts +44 -13
- package/src/memory/private-spans.ts +32 -0
- package/src/memory/project-export.ts +157 -0
- package/src/memory/project-import.ts +147 -0
- package/src/memory/recall-staleness.ts +56 -0
- package/src/memory/recall.ts +74 -0
- package/src/memory/repo-scope.ts +404 -0
- package/src/memory/shard-sync-format.ts +116 -0
- package/src/memory/shard-sync.ts +421 -0
- package/src/memory/store.ts +344 -35
- package/src/memory/sync-cadence.ts +149 -0
- package/src/memory/sync-screen.ts +86 -0
- package/src/memory/sync.ts +91 -11
- package/src/memory/usage.ts +35 -0
- package/src/memory/use-telemetry.ts +51 -0
- package/src/memory/work-lists.ts +10 -4
- package/src/session-brief-worker.ts +3 -2
- package/src/session-brief.ts +74 -15
- package/src/setup-owned.ts +120 -0
- package/src/setup-toml.ts +296 -0
- package/src/setup.ts +327 -0
package/src/memory/candidates.ts
CHANGED
|
@@ -2,11 +2,16 @@ import { Database } from "bun:sqlite";
|
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { chmodSync, mkdirSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { CANDIDATE_SHOWN_GRACE_MS, hasShownColumn } from "./candidate-pending.ts";
|
|
6
|
+
import { oneLine } from "./note-add.ts";
|
|
5
7
|
import { memoryHome } from "./home.ts";
|
|
6
8
|
import { realPath } from "./platform.ts";
|
|
7
|
-
import {
|
|
9
|
+
import { stripPrivateDeep } from "./private-spans.ts";
|
|
10
|
+
import { MemoryStore, type GoalView, type NoteInput, type View } from "./store.ts";
|
|
8
11
|
|
|
9
12
|
export const CANDIDATE_DB_BUSY_TIMEOUT_MS = 3000;
|
|
13
|
+
/** First line of every note written by accepting a consolidation candidate; `consolidationClusters` reads it to avoid proposing summaries of its own summaries. */
|
|
14
|
+
export const ACCEPTED_CONSOLIDATION_PREFIX = "Accepted consolidation candidate: ";
|
|
10
15
|
export const CANDIDATE_RETENTION = { count: 500, ageMs: 30 * 86400_000 } as const;
|
|
11
16
|
export const COUNTER_RETENTION = { count: 10_000, ageMs: 90 * 86400_000 } as const;
|
|
12
17
|
export type CandidateVerdict = "public" | "private" | "pii" | "unknown";
|
|
@@ -16,6 +21,9 @@ export type CandidateInput = { project: string } & (
|
|
|
16
21
|
| { kind: "fail"; target?: CandidateTarget; approach: string; symptom: string; cause: string; evidenceRefs: string[]; retryWhen: string }
|
|
17
22
|
| { kind: "memory"; text: string; title?: string; noteKind?: "decision" | "finding" | "contract" | "fail" }
|
|
18
23
|
| { kind: "decay"; target: CandidateTarget; reason: string }
|
|
24
|
+
| { kind: "promotion"; target: CandidateTarget; goalId: string }
|
|
25
|
+
| { kind: "consolidation"; action: "merge" | "supersession" | "recurring-fix"; targets: CandidateTarget[]; text: string; about: string[]; noteKind: "decision" | "finding" | "contract" | "fail" }
|
|
26
|
+
| { kind: "model-answer"; marker: "model-answer"; targets: CandidateTarget[]; text: string }
|
|
19
27
|
);
|
|
20
28
|
export type CandidateStatus = "pending" | "accepting" | "accepted" | "rejected";
|
|
21
29
|
export interface Candidate { id: string; createdAt: string; status: CandidateStatus; input: CandidateInput; acceptedNoteId: string | null }
|
|
@@ -29,7 +37,9 @@ export interface CandidateAcceptance {
|
|
|
29
37
|
actor: "person" | "agent";
|
|
30
38
|
verdict: CandidateVerdict;
|
|
31
39
|
store: MemoryStore;
|
|
32
|
-
policy: (candidate: Candidate, target: View | undefined, note: NoteInput) => boolean;
|
|
40
|
+
policy: (candidate: Candidate, target: View | undefined, note: NoteInput, goal?: GoalView) => boolean;
|
|
41
|
+
/** Required for multi-source consolidation; must change with the effective host policy. */
|
|
42
|
+
policyVersion?: () => string;
|
|
33
43
|
}
|
|
34
44
|
export interface CandidateAcceptanceResult { candidateId: string; noteId: string; alreadyAccepted: boolean }
|
|
35
45
|
|
|
@@ -53,12 +63,35 @@ function validate(input: CandidateInput): void {
|
|
|
53
63
|
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");
|
|
54
64
|
} else if (input.kind === "memory") {
|
|
55
65
|
if (!text(input.text, 3000) || input.title !== undefined && !text(input.title, 200) || input.noteKind !== undefined && !["decision", "finding", "contract", "fail"].includes(input.noteKind)) throw new Error("invalid memory candidate");
|
|
66
|
+
} else if (input.kind === "promotion") {
|
|
67
|
+
if (!validTarget(input.target) || !entryId(input.goalId)) throw new Error("invalid promotion candidate");
|
|
68
|
+
} else if (input.kind === "consolidation") {
|
|
69
|
+
if (!["merge", "supersession", "recurring-fix"].includes(input.action) || !Array.isArray(input.targets) || input.targets.length < 1 || input.targets.length > 8 || !input.targets.every(validTarget)
|
|
70
|
+
|| new Set(input.targets.map(target => target.id)).size !== input.targets.length || !text(input.text, 3000) || !["decision", "finding", "contract", "fail"].includes(input.noteKind)
|
|
71
|
+
|| !Array.isArray(input.about) || input.about.length > 32 || !input.about.every(node => text(node, 400))) throw Error("invalid consolidation candidate");
|
|
72
|
+
} else if (input.kind === "model-answer") {
|
|
73
|
+
if (input.marker !== "model-answer" || !text(input.text, 3000) || !input.text.startsWith("Model answer:\n") || !input.text.slice("Model answer:\n".length).trim() || !Array.isArray(input.targets) || input.targets.length < 1 || input.targets.length > 8 || !input.targets.every(validTarget)
|
|
74
|
+
|| new Set(input.targets.map(target => target.id)).size !== input.targets.length) throw Error("invalid model answer candidate");
|
|
56
75
|
} else if (input.kind === "decay") {
|
|
57
76
|
if (!validTarget(input.target) || !text(input.reason)) throw new Error("invalid decay candidate");
|
|
58
77
|
} else throw new Error("invalid candidate kind");
|
|
59
78
|
if (Buffer.byteLength(canonical(input), "utf8") > 16_384) throw new Error("candidate payload exceeds 16 KiB");
|
|
60
79
|
}
|
|
61
80
|
|
|
81
|
+
/** The same normalized identity used by put(), without a sidecar write or a capped listing. */
|
|
82
|
+
export function candidateInputId(rawInput: CandidateInput): string {
|
|
83
|
+
const input = stripPrivateDeep(rawInput); validate(input);
|
|
84
|
+
return hash({ schema: "agent-mem.candidate/v1", input });
|
|
85
|
+
}
|
|
86
|
+
/** Exact correction body shared by proposal validation and person acceptance. */
|
|
87
|
+
export function acceptedCorrectionText(input: Extract<CandidateInput, { kind: "correction" }>, id = candidateInputId(input)): string {
|
|
88
|
+
const data = stripPrivateDeep(input);
|
|
89
|
+
return `Accepted candidate: ${id}\nTarget revision: ${data.target.revision}\nTarget entry: ${data.target.id}\n\n${data.proposedText}\n\nReason: ${data.reason}`;
|
|
90
|
+
}
|
|
91
|
+
export function goalPromotionLinked(view: Pick<View, "task" | "about">, goal: Pick<GoalView, "id" | "links">): boolean {
|
|
92
|
+
const tasks = new Set(goal.links.map(link => link.task));
|
|
93
|
+
return !!view.task && tasks.has(view.task) || view.about.some(node => node === `goal:${goal.id}` || node.startsWith("task:") && tasks.has(node.slice(5)));
|
|
94
|
+
}
|
|
62
95
|
/** Includes current lifecycle state, so a changed promotion, supersession or demotion makes a proposal stale. */
|
|
63
96
|
export function candidateTargetRevision(target: View): string {
|
|
64
97
|
return hash({ ...target, about: [...target.about].sort(), supersedes: [...target.supersedes].sort() });
|
|
@@ -71,6 +104,23 @@ function counterKey(key: CounterKey): CounterKey {
|
|
|
71
104
|
}
|
|
72
105
|
|
|
73
106
|
/** Explicitly opened machine-local data. Importing this module never opens a store or registers a consumer. */
|
|
107
|
+
/** Run `work`, retrying only SQLite's "database is locked" for at most about two seconds; any other error, or the last lock error, is thrown. */
|
|
108
|
+
function retryLocked(work: () => void): void {
|
|
109
|
+
const until = Date.now() + 2000;
|
|
110
|
+
for (let delay = 5;; delay = Math.min(delay * 2, 100)) {
|
|
111
|
+
try { work(); return; }
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (!/database is locked|SQLITE_BUSY/i.test((error as Error).message) || Date.now() >= until) throw error;
|
|
114
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The queue holds the cap's worth of candidates nobody has seen. Named so a caller (the MCP tool) can map it; the message is worded for an agent, which cannot run the person-only commands itself. */
|
|
120
|
+
export class CandidateQueueFull extends Error {
|
|
121
|
+
readonly code = "candidate_queue_full";
|
|
122
|
+
constructor(readonly waiting: number) { super(`${waiting} unshown candidates await the person's review; no more are accepted until they have run \`amem review\`. Ask the person to review them`); this.name = "CandidateQueueFull"; }
|
|
123
|
+
}
|
|
74
124
|
export class CandidateStore {
|
|
75
125
|
readonly dir: string;
|
|
76
126
|
readonly path: string;
|
|
@@ -88,18 +138,27 @@ export class CandidateStore {
|
|
|
88
138
|
this.path = join(this.dir, "candidates.db");
|
|
89
139
|
this.db = new Database(this.path, { create: true });
|
|
90
140
|
this.db.run(`PRAGMA busy_timeout = ${CANDIDATE_DB_BUSY_TIMEOUT_MS}`);
|
|
91
|
-
chmodSync(this.path, 0o600);
|
|
141
|
+
chmodSync(this.path, 0o600);
|
|
142
|
+
// `journal_mode = WAL` takes a lock that busy_timeout does not always wait for, so several processes opening a brand-new home at once (every `amem mcp` builds this store at
|
|
143
|
+
// startup) can see "database is locked". It is retried for a bounded time: the first caller switches the mode, the others then find it already WAL.
|
|
144
|
+
retryLocked(() => this.db.run("PRAGMA journal_mode = WAL"));
|
|
92
145
|
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)");
|
|
146
|
+
// F7 / AC4: when a candidate was first shown to the person. In place and nullable: an older database gains it here, and a reader without it treats every row as unshown.
|
|
147
|
+
// Several processes open this store at once (every `amem mcp` builds it at startup), so the check and the ALTER are one immediate transaction: the loser waits, then sees the column.
|
|
148
|
+
if (!hasShownColumn(this.db)) this.db.transaction(() => { if (!hasShownColumn(this.db)) this.db.run("ALTER TABLE candidates ADD COLUMN shown_at INTEGER"); }).immediate();
|
|
93
149
|
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)");
|
|
94
150
|
this.prune();
|
|
95
151
|
}
|
|
96
152
|
close(): void { this.db.close(); }
|
|
97
153
|
private prune(): void {
|
|
98
154
|
this.db.transaction(() => {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
155
|
+
const now = this.now();
|
|
156
|
+
// Candidates (AC4, no candidate expires unshown): by age only a decided one, or a pending one that was shown and has had its grace period, goes. By count three tiers decide who is
|
|
157
|
+
// kept: unshown pending first, then shown pending, then decided, so a cap eviction takes decided rows before a shown one (which is owed its grace) and shown ones before an unseen one.
|
|
158
|
+
this.db.query("DELETE FROM candidates WHERE at <= ? AND (status IN ('accepted', 'rejected') OR (shown_at IS NOT NULL AND shown_at <= ?))").run(now - CANDIDATE_RETENTION.ageMs, now - CANDIDATE_SHOWN_GRACE_MS);
|
|
159
|
+
this.db.query(`DELETE FROM candidates WHERE rowid IN (SELECT rowid FROM (SELECT rowid, row_number() OVER (PARTITION BY project ORDER BY CASE WHEN status IN ('pending', 'accepting') AND shown_at IS NULL THEN 2 WHEN status IN ('pending', 'accepting') THEN 1 ELSE 0 END DESC, at DESC, rowid DESC) AS rank FROM candidates) WHERE rank > ?)`).run(CANDIDATE_RETENTION.count);
|
|
160
|
+
this.db.query("DELETE FROM counters WHERE at <= ?").run(now - COUNTER_RETENTION.ageMs);
|
|
161
|
+
this.db.query("DELETE FROM counters WHERE rowid IN (SELECT rowid FROM (SELECT rowid, row_number() OVER (PARTITION BY project ORDER BY at DESC, rowid DESC) AS rank FROM counters) WHERE rank > ?)").run(COUNTER_RETENTION.count);
|
|
103
162
|
}).immediate();
|
|
104
163
|
}
|
|
105
164
|
private decode(row: { id: string; payload: string; at: number; status: CandidateStatus; note_id: string | null }): Candidate {
|
|
@@ -107,11 +166,22 @@ export class CandidateStore {
|
|
|
107
166
|
if (hash({ schema: "agent-mem.candidate/v1", input }) !== row.id) throw new Error("candidate content hash mismatch");
|
|
108
167
|
return { id: row.id, createdAt: new Date(row.at).toISOString(), status: row.status, input, acceptedNoteId: row.note_id };
|
|
109
168
|
}
|
|
110
|
-
put(
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
169
|
+
put(rawInput: CandidateInput, verdict: CandidateVerdict): string {
|
|
170
|
+
// `<private>` spans are never stored: dropped before the input is validated, hashed (so the id) or written.
|
|
171
|
+
const input = stripPrivateDeep(rawInput);
|
|
172
|
+
publicOnly(verdict); validate(input);
|
|
173
|
+
const payload = canonical(input), id = candidateInputId(input);
|
|
174
|
+
// Prune, count, insert and prune again are ONE immediate transaction: concurrent proposers (several agents) cannot all pass the count and then let the cap evict an unseen candidate.
|
|
175
|
+
return this.db.transaction((): string => {
|
|
176
|
+
this.prune();
|
|
177
|
+
// AC4: a new candidate never pushes an unshown one out. With the cap's worth waiting the person has to review first, and the refusal says so (a named error an MCP tool can map).
|
|
178
|
+
const waiting = (this.db.query("SELECT COUNT(*) AS n FROM candidates WHERE project = ? AND status IN ('pending', 'accepting') AND shown_at IS NULL").get(input.project) as { n: number }).n;
|
|
179
|
+
const known = this.db.query("SELECT 1 FROM candidates WHERE id = ?").get(id);
|
|
180
|
+
if (!known && waiting >= CANDIDATE_RETENTION.count) throw new CandidateQueueFull(waiting);
|
|
181
|
+
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());
|
|
182
|
+
this.prune();
|
|
183
|
+
return id;
|
|
184
|
+
}).immediate();
|
|
115
185
|
}
|
|
116
186
|
get(id: string, project: string): Candidate | undefined {
|
|
117
187
|
if (!candidateId(id) || !text(project, 400)) throw new Error("invalid candidate identity");
|
|
@@ -120,7 +190,7 @@ export class CandidateStore {
|
|
|
120
190
|
return row ? this.decode(row) : undefined;
|
|
121
191
|
}
|
|
122
192
|
list(filter: CandidateFilter): Candidate[] {
|
|
123
|
-
if (!text(filter.project, 400) || filter.status !== undefined && !["pending", "accepting", "accepted", "rejected", "all"].includes(filter.status) || filter.kind !== undefined && !["correction", "fail", "decay", "memory"].includes(filter.kind)) throw new Error("invalid candidate filter");
|
|
193
|
+
if (!text(filter.project, 400) || filter.status !== undefined && !["pending", "accepting", "accepted", "rejected", "all"].includes(filter.status) || filter.kind !== undefined && !["correction", "fail", "decay", "memory", "promotion", "consolidation", "model-answer"].includes(filter.kind)) throw new Error("invalid candidate filter");
|
|
124
194
|
const limit = filter.limit ?? CANDIDATE_RETENTION.count;
|
|
125
195
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > CANDIDATE_RETENTION.count) throw new Error("candidate limit must be 1..500");
|
|
126
196
|
this.prune();
|
|
@@ -163,42 +233,153 @@ export class CandidateStore {
|
|
|
163
233
|
if (!candidate) throw new Error("candidate unavailable");
|
|
164
234
|
if (candidate.status === "accepted") return { candidateId: id, noteId: candidate.acceptedNoteId!, alreadyAccepted: true };
|
|
165
235
|
if (candidate.status !== "pending") throw new Error("candidate is rejected or acceptance was interrupted; no automatic replay");
|
|
166
|
-
|
|
236
|
+
const data = candidate.input;
|
|
237
|
+
if (data.kind === "model-answer") throw Error("model answers cannot become curated memory; use amem note add to author an independent note");
|
|
238
|
+
if (data.kind === "decay") throw new Error("decay candidates are listing-only; no automatic mutation is available");
|
|
239
|
+
if (data.kind === "promotion") return this.acceptPromotion(candidate, data, context);
|
|
240
|
+
if (data.kind === "consolidation") return this.acceptConsolidation(candidate, data, context);
|
|
241
|
+
const execute = () => {
|
|
242
|
+
const resolve = () => {
|
|
243
|
+
const target = data.kind === "memory" ? undefined : data.target;
|
|
244
|
+
if (!target) return undefined;
|
|
245
|
+
const current = context.store.get(target.id, context.project);
|
|
246
|
+
if (!current || current.id !== target.id || candidateTargetRevision(current) !== target.revision) throw new Error("candidate target revision is stale");
|
|
247
|
+
return current;
|
|
248
|
+
};
|
|
249
|
+
const target = resolve();
|
|
250
|
+
const body = data.kind === "memory" ? data.text : data.kind === "correction" ? `${data.proposedText}\n\nReason: ${data.reason}`
|
|
251
|
+
: `Approach: ${oneLine(data.approach)}\nSymptom: ${oneLine(data.symptom)}\nCause: ${oneLine(data.cause)}\nEvidence: ${data.evidenceRefs.map(oneLine).join(", ")}\nRetry when: ${oneLine(data.retryWhen)}`;
|
|
252
|
+
const note: NoteInput = { op: "note", ...(data.kind === "memory" && data.title ? { title: data.title } : {}), project: data.kind === "correction" ? target!.project : context.project,
|
|
253
|
+
scope: data.kind === "correction" ? target!.scope : "project",
|
|
254
|
+
// A demoted synced target is logically short, but replacing it locally would narrow its reach (X04).
|
|
255
|
+
tier: data.kind === "correction" && target!.local ? "short" : "long",
|
|
256
|
+
kind: data.kind === "fail" ? "fail" : data.noteKind ?? (target?.kind === "contract" || target?.kind === "fail" ? target.kind : "decision"), peer: "person", source: "person", about: [...target?.about ?? []],
|
|
257
|
+
text: data.kind === "correction" ? acceptedCorrectionText(data, id) : `Accepted candidate: ${id}\nTarget revision: ${(data.kind === "memory" ? undefined : data.target)?.revision ?? "none"}\nTarget entry: ${(data.kind === "memory" ? undefined : data.target)?.id ?? "none"}\n\n${body}` };
|
|
258
|
+
if (note.text.length > 4000) throw new Error("accepted candidate text exceeds the v1 note limit");
|
|
259
|
+
if (context.policy(structuredClone(candidate), target && structuredClone(target), structuredClone(note)) !== true) throw new Error("candidate refused by current host policy");
|
|
260
|
+
return context.store.withAcceptedNote(note, append => {
|
|
261
|
+
resolve(); // Recheck under the same mutation lock as the note and supersede appends.
|
|
262
|
+
const started = this.beginAcceptance(id, context.project);
|
|
263
|
+
if (started) return { candidateId: id, noteId: started, alreadyAccepted: true };
|
|
264
|
+
// ponytail: the sidecar receipt and v1 append are not one transaction. An interrupted 'accepting' row refuses
|
|
265
|
+
// automatic replay; add explicit person-audited recovery before offering background acceptance retries.
|
|
266
|
+
const noteId = append();
|
|
267
|
+
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");
|
|
268
|
+
this.db.query("UPDATE candidates SET status = 'accepted', note_id = ? WHERE id = ? AND project IN (SELECT value FROM json_each(?))").run(noteId, id, this.scope(context.project));
|
|
269
|
+
return { candidateId: id, noteId, alreadyAccepted: false };
|
|
270
|
+
});
|
|
271
|
+
};
|
|
272
|
+
const target = data.kind === "memory" ? undefined : data.target;
|
|
273
|
+
if (!target) return execute();
|
|
274
|
+
const current = context.store.get(target.id, context.project);
|
|
275
|
+
if (!current || current.id !== target.id || candidateTargetRevision(current) !== target.revision) throw new Error("candidate target revision is stale");
|
|
276
|
+
return context.store.withCurrentEntries([target.id], context.project, execute);
|
|
277
|
+
}
|
|
278
|
+
private acceptConsolidation(candidate: Candidate, data: Extract<CandidateInput, { kind: "consolidation" }>, context: CandidateAcceptance): CandidateAcceptanceResult {
|
|
279
|
+
if (typeof context.policyVersion !== "function") throw Error("consolidation requires a policy revision guard");
|
|
280
|
+
const boundStore = context.store, boundProject = context.project, boundPolicy = context.policy;
|
|
281
|
+
const policyVersion = context.policyVersion(); if (typeof policyVersion !== "string") throw Error("consolidation policy revision unavailable");
|
|
282
|
+
return context.store.withCurrentEntries(data.targets.map(target => target.id), context.project, () => {
|
|
283
|
+
const sourcePlacements = context.store.entryPlacements(data.targets.map(target => target.id), context.project);
|
|
284
|
+
if (data.targets.some(target => sourcePlacements.get(target.id) !== "synced")) throw Error("consolidation requires verified synced source placement");
|
|
285
|
+
const policyCurrent = () => {
|
|
286
|
+
if (context.actor !== "person" || context.store !== boundStore || context.project !== boundProject || context.policy !== boundPolicy) throw Error("consolidation acceptance context changed");
|
|
287
|
+
if (context.policyVersion!() !== policyVersion) throw Error("consolidation policy changed");
|
|
288
|
+
};
|
|
289
|
+
// One placement proof per locked section: `sourcePlacements` (read once inside withCurrentEntries, whose fences stay held) serves every check before the append section;
|
|
290
|
+
// the append section reads one fresh proof. Each proof re-reads the legacy snapshot (up to 32 MiB), so four per acceptance was the cost this removes.
|
|
291
|
+
const check = (fresh = false) => {
|
|
292
|
+
policyCurrent();
|
|
293
|
+
const current = data.targets.map(target => {
|
|
294
|
+
const row = context.store.get(target.id, context.project);
|
|
295
|
+
if (!row || row.id !== target.id || row.project !== context.project || row.supersededBy || candidateTargetRevision(row) !== target.revision) throw Error("consolidation source revision is stale");
|
|
296
|
+
return row;
|
|
297
|
+
});
|
|
298
|
+
const placements = fresh ? context.store.entryPlacements(data.targets.map(target => target.id), context.project) : sourcePlacements;
|
|
299
|
+
if (current.some(row => row.scope !== current[0]!.scope || placements.get(row.id) !== placements.get(current[0]!.id))) throw Error("consolidation sources must have the same placement and scope");
|
|
300
|
+
if (data.targets.some(target => placements.get(target.id) !== sourcePlacements.get(target.id))) throw Error("consolidation source placement changed");
|
|
301
|
+
policyCurrent(); // The proof scan can be long; fence policy changes after it, not only before it.
|
|
302
|
+
return current;
|
|
303
|
+
};
|
|
304
|
+
const sources = check(), first = sources[0]!;
|
|
305
|
+
const note: NoteInput = { op: "note", project: context.project, scope: first.scope, tier: "long", kind: data.noteKind, peer: "person", source: "person", about: [...data.about],
|
|
306
|
+
text: `${ACCEPTED_CONSOLIDATION_PREFIX}${candidate.id}\nAction: ${data.action}\nSources: ${data.targets.map(target => `mem:${target.id}`).join(", ")}\n\n${data.text}` };
|
|
307
|
+
if (note.text.length > 4000) throw Error("accepted consolidation exceeds the v1 note limit");
|
|
308
|
+
for (const source of sources) if (context.policy(structuredClone(candidate), structuredClone(source), structuredClone(note)) !== true) throw Error("consolidation refused by current host policy");
|
|
309
|
+
check();
|
|
310
|
+
return context.store.withAcceptedNote(note, append => {
|
|
311
|
+
check(true);
|
|
312
|
+
const replay = this.beginAcceptance(candidate.id, context.project, policyCurrent); if (replay) return { candidateId: candidate.id, noteId: replay, alreadyAccepted: true };
|
|
313
|
+
const noteId = append(), applied: string[] = [];
|
|
314
|
+
try {
|
|
315
|
+
if (data.action !== "recurring-fix") for (const target of data.targets) {
|
|
316
|
+
if (!context.store.supersede(target.id, noteId, "person", context.project)) throw Error("supersession refused");
|
|
317
|
+
applied.push(target.id);
|
|
318
|
+
}
|
|
319
|
+
} catch {
|
|
320
|
+
// ponytail: v1 appends cannot form an atomic multi-source replacement. Keep 'accepting' and
|
|
321
|
+
// report every confirmed effect; recovery requires person inspection before any replay.
|
|
322
|
+
throw Error(`consolidation interrupted: inspect note ${noteId}; superseded ${applied.join(", ") || "none"}; remaining or uncertain ${data.targets.filter(target => !applied.includes(target.id)).map(target => target.id).join(", ")}`);
|
|
323
|
+
}
|
|
324
|
+
this.db.query("UPDATE candidates SET status = 'accepted', note_id = ? WHERE id = ? AND project IN (SELECT value FROM json_each(?))").run(noteId, candidate.id, this.scope(context.project));
|
|
325
|
+
return { candidateId: candidate.id, noteId, alreadyAccepted: false };
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
private acceptPromotion(candidate: Candidate, data: Extract<CandidateInput, { kind: "promotion" }>, context: CandidateAcceptance): CandidateAcceptanceResult {
|
|
167
330
|
const resolve = () => {
|
|
168
|
-
const target =
|
|
169
|
-
if (!target)
|
|
170
|
-
const
|
|
171
|
-
if (!
|
|
172
|
-
return
|
|
331
|
+
const target = context.store.get(data.target.id, context.project);
|
|
332
|
+
if (!target || target.id !== data.target.id || candidateTargetRevision(target) !== data.target.revision || target.tier !== "short" || target.supersededBy) throw Error("candidate target revision is stale");
|
|
333
|
+
const goal = context.store.goalOf(data.goalId, context.project);
|
|
334
|
+
if (!goal || goal.id !== data.goalId || goal.state !== "done" || !goalPromotionLinked(target, goal)) throw Error("promotion requires a current done goal and a linked short note");
|
|
335
|
+
return { target, goal };
|
|
173
336
|
};
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
this.db.query("UPDATE candidates SET status = 'accepting' WHERE id = ? AND project IN (SELECT value FROM json_each(?))").run(id, this.scope(context.project));
|
|
193
|
-
return undefined;
|
|
194
|
-
}).immediate();
|
|
195
|
-
if (started) return { candidateId: id, noteId: started, alreadyAccepted: true };
|
|
196
|
-
// ponytail: the sidecar receipt and v1 append are not one transaction. An interrupted 'accepting' row refuses
|
|
197
|
-
// automatic replay; add explicit person-audited recovery before offering background acceptance retries.
|
|
198
|
-
const noteId = append();
|
|
199
|
-
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");
|
|
200
|
-
this.db.query("UPDATE candidates SET status = 'accepted', note_id = ? WHERE id = ? AND project IN (SELECT value FROM json_each(?))").run(noteId, id, this.scope(context.project));
|
|
201
|
-
return { candidateId: id, noteId, alreadyAccepted: false };
|
|
337
|
+
resolve();
|
|
338
|
+
return context.store.withCurrentEntries([data.target.id], context.project, () => {
|
|
339
|
+
const { target, goal } = resolve();
|
|
340
|
+
const note: NoteInput = { op: "note", project: target.project, scope: target.scope, tier: "long", kind: target.kind, peer: target.peer, source: target.source as NoteInput["source"], text: target.text, about: [...target.about],
|
|
341
|
+
...(target.title ? { title: target.title } : {}), ...(target.task ? { task: target.task } : {}), promotedFrom: target.id };
|
|
342
|
+
if (context.policy(structuredClone(candidate), structuredClone(target), structuredClone(note), structuredClone(goal)) !== true) throw Error("candidate refused by current host policy");
|
|
343
|
+
resolve();
|
|
344
|
+
const replay = this.beginAcceptance(candidate.id, context.project); if (replay) return { candidateId: candidate.id, noteId: replay, alreadyAccepted: true };
|
|
345
|
+
// A thrown append remains accepting: its authored-row outcome needs person-audited recovery.
|
|
346
|
+
const noteId = context.store.promote(target.id, "person", context.project);
|
|
347
|
+
if (noteId === undefined) {
|
|
348
|
+
this.db.query("UPDATE candidates SET status = 'pending' WHERE id = ? AND project IN (SELECT value FROM json_each(?)) AND status = 'accepting'").run(candidate.id, this.scope(context.project));
|
|
349
|
+
throw Error("promotion unavailable or declined; candidate remains pending");
|
|
350
|
+
}
|
|
351
|
+
const promoted = context.store.get(noteId, context.project);
|
|
352
|
+
if (noteId === target.id || !promoted || promoted.id !== noteId || promoted.tier !== "long" || promoted.promotedFrom !== target.id) throw Error("promotion outcome unconfirmed; inspect the accepting candidate");
|
|
353
|
+
this.db.query("UPDATE candidates SET status = 'accepted', note_id = ? WHERE id = ? AND project IN (SELECT value FROM json_each(?))").run(noteId, candidate.id, this.scope(context.project));
|
|
354
|
+
return { candidateId: candidate.id, noteId, alreadyAccepted: false };
|
|
202
355
|
});
|
|
203
356
|
}
|
|
357
|
+
/** Reserve before the optional callback, including failed attempts. No table is created on capture/MCP reads. */
|
|
358
|
+
reserveConsolidationModel(inputBytes: number): boolean {
|
|
359
|
+
if (!Number.isSafeInteger(inputBytes) || inputBytes < 1 || inputBytes > 8192) throw Error("invalid model input budget");
|
|
360
|
+
const at = this.now(); if (!Number.isFinite(at)) throw Error("model budget clock unavailable");
|
|
361
|
+
const day = new Date(at).toISOString().slice(0, 10);
|
|
362
|
+
return this.db.transaction(() => {
|
|
363
|
+
this.db.run("CREATE TABLE IF NOT EXISTS consolidation_budget (day TEXT PRIMARY KEY, calls INTEGER NOT NULL, input_bytes INTEGER NOT NULL)");
|
|
364
|
+
const latest = this.db.query("SELECT MAX(day) AS day FROM consolidation_budget").get() as { day: string | null };
|
|
365
|
+
if (latest.day && latest.day > day) return false;
|
|
366
|
+
const row = this.db.query("SELECT calls,input_bytes FROM consolidation_budget WHERE day = ?").get(day) as { calls: number; input_bytes: number } | null;
|
|
367
|
+
if (row && (!Number.isSafeInteger(row.calls) || row.calls < 0 || !Number.isSafeInteger(row.input_bytes) || row.input_bytes < 0)) throw Error("model budget state unavailable");
|
|
368
|
+
if (row && (row.calls >= 4 || row.input_bytes + inputBytes > 32768)) return false;
|
|
369
|
+
this.db.query("INSERT INTO consolidation_budget (day,calls,input_bytes) VALUES (?,1,?) ON CONFLICT(day) DO UPDATE SET calls = calls + 1, input_bytes = input_bytes + excluded.input_bytes").run(day, inputBytes);
|
|
370
|
+
this.db.query("DELETE FROM consolidation_budget WHERE day < ?").run(new Date(at - 30 * 86400_000).toISOString().slice(0, 10));
|
|
371
|
+
return true;
|
|
372
|
+
}).immediate();
|
|
373
|
+
}
|
|
374
|
+
private beginAcceptance(id: string, project: string, before?: () => void): string | undefined {
|
|
375
|
+
return this.db.transaction(() => {
|
|
376
|
+
const current = this.get(id, project);
|
|
377
|
+
if (!current) throw Error("candidate unavailable");
|
|
378
|
+
if (current.status === "accepted") return current.acceptedNoteId!;
|
|
379
|
+
if (current.status !== "pending") throw Error("candidate is rejected or acceptance was interrupted; no automatic replay");
|
|
380
|
+
before?.();
|
|
381
|
+
this.db.query("UPDATE candidates SET status = 'accepting' WHERE id = ? AND project IN (SELECT value FROM json_each(?))").run(id, this.scope(project));
|
|
382
|
+
return undefined;
|
|
383
|
+
}).immediate();
|
|
384
|
+
}
|
|
204
385
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { lstatSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import { capacityReport, type ShardSplit } from "./capacity.ts";
|
|
4
|
+
import { readCaptureConfigFile, writeCaptureConfigFile, type CaptureConfig } from "./capture.ts";
|
|
5
|
+
import { walkShards } from "./catalog.ts";
|
|
6
|
+
import { lifecycleResolver } from "./lifecycle.ts";
|
|
7
|
+
import { machineId } from "./store.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* #15 T5 / D7: `amem capacity apply --split`. Writes each shard's own capture config (`machine/<id>/config.json`, which overrides the home default) with its
|
|
11
|
+
* share of the global budget. Person-only like the other apply (the CLI refuses an agent marker and a missing terminal before this runs), and in two steps:
|
|
12
|
+
* a preview that records which shards, which current configs and which target values the person is shown, and a `--confirm` that writes exactly those
|
|
13
|
+
* target values and nothing recomputed. It refuses unless the set of shards and every shard's current config are unchanged, and unless the preview is younger
|
|
14
|
+
* than `PREVIEW_MAX_AGE_MS`: a week-old preview cannot write a cap the person never saw, and free disk drifting a little between the two steps changes nothing
|
|
15
|
+
* that is written. A forgotten shard is never written. This changes capture bounds only: gc and the capture path apply them later, in bounded
|
|
16
|
+
* batches (R2); nothing is evicted here, and no curated note, goal or work list is read or written.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface SplitTarget { key: string; dir: string; current: Partial<CaptureConfig>; target: CaptureConfig; reason: string }
|
|
20
|
+
export interface SplitPreview { at: number; split: SplitTarget[]; skipped: string[] }
|
|
21
|
+
/** A preview older than this is refused: the person saw those values, but the readings behind them are stale. */
|
|
22
|
+
export const PREVIEW_MAX_AGE_MS = 3600_000;
|
|
23
|
+
|
|
24
|
+
const previewPath = (base: string): string => join(base, "capacity-split.preview.json");
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One target per non-forgotten shard that has a directory of this machine. A shard that only another machine has (a synced home) is never written here: that
|
|
28
|
+
* machine's config is its own, so the shard is left out and named in `skipped`. The directory is matched by its name, which is portable, not by a path suffix.
|
|
29
|
+
*/
|
|
30
|
+
function targets(base: string, split: ShardSplit[]): { targets: SplitTarget[]; skipped: string[] } {
|
|
31
|
+
const dirs = new Map(walkShards(base).map(shard => [shard.key, shard.machineDirs]));
|
|
32
|
+
const stateOf = lifecycleResolver(base);
|
|
33
|
+
const machine = machineId(base).id;
|
|
34
|
+
const out: SplitTarget[] = [], skipped: string[] = [];
|
|
35
|
+
for (const row of split) {
|
|
36
|
+
if (stateOf(row.key) === "forgotten") continue;
|
|
37
|
+
const dir = (dirs.get(row.key) ?? []).find(candidate => basename(candidate) === machine);
|
|
38
|
+
if (!dir) { skipped.push(row.key); continue; }
|
|
39
|
+
out.push({ key: row.key, dir, current: readCaptureConfigFile(join(dir, "config.json")), target: { capMb: row.capMb, retentionDays: row.retentionDays }, reason: row.reason });
|
|
40
|
+
}
|
|
41
|
+
return { targets: out, skipped };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function previewSplit(base: string, opts: { diskShare?: number; now?: () => number } = {}): SplitPreview & { confirm: string } {
|
|
45
|
+
const report = capacityReport({ memoryDir: base, ...(opts.diskShare !== undefined ? { diskShare: opts.diskShare } : {}) });
|
|
46
|
+
if (report.splitRefused) throw new Error(`capacity apply --split: ${report.splitRefused}`);
|
|
47
|
+
const found = targets(base, report.split);
|
|
48
|
+
const preview: SplitPreview = { at: (opts.now ?? Date.now)(), split: found.targets, skipped: found.skipped };
|
|
49
|
+
writeFileSync(previewPath(base), JSON.stringify(preview) + "\n", { mode: 0o600 });
|
|
50
|
+
return { ...preview, confirm: "re-run with --confirm to write each shard's share" };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function applySplit(base: string, opts: { diskShare?: number; now?: () => number } = {}): { split: SplitTarget[]; written: { key: string; cap_mb: number; retention_days: number }[] } {
|
|
54
|
+
const file = previewPath(base);
|
|
55
|
+
let approved: SplitPreview;
|
|
56
|
+
try {
|
|
57
|
+
const stat = lstatSync(file);
|
|
58
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024) throw new Error();
|
|
59
|
+
approved = JSON.parse(readFileSync(file, "utf8")) as SplitPreview;
|
|
60
|
+
} catch { throw new Error("capacity apply --split needs a preview first; re-run without --confirm"); }
|
|
61
|
+
const report = capacityReport({ memoryDir: base, ...(opts.diskShare !== undefined ? { diskShare: opts.diskShare } : {}) });
|
|
62
|
+
if (report.splitRefused) throw new Error(`capacity apply --split: ${report.splitRefused}`);
|
|
63
|
+
const live = targets(base, report.split).targets;
|
|
64
|
+
const age = (opts.now ?? Date.now)() - approved.at;
|
|
65
|
+
if (!Number.isFinite(age) || age < 0 || age > PREVIEW_MAX_AGE_MS) throw new Error("capacity preview is out of date (older than an hour); re-run without --confirm");
|
|
66
|
+
// What was previewed is what is written: the same shards, each with the config the person saw replaced by the target the person saw.
|
|
67
|
+
const identity = (rows: SplitTarget[]) => JSON.stringify(rows.map(({ key, dir, current }) => ({ key, dir, current })));
|
|
68
|
+
if (!Array.isArray(approved.split) || identity(approved.split) !== identity(live)) throw new Error("capacity preview changed (a shard appeared or went, or a shard's config moved); re-run without --confirm");
|
|
69
|
+
const written = approved.split.map(row => {
|
|
70
|
+
const next = writeCaptureConfigFile(join(row.dir, "config.json"), row.target);
|
|
71
|
+
return { key: row.key, cap_mb: next.capMb ?? row.target.capMb, retention_days: next.retentionDays ?? row.target.retentionDays };
|
|
72
|
+
});
|
|
73
|
+
rmSync(file, { force: true });
|
|
74
|
+
return { split: approved.split, written };
|
|
75
|
+
}
|
|
76
|
+
|