@staix/agent-mem 0.1.1 → 0.3.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 +71 -1
- package/package.json +4 -2
- package/src/cli.ts +97 -6
- package/src/commands-table.ts +16 -0
- package/src/index.ts +5 -0
- package/src/memory/bridge.ts +376 -0
- package/src/memory/candidates.ts +196 -0
- package/src/memory/commands.ts +9 -2
- package/src/memory/generation-index.ts +245 -0
- package/src/memory/generation-sync.ts +248 -0
- package/src/memory/generation.ts +143 -0
- package/src/memory/migrate.ts +216 -0
- package/src/memory/mutation.ts +22 -0
- package/src/memory/ops-contract.ts +159 -0
- package/src/memory/platform.ts +8 -1
- package/src/memory/prune.ts +259 -0
- package/src/memory/store.ts +235 -36
- package/src/memory/sync.ts +31 -3
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { createHash, randomBytes, type Hash } from "node:crypto";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { closeSync, constants, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, unlinkSync, writeSync } from "node:fs";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
import { isDeepStrictEqual } from "node:util";
|
|
6
|
+
import { memoryGitArgv, memoryGitEnv, realPath } from "./platform.ts";
|
|
7
|
+
import { canonicalJSON, fsyncDirectory, readProfileJSON, writeProfileJSON } from "./generation.ts";
|
|
8
|
+
import { OP_CONTRACTS } from "./ops-contract.ts";
|
|
9
|
+
import { entryId, type Entry } from "./store.ts";
|
|
10
|
+
|
|
11
|
+
export interface LegacyCheckpoint { source: string; path: string; offset: number; hash: string; commit?: string; tailBytes?: number; stamp?: string }
|
|
12
|
+
export interface LegacySegment extends LegacyCheckpoint { bytes: Buffer; local: boolean; tailBytes: number; start?: number }
|
|
13
|
+
export interface LegacySnapshotOptions {
|
|
14
|
+
generation?: string;
|
|
15
|
+
commit?: string;
|
|
16
|
+
previousCommit?: string;
|
|
17
|
+
/** Count skipped complete legacy lines; authoritative generation callers use legacyEntries strict mode. */
|
|
18
|
+
onMalformed?: () => void;
|
|
19
|
+
checkpoints?: readonly LegacyCheckpoint[];
|
|
20
|
+
reader?: SegmentReader;
|
|
21
|
+
incremental?: boolean;
|
|
22
|
+
/** A migration replay reads exactly these captured complete prefixes, ignoring later suffixes/new segments. */
|
|
23
|
+
limits?: readonly LegacyCheckpoint[];
|
|
24
|
+
}
|
|
25
|
+
const diskSource = "legacy-local", remoteSource = "legacy-main";
|
|
26
|
+
const digest = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex");
|
|
27
|
+
class LegacyInputError extends Error {
|
|
28
|
+
constructor(readonly malformed = false) { super("legacy bridge input changed or is invalid; person inspection required"); }
|
|
29
|
+
}
|
|
30
|
+
function failure(): never { throw new LegacyInputError(); }
|
|
31
|
+
function malformed(): never { throw new LegacyInputError(true); }
|
|
32
|
+
const key = (row: LegacyCheckpoint) => `${row.source}:${row.path}`;
|
|
33
|
+
const segmentPath = (path: string) => /^(log|local)\/[A-Za-z0-9][A-Za-z0-9._-]*\.jsonl$/.test(path);
|
|
34
|
+
const commitId = (value: string) => /^[a-f0-9]{40}$/.test(value);
|
|
35
|
+
|
|
36
|
+
function directory(path: string, optional = false): boolean {
|
|
37
|
+
try { const stat = lstatSync(path); if (stat.isSymbolicLink() || !stat.isDirectory()) failure(); return true; }
|
|
38
|
+
catch (error) { if (optional && (error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SegmentReadMetrics { bytesRead: number; bytesHashed: number; coldChanged: number }
|
|
42
|
+
interface PrefixState { offset: number; hash: string; hasher: Hash; bytes: Buffer }
|
|
43
|
+
type SegmentBytes = Pick<LegacySegment, "bytes" | "offset" | "hash" | "tailBytes" | "stamp" | "start">;
|
|
44
|
+
const statStamp = (stat: { dev: bigint | number; ino: bigint | number; size: bigint | number; mtimeNs?: bigint; ctimeNs?: bigint }) => JSON.stringify([String(stat.dev), String(stat.ino), String(stat.size), String(stat.mtimeNs), String(stat.ctimeNs)]);
|
|
45
|
+
|
|
46
|
+
/** Handle-local SHA state: unchanged persisted metadata costs no data read or hashing, including reopen. */
|
|
47
|
+
export class SegmentReader {
|
|
48
|
+
private readonly prefixes = new Map<string, PrefixState>();
|
|
49
|
+
private measured: SegmentReadMetrics = { bytesRead: 0, bytesHashed: 0, coldChanged: 0 };
|
|
50
|
+
metrics(): SegmentReadMetrics { return { ...this.measured }; }
|
|
51
|
+
resetMetrics(): void { this.measured = { bytesRead: 0, bytesHashed: 0, coldChanged: 0 }; }
|
|
52
|
+
read(path: string, prior?: LegacyCheckpoint, full = false, immutable = false): SegmentBytes {
|
|
53
|
+
const before = lstatSync(path, { bigint: true });
|
|
54
|
+
if (before.isSymbolicLink() || !before.isFile() || !Number.isSafeInteger(Number(before.size))) failure();
|
|
55
|
+
const stamp = statStamp(before), size = Number(before.size);
|
|
56
|
+
if (prior && size < prior.offset) failure();
|
|
57
|
+
if (!full && prior?.stamp === stamp) return { bytes: Buffer.alloc(0), start: prior.offset, offset: prior.offset, hash: prior.hash, tailBytes: size - prior.offset, stamp };
|
|
58
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
59
|
+
try {
|
|
60
|
+
const opened = fstatSync(fd, { bigint: true });
|
|
61
|
+
if (statStamp(opened) !== stamp) failure();
|
|
62
|
+
const read = (length: number): Buffer => {
|
|
63
|
+
const bytes = Buffer.alloc(length); let at = 0;
|
|
64
|
+
while (at < length) { const n = readSync(fd, bytes, at, length - at, at); if (!n) failure(); at += n; this.measured.bytesRead += n; }
|
|
65
|
+
return bytes;
|
|
66
|
+
};
|
|
67
|
+
const all = read(size), end = all.lastIndexOf(10) + 1, bytes = all.subarray(0, end);
|
|
68
|
+
if (prior && end < prior.offset) failure();
|
|
69
|
+
if (!read(end).equals(bytes) || fstatSync(fd, { bigint: true }).size < opened.size) failure();
|
|
70
|
+
const after = lstatSync(path, { bigint: true });
|
|
71
|
+
if (after.isSymbolicLink() || after.dev !== opened.dev || after.ino !== opened.ino) failure();
|
|
72
|
+
const cached = immutable ? undefined : this.prefixes.get(path);
|
|
73
|
+
let hasher: Hash;
|
|
74
|
+
if (!full && prior && cached && cached.offset === prior.offset && cached.hash === prior.hash) {
|
|
75
|
+
// Changed live input still reads/compares the old bytes, so rewrite-plus-growth cannot masquerade as append.
|
|
76
|
+
// ponytail: the generation counterpart of C02: O(segment) I/O per changed refresh (13 MB at 20K entries, p50 247 ms,
|
|
77
|
+
// docs/bench/p9-generation-read.md). Upgrade, measured first: a per-block hash chain or a tail window plus inode/size check.
|
|
78
|
+
if (!cached.bytes.equals(bytes.subarray(0, prior.offset))) failure();
|
|
79
|
+
hasher = cached.hasher.copy(); const suffix = bytes.subarray(prior.offset); hasher.update(suffix); this.measured.bytesHashed += suffix.length;
|
|
80
|
+
} else {
|
|
81
|
+
// ponytail: a cold changed segment verifies/seeds its whole prefix once (O(segment)); unchanged reopens are O(metadata).
|
|
82
|
+
// Serialize SHA state or retain per-block receipts only if measured cold-change cost warrants that additional format.
|
|
83
|
+
hasher = createHash("sha256");
|
|
84
|
+
if (prior) {
|
|
85
|
+
hasher.update(bytes.subarray(0, prior.offset)); this.measured.bytesHashed += prior.offset;
|
|
86
|
+
if (hasher.copy().digest("hex") !== prior.hash) failure();
|
|
87
|
+
if (!full) this.measured.coldChanged++;
|
|
88
|
+
hasher.update(bytes.subarray(prior.offset)); this.measured.bytesHashed += bytes.length - prior.offset;
|
|
89
|
+
} else { hasher.update(bytes); this.measured.bytesHashed += bytes.length; }
|
|
90
|
+
}
|
|
91
|
+
const hash = hasher.copy().digest("hex");
|
|
92
|
+
if (!immutable) this.prefixes.set(path, { offset: end, hash, hasher, bytes: Buffer.from(bytes) });
|
|
93
|
+
const start = !full && prior ? prior.offset : 0;
|
|
94
|
+
// A concurrent append is outside the captured length; retain the earlier stamp so the next read notices it.
|
|
95
|
+
return { bytes: Buffer.from(bytes.subarray(start)), ...(start ? { start } : {}), offset: end, hash, tailBytes: size - end, stamp };
|
|
96
|
+
} finally { closeSync(fd); }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function checkpointMap(rows: readonly LegacyCheckpoint[] = []): Map<string, LegacyCheckpoint> {
|
|
101
|
+
const map = new Map<string, LegacyCheckpoint>();
|
|
102
|
+
for (const row of rows) {
|
|
103
|
+
if (![diskSource, remoteSource].includes(row.source) || !segmentPath(row.path) || row.source === remoteSource && !row.path.startsWith("log/")
|
|
104
|
+
|| !Number.isSafeInteger(row.offset) || row.offset < 0 || !/^[a-f0-9]{64}$/.test(row.hash)
|
|
105
|
+
|| row.tailBytes !== undefined && (!Number.isSafeInteger(row.tailBytes) || row.tailBytes < 0)
|
|
106
|
+
|| row.stamp !== undefined && (typeof row.stamp !== "string" || row.stamp.length > 1024)
|
|
107
|
+
|| row.source === remoteSource && (!row.commit || !commitId(row.commit)) || row.source === diskSource && row.commit !== undefined || map.has(key(row))) failure();
|
|
108
|
+
map.set(key(row), row);
|
|
109
|
+
}
|
|
110
|
+
return map;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function checkedEntry(value: unknown): Entry {
|
|
114
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) malformed();
|
|
115
|
+
const row = value as Record<string, unknown>;
|
|
116
|
+
const operation = row.op;
|
|
117
|
+
if (typeof operation !== "string") return malformed();
|
|
118
|
+
if (!Object.hasOwn(OP_CONTRACTS, operation)) return malformed();
|
|
119
|
+
const contract = OP_CONTRACTS[operation]!;
|
|
120
|
+
const schema = contract.payloadSchema;
|
|
121
|
+
if (!contract.implemented || contract.status !== "v1-equal" || !schema) malformed();
|
|
122
|
+
if (row.schema !== "agent-hub.memory/v1" || typeof row.id !== "string" || !/^[a-f0-9]{32}$/.test(row.id)) malformed();
|
|
123
|
+
const { id, ...body } = row;
|
|
124
|
+
if (entryId(body as unknown as Omit<Entry, "id">) !== id) malformed();
|
|
125
|
+
for (const name of schema.required) if (row[name] === undefined && !schema.legacyOptional.includes(name)) malformed();
|
|
126
|
+
for (const [name, field] of Object.entries(schema.fields)) {
|
|
127
|
+
const value = row[name];
|
|
128
|
+
if (value === undefined || value === null && (!schema.required.includes(name) || schema.legacyOptional.includes(name))) continue;
|
|
129
|
+
if (field.type === "string-array") { if (!Array.isArray(value) || !value.every(item => typeof item === "string")) malformed(); continue; }
|
|
130
|
+
if (typeof value !== "string" || field.maxLength !== undefined && value.length > field.maxLength || field.values && !field.values.includes(value)) malformed();
|
|
131
|
+
if (field.format === "id" && !/^[a-f0-9]{32}$/.test(value)) malformed();
|
|
132
|
+
if (field.format === "utc" && Number.isNaN(Date.parse(value))) malformed();
|
|
133
|
+
}
|
|
134
|
+
if (typeof row.at !== "string" || new Date(row.at).toISOString() !== row.at) malformed();
|
|
135
|
+
return row as unknown as Entry;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function* completeLines(bytes: Buffer): Generator<Buffer> {
|
|
139
|
+
let start = 0;
|
|
140
|
+
while (start < bytes.length) { const end = bytes.indexOf(10, start); if (end < 0) failure(); yield bytes.subarray(start, end); start = end + 1; }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Preserve original IDs; row order and JSON key order never produce duplicate evidence. */
|
|
144
|
+
export function legacyEntries(segments: readonly LegacySegment[], options: { strict?: boolean; onMalformed?: () => void } = {}): Entry[] {
|
|
145
|
+
const rows = new Map<string, Entry>();
|
|
146
|
+
for (const segment of segments) {
|
|
147
|
+
if (segment.bytes.length !== segment.offset || digest(segment.bytes) !== segment.hash || segment.offset && segment.bytes.at(-1) !== 10) failure();
|
|
148
|
+
for (const bytes of completeLines(segment.bytes)) {
|
|
149
|
+
let row: Entry;
|
|
150
|
+
try {
|
|
151
|
+
let line: string;
|
|
152
|
+
try { line = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { malformed(); }
|
|
153
|
+
if (!line!.trim()) continue;
|
|
154
|
+
let value: unknown; try { value = JSON.parse(line!); } catch { malformed(); }
|
|
155
|
+
row = checkedEntry(value);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (!(error instanceof LegacyInputError) || !error.malformed || options.strict) throw error;
|
|
158
|
+
options.onMalformed?.();
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const prior = rows.get(row.id);
|
|
162
|
+
if (prior && !isDeepStrictEqual(JSON.parse(JSON.stringify(prior)), JSON.parse(JSON.stringify(row)))) failure();
|
|
163
|
+
rows.set(row.id, row);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return [...rows.values()];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Raw authoritative inputs only: never constructs a legacy store, opens its index or performs network I/O. */
|
|
170
|
+
function snapshot(homeM: string, options: LegacySnapshotOptions): LegacySegment[] {
|
|
171
|
+
const root = realPath(homeM);
|
|
172
|
+
directory(root);
|
|
173
|
+
const checkpoints = checkpointMap(options.checkpoints), limits = options.limits === undefined ? undefined : checkpointMap(options.limits);
|
|
174
|
+
const result: LegacySegment[] = [];
|
|
175
|
+
const accept = (source: string, path: string, complete: Buffer, commit?: string, tailBytes = 0, stamp?: string) => {
|
|
176
|
+
if (!segmentPath(path)) failure();
|
|
177
|
+
const identity = `${source}:${path}`, prior = checkpoints.get(identity), limit = limits?.get(identity);
|
|
178
|
+
if (limits && !limit) return;
|
|
179
|
+
const verify = (receipt: LegacyCheckpoint) => {
|
|
180
|
+
if (receipt.offset > complete.length || receipt.offset > 0 && complete[receipt.offset - 1] !== 10 || digest(complete.subarray(0, receipt.offset)) !== receipt.hash) failure();
|
|
181
|
+
};
|
|
182
|
+
if (prior) verify(prior);
|
|
183
|
+
if (limit) { verify(limit); if (prior && limit.offset < prior.offset || limit.commit !== commit) failure(); }
|
|
184
|
+
const bytes = Buffer.from(limit ? complete.subarray(0, limit.offset) : complete);
|
|
185
|
+
result.push({ source, path, bytes, offset: bytes.length, hash: digest(bytes), local: path.startsWith("local/"), tailBytes: limit ? limit.tailBytes ?? 0 : tailBytes, ...((limit ? limit.stamp : stamp) ? { stamp: limit ? limit.stamp : stamp } : {}), ...(commit ? { commit } : {}) });
|
|
186
|
+
};
|
|
187
|
+
// Identity is read without invoking machineId(), which can initialize or repair local state.
|
|
188
|
+
let machine: unknown;
|
|
189
|
+
try { machine = JSON.parse(capturedIdentity(join(root, "machine.json"))); } catch { failure(); }
|
|
190
|
+
const machineId = (machine as { id?: unknown } | undefined)?.id;
|
|
191
|
+
if (typeof machineId !== "string" || !/^[a-f0-9]{16}$/.test(machineId)) failure();
|
|
192
|
+
for (const folder of ["log", "local"]) {
|
|
193
|
+
const dir = join(root, folder);
|
|
194
|
+
if (!directory(dir, true)) continue;
|
|
195
|
+
for (const name of readdirSync(dir).sort()) {
|
|
196
|
+
if (!name.endsWith(".jsonl")) continue;
|
|
197
|
+
const path = `${folder}/${name}`;
|
|
198
|
+
if (!segmentPath(path)) failure();
|
|
199
|
+
if (folder === "local" && name !== `${machineId}.jsonl`) continue;
|
|
200
|
+
const reader = options.reader ?? new SegmentReader();
|
|
201
|
+
const incremental = options.incremental && !limits;
|
|
202
|
+
const data = reader.read(join(dir, name), incremental ? checkpoints.get(`${diskSource}:${path}`) : undefined, !incremental);
|
|
203
|
+
if (incremental) result.push({ ...data, source: diskSource, path, local: folder === "local" });
|
|
204
|
+
else accept(diskSource, path, data.bytes, undefined, data.tailBytes, data.stamp);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if ((options.commit || options.previousCommit) && !options.generation || options.previousCommit && !commitId(options.previousCommit)) failure();
|
|
208
|
+
if (options.generation !== undefined) {
|
|
209
|
+
const commit = options.commit ?? legacyHead(root, options.generation);
|
|
210
|
+
if (commit) {
|
|
211
|
+
const manifest = archived(root, options.generation, commit);
|
|
212
|
+
directory(join(archiveDir(root, options.generation, commit), "log"));
|
|
213
|
+
for (const prior of new Set([...(options.previousCommit ? [options.previousCommit] : []), ...[...checkpoints.values()].filter(row => row.source === remoteSource).map(row => row.commit!)])) if (!archivedAncestor(root, options.generation, prior, commit)) failure();
|
|
214
|
+
for (const segment of manifest.segments) {
|
|
215
|
+
const reader = options.reader ?? new SegmentReader(), incremental = options.incremental && !limits;
|
|
216
|
+
const data = reader.read(join(archiveDir(root, options.generation, commit), segment.path), incremental ? checkpoints.get(`${remoteSource}:${segment.path}`) : undefined, !incremental, true);
|
|
217
|
+
if (data.offset !== segment.offset || data.hash !== segment.hash || data.tailBytes) failure();
|
|
218
|
+
if (incremental) result.push({ ...data, source: remoteSource, path: segment.path, local: false, tailBytes: segment.tailBytes, commit });
|
|
219
|
+
else accept(remoteSource, segment.path, data.bytes, commit, segment.tailBytes, data.stamp);
|
|
220
|
+
}
|
|
221
|
+
} else if (options.previousCommit) failure();
|
|
222
|
+
}
|
|
223
|
+
const returned = new Set(result.map(key));
|
|
224
|
+
for (const receipt of [...checkpoints.values(), ...(limits?.values() ?? [])]) if (!returned.has(key(receipt))) failure();
|
|
225
|
+
// Checkpointed complete prefixes were already validated; their full hashes prove unchanged bytes.
|
|
226
|
+
// Only newly completed suffixes need parsing on an incremental read.
|
|
227
|
+
const suffixes = result.map(segment => {
|
|
228
|
+
const start = checkpoints.get(key(segment))?.offset ?? 0;
|
|
229
|
+
const bytes = segment.bytes.subarray(Math.max(0, start - (segment.start ?? 0)));
|
|
230
|
+
return { ...segment, bytes, offset: bytes.length, hash: digest(bytes) };
|
|
231
|
+
});
|
|
232
|
+
legacyEntries(suffixes, { onMalformed: options.onMalformed });
|
|
233
|
+
return result;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function capturedIdentity(path: string): string {
|
|
237
|
+
const before = lstatSync(path);
|
|
238
|
+
if (before.isSymbolicLink() || !before.isFile()) failure();
|
|
239
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
240
|
+
try {
|
|
241
|
+
const stat = fstatSync(fd);
|
|
242
|
+
if (stat.dev !== before.dev || stat.ino !== before.ino || stat.size > 4096) failure();
|
|
243
|
+
const bytes = Buffer.alloc(stat.size);
|
|
244
|
+
if (readSync(fd, bytes, 0, bytes.length, 0) !== bytes.length) failure();
|
|
245
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
246
|
+
} finally { closeSync(fd); }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
function cacheDirectory(root: string, generation: string): string | undefined {
|
|
251
|
+
if (!/^[a-f0-9]{32}$/.test(generation)) failure();
|
|
252
|
+
const cache = join(root, "generations", generation, "bridge.git");
|
|
253
|
+
for (const path of [join(root, "generations"), join(root, "generations", generation), cache]) if (!directory(path, true)) return undefined;
|
|
254
|
+
return cache;
|
|
255
|
+
}
|
|
256
|
+
function cachedGit(cache: string, args: string[], allowMissing = false): Buffer | undefined {
|
|
257
|
+
const argv = memoryGitArgv(["--git-dir", cache, ...args]);
|
|
258
|
+
const run = spawnSync(argv[0]!, argv.slice(1), { env: memoryGitEnv({ ...process.env, GIT_OPTIONAL_LOCKS: "0" }, { repositoryIsolation: true }), encoding: "buffer", timeout: 10_000, maxBuffer: 256 * 1024 * 1024 });
|
|
259
|
+
if (run.error || run.signal || run.status !== 0) { if (allowMissing && !run.error && !run.signal && run.status === 1) return undefined; failure(); }
|
|
260
|
+
return run.stdout;
|
|
261
|
+
}
|
|
262
|
+
/** Last explicitly fetched legacy main, not a claim about the current remote state. Never fetches or writes. */
|
|
263
|
+
function head(homeM: string, generation: string): string | undefined {
|
|
264
|
+
const root = realPath(homeM); directory(root);
|
|
265
|
+
const dir = fixedGeneration(root, generation);
|
|
266
|
+
if (!directory(dir, true)) return undefined;
|
|
267
|
+
const active = readProfileJSON(join(dir, "bridge-snapshot.json"), 4096) as { schema?: unknown; generation?: unknown; commit?: unknown } | undefined;
|
|
268
|
+
if (!active) return undefined;
|
|
269
|
+
if (active.schema !== "agent-mem.legacy-head/v1" || active.generation !== generation || typeof active.commit !== "string" || !commitId(active.commit)) failure();
|
|
270
|
+
archived(root, generation, active.commit);
|
|
271
|
+
return active.commit;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
export function readLegacySnapshot(homeM: string, options: LegacySnapshotOptions = {}): LegacySegment[] {
|
|
276
|
+
try { return snapshot(homeM, options); } catch { return failure(); }
|
|
277
|
+
}
|
|
278
|
+
export function legacyHead(homeM: string, generation: string): string | undefined {
|
|
279
|
+
try { return head(homeM, generation); } catch { return failure(); }
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
// ponytail: immutable historical snapshots remain while migration/resume and prior-commit receipts can reference them.
|
|
284
|
+
// Disk use grows with sync history; add explicit journal-aware GC or content-addressed reuse if measurements justify it.
|
|
285
|
+
interface ArchivedSegment { path: string; offset: number; hash: string; tailBytes: number }
|
|
286
|
+
interface LegacyArchive { schema: "agent-mem.legacy-snapshot/v1"; generation: string; commit: string; previousCommit?: string; segments: ArchivedSegment[] }
|
|
287
|
+
function fixedGeneration(root: string, generation: string): string {
|
|
288
|
+
if (!/^[a-f0-9]{32}$/.test(generation)) failure();
|
|
289
|
+
const parent = join(root, "generations");
|
|
290
|
+
if (directory(parent, true)) directory(join(parent, generation), true);
|
|
291
|
+
return join(parent, generation);
|
|
292
|
+
}
|
|
293
|
+
function archiveDir(root: string, generation: string, commit: string): string {
|
|
294
|
+
if (!commitId(commit)) failure();
|
|
295
|
+
const dir = join(fixedGeneration(root, generation), "bridge-snapshots");
|
|
296
|
+
directory(dir); const target = join(dir, commit); directory(target); return target;
|
|
297
|
+
}
|
|
298
|
+
function archived(root: string, generation: string, commit: string): LegacyArchive {
|
|
299
|
+
const value = readProfileJSON(join(archiveDir(root, generation, commit), "snapshot.json"), 32 * 1024 * 1024) as LegacyArchive | undefined;
|
|
300
|
+
if (!value || value.schema !== "agent-mem.legacy-snapshot/v1" || value.generation !== generation || value.commit !== commit || value.previousCommit !== undefined && !commitId(value.previousCommit) || !Array.isArray(value.segments)) failure();
|
|
301
|
+
const paths = new Set<string>();
|
|
302
|
+
for (const segment of value.segments) {
|
|
303
|
+
if (!segment || !segmentPath(segment.path) || !segment.path.startsWith("log/") || paths.has(segment.path) || !Number.isSafeInteger(segment.offset) || segment.offset < 0 || !/^[a-f0-9]{64}$/.test(segment.hash) || !Number.isSafeInteger(segment.tailBytes) || segment.tailBytes < 0) failure();
|
|
304
|
+
paths.add(segment.path);
|
|
305
|
+
}
|
|
306
|
+
return value;
|
|
307
|
+
}
|
|
308
|
+
function archivedAncestor(root: string, generation: string, previous: string, commit: string): boolean {
|
|
309
|
+
const visited = new Set<string>();
|
|
310
|
+
for (let cursor: string | undefined = commit; cursor && !visited.has(cursor); cursor = archived(root, generation, cursor).previousCommit) {
|
|
311
|
+
if (cursor === previous) return true;
|
|
312
|
+
visited.add(cursor);
|
|
313
|
+
}
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Explicit sync-only Git capture. Ordinary readLegacySnapshot/legacyHead never call Git. */
|
|
318
|
+
export function readLegacyGitSnapshot(homeM: string, generation: string, commit: string, previousCommit?: string): LegacySegment[] {
|
|
319
|
+
try {
|
|
320
|
+
const root = realPath(homeM), cache = cacheDirectory(root, generation);
|
|
321
|
+
if (!cache || !commitId(commit) || previousCommit !== undefined && !commitId(previousCommit)) failure();
|
|
322
|
+
if (cachedGit(cache, ["cat-file", "-t", commit])?.toString("utf8").trim() !== "commit") failure();
|
|
323
|
+
if (previousCommit && previousCommit !== commit) cachedGit(cache, ["merge-base", "--is-ancestor", previousCommit, commit]);
|
|
324
|
+
const result: LegacySegment[] = [];
|
|
325
|
+
for (const record of cachedGit(cache, ["ls-tree", "-rz", "--full-tree", commit, "--", "log"])!.toString("utf8").split("\0").filter(Boolean)) {
|
|
326
|
+
const match = /^([0-7]{6}) blob ([a-f0-9]{40})\t(.+)$/.exec(record);
|
|
327
|
+
if (!match || match[1] !== "100644" && match[1] !== "100755" || !segmentPath(match[3]!) || !match[3]!.startsWith("log/")) return failure();
|
|
328
|
+
const blob = cachedGit(cache, ["cat-file", "blob", match[2]!])!, end = blob.lastIndexOf(10) + 1, bytes = blob.subarray(0, end);
|
|
329
|
+
result.push({ source: remoteSource, path: match[3]!, offset: end, hash: digest(bytes), bytes, local: false, tailBytes: blob.length - end, commit });
|
|
330
|
+
}
|
|
331
|
+
if (previousCommit) for (const old of archived(root, generation, previousCommit).segments) {
|
|
332
|
+
const next = result.find(segment => segment.path === old.path);
|
|
333
|
+
if (!next || next.offset < old.offset || digest(next.bytes.subarray(0, old.offset)) !== old.hash) failure();
|
|
334
|
+
}
|
|
335
|
+
legacyEntries(result); return result;
|
|
336
|
+
} catch { return failure(); }
|
|
337
|
+
}
|
|
338
|
+
function immutable(path: string, bytes: Buffer): void {
|
|
339
|
+
const temporary = join(dirname(path), `.${basename(path)}.${process.pid}.${randomBytes(16).toString("hex")}.tmp`);
|
|
340
|
+
const fd = openSync(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
|
|
341
|
+
try {
|
|
342
|
+
try { let at = 0; while (at < bytes.length) { const wrote = writeSync(fd, bytes, at, bytes.length - at); if (wrote <= 0) failure(); at += wrote; } fsyncSync(fd); } finally { closeSync(fd); }
|
|
343
|
+
try { linkSync(temporary, path); }
|
|
344
|
+
catch (error) {
|
|
345
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
346
|
+
const before = lstatSync(path); if (!before.isFile() || before.isSymbolicLink()) failure();
|
|
347
|
+
const existing = openSync(path, constants.O_RDWR | constants.O_NOFOLLOW);
|
|
348
|
+
try {
|
|
349
|
+
const stat = fstatSync(existing);
|
|
350
|
+
if (stat.dev !== before.dev || stat.ino !== before.ino || !readFileSync(existing).equals(bytes)) failure();
|
|
351
|
+
// Matching bytes after an interrupted capture still need a durable-data fence before activation.
|
|
352
|
+
fsyncSync(existing);
|
|
353
|
+
} finally { closeSync(existing); }
|
|
354
|
+
}
|
|
355
|
+
fsyncDirectory(dirname(path));
|
|
356
|
+
} finally { try { unlinkSync(temporary); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } }
|
|
357
|
+
}
|
|
358
|
+
/** Under the generation mutation fence, publish immutable cached blobs only after explicit sync's fixed-ref CAS. */
|
|
359
|
+
export function captureLegacyMain(homeM: string, generation: string, commit: string, previousCommit?: string): void {
|
|
360
|
+
try {
|
|
361
|
+
const root = realPath(homeM), current = legacyHead(root, generation), cache = cacheDirectory(root, generation);
|
|
362
|
+
if (!cache || previousCommit !== current && current !== commit) failure();
|
|
363
|
+
if (cachedGit(cache, ["rev-parse", "--verify", "refs/remotes/legacy/main^{commit}"])?.toString("utf8").trim() !== commit) failure();
|
|
364
|
+
const segments = readLegacyGitSnapshot(root, generation, commit, current);
|
|
365
|
+
const dir = join(fixedGeneration(root, generation), "bridge-snapshots");
|
|
366
|
+
for (const target of [dir, join(dir, commit), join(dir, commit, "log")]) {
|
|
367
|
+
if (!directory(target, true)) mkdirSync(target, { mode: 0o700 });
|
|
368
|
+
}
|
|
369
|
+
for (const segment of segments) immutable(join(dir, commit, segment.path), segment.bytes);
|
|
370
|
+
const prior = current === commit ? archived(root, generation, commit).previousCommit : current;
|
|
371
|
+
const manifest: LegacyArchive = { schema: "agent-mem.legacy-snapshot/v1", generation, commit, ...(prior ? { previousCommit: prior } : {}), segments: segments.map(({ path, offset, hash, tailBytes }) => ({ path, offset, hash, tailBytes })) };
|
|
372
|
+
immutable(join(dir, commit, "snapshot.json"), Buffer.from(canonicalJSON(manifest) + "\n"));
|
|
373
|
+
for (const target of [join(dir, commit, "log"), join(dir, commit), dir]) fsyncDirectory(target);
|
|
374
|
+
writeProfileJSON(join(fixedGeneration(root, generation), "bridge-snapshot.json"), { schema: "agent-mem.legacy-head/v1", generation, commit });
|
|
375
|
+
} catch { return failure(); }
|
|
376
|
+
}
|
|
@@ -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
|
+
}
|
package/src/memory/commands.ts
CHANGED
|
@@ -9,6 +9,8 @@ export interface MemoryCommandOptions {
|
|
|
9
9
|
open: () => MemoryStore;
|
|
10
10
|
readable: (notes: View[]) => View[];
|
|
11
11
|
importPolicy: () => (text: string) => boolean;
|
|
12
|
+
/** Explicit person-status receipt; model-facing hosts omit this callback. */
|
|
13
|
+
onBridgeStatus?: (status: { skippedRows: number } | undefined) => void;
|
|
12
14
|
}
|
|
13
15
|
export interface GoalCommandOptions {
|
|
14
16
|
store?: MemoryStore;
|
|
@@ -55,7 +57,9 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
|
|
|
55
57
|
// Screened first, then cut to the limit: withheld entries never crowd out readable ones.
|
|
56
58
|
const readable = opts.readable(all), rows = readable.slice(0, limit ? Number(limit) : 50);
|
|
57
59
|
if (json) return JSON.stringify(rows, null, 2);
|
|
58
|
-
|
|
60
|
+
// Preserve project-wide header semantics, but count only rows the host policy lets this reader see.
|
|
61
|
+
const counted = opts.readable(store.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true }));
|
|
62
|
+
const c = { machine: store.machine, long: counted.filter(v => v.tier === "long").length, short: counted.filter(v => v.tier === "short").length };
|
|
59
63
|
const withheld = all.length - readable.length;
|
|
60
64
|
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
65
|
...(withheld ? [`(${withheld} withheld: they match a PII pattern; the dashboard shows them)`] : [])].join("\n");
|
|
@@ -127,7 +131,10 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
|
|
|
127
131
|
case "rebuild": { store.rebuild(); const c = store.counts(); return `rebuilt the index from ${c.segments} segment${c.segments === 1 ? "" : "s"}: ${c.long} long-term, ${c.short} short-term entries`; }
|
|
128
132
|
default: return fail(MEMORY_USAGE);
|
|
129
133
|
}
|
|
130
|
-
} finally {
|
|
134
|
+
} finally {
|
|
135
|
+
try { opts.onBridgeStatus?.(store.bridgeStatus()); }
|
|
136
|
+
finally { if (!opts.store) store.close(); }
|
|
137
|
+
}
|
|
131
138
|
}
|
|
132
139
|
|
|
133
140
|
const goalOne = (g: GoalView, me: string) => `${g.id.slice(0, 8)} ${g.state.padEnd(7)} ${g.title}${g.scope === "shared" ? " [shared]" : ""} ${leaseText(g, me)}`;
|