@staix/agent-mem 0.5.0 → 0.7.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 +15 -0
- package/README.md +186 -21
- package/bin/amem.js +26 -3
- package/hooks/hooks.json +81 -0
- package/package.json +3 -1
- package/src/cli.ts +492 -39
- package/src/commands-table.ts +56 -15
- package/src/hook-contract.ts +3 -0
- package/src/hook-git.ts +42 -0
- package/src/hook-native.ts +92 -0
- package/src/hook-project.ts +206 -0
- package/src/hook-resources.ts +15 -0
- package/src/hook-spool.ts +566 -0
- package/src/hooks.ts +253 -0
- package/src/index.ts +18 -0
- package/src/ingest.ts +351 -0
- package/src/mcp-memory.ts +141 -33
- package/src/mcp.ts +42 -7
- package/src/memory/aliases.ts +87 -0
- package/src/memory/bridge.ts +8 -2
- package/src/memory/candidate-pending.ts +119 -0
- package/src/memory/candidates.ts +144 -47
- package/src/memory/capacity-split.ts +76 -0
- package/src/memory/capacity.ts +162 -59
- package/src/memory/capture-input.ts +36 -0
- package/src/memory/capture.ts +153 -39
- package/src/memory/catalog.ts +43 -10
- package/src/memory/commands.ts +98 -44
- package/src/memory/config.ts +167 -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 +412 -0
- package/src/memory/editor.ts +58 -0
- package/src/memory/eval-fixture.ts +137 -0
- package/src/memory/eval-set.json +443 -0
- package/src/memory/eval.ts +150 -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/machine.ts +31 -0
- package/src/memory/metrics.ts +63 -0
- package/src/memory/mutation.ts +6 -4
- 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 +9 -4
- package/src/memory/policy.ts +44 -13
- package/src/memory/private-spans.ts +32 -0
- package/src/memory/project-export.ts +122 -0
- package/src/memory/project.ts +129 -20
- package/src/memory/reader-lease.ts +22 -10
- 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/shard.ts +3 -80
- package/src/memory/store.ts +286 -56
- package/src/memory/sync-cadence.ts +139 -0
- package/src/memory/sync-screen.ts +86 -0
- package/src/memory/sync.ts +79 -11
- package/src/memory/usage.ts +35 -0
- package/src/memory/use-telemetry.ts +51 -0
- package/src/memory/work-lists.ts +115 -38
- package/src/session-brief-worker.ts +82 -0
- package/src/session-brief.ts +261 -0
- package/src/setup-owned.ts +120 -0
- package/src/setup-toml.ts +291 -0
- package/src/setup.ts +327 -0
package/src/memory/capture.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
-
import { chmodSync, closeSync, existsSync, mkdirSync, openSync, opendirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, opendirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { looksSecret } from "./screen.ts";
|
|
6
|
-
import {
|
|
6
|
+
import { normalizeCaptureFields, type CaptureFields } from "./capture-input.ts";
|
|
7
|
+
export { CAPTURE_MAX_EXCERPT, CAPTURE_MAX_PATHS } from "./capture-input.ts";
|
|
7
8
|
import { memoryBase } from "./home.ts";
|
|
8
|
-
import { captureShardDir, ensureShardDescriptor, resolveAlias } from "./shard.ts";
|
|
9
|
-
import {
|
|
9
|
+
import { captureShardDir, ensureShardDescriptor, readShardDescriptor, resolveAlias, shardKeyHash } from "./shard.ts";
|
|
10
|
+
import { assertProjectActive } from "./lifecycle.ts";
|
|
11
|
+
import { machineId } from "./machine.ts";
|
|
12
|
+
import { loadAmemConfig, repoConfigRoot } from "./config.ts";
|
|
13
|
+
import { fold } from "./policy.ts";
|
|
10
14
|
import { readWindowSql, readRef, type ReadWindow, type ReadRef, ftsTerms, fts5Available } from "./search-fts.ts";
|
|
11
15
|
|
|
12
16
|
/**
|
|
@@ -39,15 +43,12 @@ export const CAPTURE_DEFAULT_CAP_MB = 512;
|
|
|
39
43
|
export const CAPTURE_DEFAULT_RETENTION_DAYS = 90;
|
|
40
44
|
/** Well under the 150 ms R2 wall budget even with process startup: contention spools instead of waiting. */
|
|
41
45
|
export const CAPTURE_BUSY_TIMEOUT_MS = 25;
|
|
42
|
-
export const CAPTURE_MAX_EXCERPT = 2000;
|
|
43
|
-
export const CAPTURE_MAX_PATHS = 32;
|
|
44
46
|
/** Eviction and drain work per invocation, so a shrunk cap or a long outage is worked off over many hooks. */
|
|
45
47
|
export const CAPTURE_EVICT_BATCH_ROWS = 256;
|
|
46
48
|
export const CAPTURE_EVICT_MAX_BATCHES = 4;
|
|
47
49
|
/** Wall-time bound for one invocation's eviction: with the batch bound, an append stays inside R2 after a cap shrink. */
|
|
48
50
|
export const CAPTURE_EVICT_MAX_MS = 25;
|
|
49
51
|
export const CAPTURE_DRAIN_MAX_LINES = 1000;
|
|
50
|
-
const ROW_OVERHEAD_BYTES = 96;
|
|
51
52
|
const SPOOL = "spool.jsonl";
|
|
52
53
|
const DRAINING = "spool.jsonl.draining";
|
|
53
54
|
const SPOOL_DIR = "spool";
|
|
@@ -58,12 +59,7 @@ const segmentName = /^spool-[0-9a-f-]+\.jsonl(?:\.offset-(\d+)(-skip)?)?(?:\.dra
|
|
|
58
59
|
let spoolSequence = 0;
|
|
59
60
|
const spoolToken = () => `${Date.now().toString(16).padStart(13, "0")}-${process.pid.toString(16).padStart(8, "0")}-${(spoolSequence++).toString(16).padStart(8, "0")}-${randomBytes(12).toString("hex")}`;
|
|
60
61
|
|
|
61
|
-
export interface CaptureInput {
|
|
62
|
-
project: string; agent: string; event: string;
|
|
63
|
-
tool?: string; paths?: string[]; status?: string; excerpt?: string;
|
|
64
|
-
/** Tests and the spool replay pin the time; hooks leave it to the store clock. */
|
|
65
|
-
at?: string;
|
|
66
|
-
}
|
|
62
|
+
export interface CaptureInput extends CaptureFields { project: string }
|
|
67
63
|
export interface CaptureRow {
|
|
68
64
|
id: number; at: string; project: string; agent: string; event: string;
|
|
69
65
|
tool: string | null; paths: string[]; status: string | null; excerpt: string;
|
|
@@ -80,10 +76,29 @@ export interface CaptureOptions {
|
|
|
80
76
|
/** Host-supplied R6 values win over the shard and home config files. */
|
|
81
77
|
capMb?: number; retentionDays?: number;
|
|
82
78
|
busyTimeoutMs?: number;
|
|
79
|
+
/** Host-only brief view: open existing capture only, without schema/index/replay writes. */
|
|
80
|
+
readOnly?: boolean;
|
|
81
|
+
/** Host-only row ceiling for the read-only snapshot; overflow is unavailable, never empty. */
|
|
82
|
+
maxReadRows?: number;
|
|
83
|
+
/** Host-only optional brief: retain the newest maxReadRows ids, with explicit partial coverage. */
|
|
84
|
+
recentReadRows?: boolean;
|
|
83
85
|
/** Excerpt screen; defaults to the built-in secret screen. A host passes its stricter policy. */
|
|
84
86
|
screen?: (text: string) => boolean;
|
|
87
|
+
/** Caps a caller may lower (F4); the built-in caps and the person's configured caps are the ceiling, so a larger value is ignored (a host can tighten, never loosen). */
|
|
88
|
+
excerptBytes?: number; capturePaths?: number;
|
|
89
|
+
/** The working directory the capture belongs to: its repository's `.amem/config` narrows the policy (deny paths, caps) for this store. Absent: the machine configuration only. */
|
|
90
|
+
cwd?: string;
|
|
91
|
+
/**
|
|
92
|
+
* #15 T5: a writable open of an archived or forgotten project is refused (the person took it out of recall; `amem project unarchive` brings an archived one back).
|
|
93
|
+
* `ignore` is for the maintenance that must still reach the shard (gc, export): it never appends.
|
|
94
|
+
*/
|
|
95
|
+
lifecycle?: "enforce" | "ignore";
|
|
85
96
|
}
|
|
86
|
-
export
|
|
97
|
+
export interface CaptureAppendOptions {
|
|
98
|
+
/** Opaque stable source receipt for deterministic transcript replay. Requires an explicit source timestamp. */
|
|
99
|
+
receipt?: string;
|
|
100
|
+
}
|
|
101
|
+
export type CaptureAppend = { id: number; already?: true } | { spooled: true };
|
|
87
102
|
|
|
88
103
|
/** One row after validation, deny-filtering, bounding and screening: what both the insert and the spool carry. */
|
|
89
104
|
interface NormalizedCapture {
|
|
@@ -122,13 +137,17 @@ export function writeCaptureConfigFile(path: string, config: Partial<CaptureConf
|
|
|
122
137
|
return next;
|
|
123
138
|
}
|
|
124
139
|
|
|
125
|
-
/**
|
|
140
|
+
/**
|
|
141
|
+
* The effective R6 settings for a shard directory: the shard's config over the home default (`capture-config.json`, which the capacity advisor writes)
|
|
142
|
+
* over the person's machine configuration (`amem.config.json` `capture`, F4) over the built-in defaults. A malformed machine configuration throws.
|
|
143
|
+
*/
|
|
126
144
|
export function readCaptureConfig(dir: string, base?: string): CaptureConfig {
|
|
127
145
|
const homeDefault = base === undefined ? {} : readCaptureConfigFile(join(base, "capture-config.json"));
|
|
128
146
|
const shard = readCaptureConfigFile(join(dir, "config.json"));
|
|
147
|
+
const machine = base === undefined ? {} : loadAmemConfig(base).config.capture;
|
|
129
148
|
return {
|
|
130
|
-
capMb: shard.capMb ?? homeDefault.capMb ?? CAPTURE_DEFAULT_CAP_MB,
|
|
131
|
-
retentionDays: shard.retentionDays ?? homeDefault.retentionDays ?? CAPTURE_DEFAULT_RETENTION_DAYS,
|
|
149
|
+
capMb: shard.capMb ?? homeDefault.capMb ?? machine.capMb ?? CAPTURE_DEFAULT_CAP_MB,
|
|
150
|
+
retentionDays: shard.retentionDays ?? homeDefault.retentionDays ?? machine.retentionDays ?? CAPTURE_DEFAULT_RETENTION_DAYS,
|
|
132
151
|
};
|
|
133
152
|
}
|
|
134
153
|
|
|
@@ -154,28 +173,90 @@ export class CaptureStore {
|
|
|
154
173
|
private readonly db: Database;
|
|
155
174
|
private readonly now: () => number;
|
|
156
175
|
private readonly screen: (text: string) => boolean;
|
|
176
|
+
private readonly caps: import("./capture-input.ts").CaptureCaps;
|
|
157
177
|
private readonly capMb: number;
|
|
158
178
|
private readonly retentionDays: number;
|
|
159
179
|
/** Set when the schema setup itself met the lock: appends spool without touching the database again. */
|
|
160
180
|
private contended = false;
|
|
181
|
+
private readonly readOnly: boolean;
|
|
182
|
+
private recentReadIds?: number[];
|
|
183
|
+
/** True when the optional recent snapshot excludes older capture rows. */
|
|
184
|
+
readonly recentReadTruncated: boolean = false;
|
|
161
185
|
|
|
162
186
|
constructor(opts: CaptureOptions) {
|
|
163
187
|
if (typeof opts.project !== "string" || !opts.project.trim()) throw new Error("capture shards need a project key");
|
|
164
188
|
if (opts.memoryDir !== undefined && opts.home !== undefined) throw new Error("choose a home or an exact memory directory");
|
|
189
|
+
this.readOnly = opts.readOnly === true;
|
|
190
|
+
if (opts.readOnly !== undefined && typeof opts.readOnly !== "boolean") throw new Error("invalid capture read-only option");
|
|
191
|
+
if (opts.recentReadRows !== undefined && (typeof opts.recentReadRows !== "boolean" || !this.readOnly)) throw new Error("recent capture rows require a read-only store");
|
|
192
|
+
const maxReadRows = opts.maxReadRows ?? 128;
|
|
193
|
+
if (this.readOnly && (!Number.isSafeInteger(maxReadRows) || maxReadRows < 1 || maxReadRows >= Number.MAX_SAFE_INTEGER)) throw new Error("invalid capture read row bound");
|
|
165
194
|
this.base = opts.memoryDir ?? memoryBase(opts.home);
|
|
166
|
-
const machine = opts.machine ?? machineId(this.base).id;
|
|
195
|
+
const machine = opts.machine ?? (this.readOnly ? this.existingMachine() : machineId(this.base).id);
|
|
196
|
+
if (this.readOnly && !/^[a-f0-9]{16}$/.test(machine)) throw new Error("capture machine identity unavailable");
|
|
167
197
|
this.requestedKey = opts.project;
|
|
168
198
|
this.key = resolveAlias(this.base, opts.project);
|
|
169
|
-
|
|
199
|
+
if (!this.readOnly && opts.lifecycle !== "ignore") assertProjectActive(this.base, this.key);
|
|
200
|
+
if (this.readOnly) {
|
|
201
|
+
const descriptor = lstatSync(join(this.base, "projects", shardKeyHash(this.key), "project.json"));
|
|
202
|
+
if (!descriptor.isFile() || descriptor.isSymbolicLink() || descriptor.size > 4096
|
|
203
|
+
|| readShardDescriptor(this.base, shardKeyHash(this.key))?.key !== this.key) throw new Error("capture shard identity unavailable");
|
|
204
|
+
} else ensureShardDescriptor(this.base, this.key);
|
|
170
205
|
this.dir = captureShardDir(this.base, opts.project, machine);
|
|
171
|
-
|
|
172
|
-
|
|
206
|
+
if (!this.readOnly) {
|
|
207
|
+
mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
208
|
+
chmodSync(this.dir, 0o700); // mkdir's mode applies only when it creates the directory
|
|
209
|
+
}
|
|
210
|
+
if (this.readOnly) for (const file of [join(this.dir, "config.json"), join(this.base, "capture-config.json")]) {
|
|
211
|
+
const stat = lstatSync(file, { throwIfNoEntry: false });
|
|
212
|
+
if (stat && (!stat.isFile() || stat.isSymbolicLink() || stat.size > 64 * 1024)) throw new Error("capture configuration unavailable");
|
|
213
|
+
}
|
|
173
214
|
const config = readCaptureConfig(this.dir, this.base); // each invocation: a config change applies at the next hook
|
|
174
215
|
this.capMb = opts.capMb ?? config.capMb;
|
|
175
216
|
this.retentionDays = opts.retentionDays ?? config.retentionDays;
|
|
176
217
|
this.now = opts.now ?? Date.now;
|
|
177
|
-
this.
|
|
218
|
+
const configured = loadAmemConfig(this.base, opts.cwd === undefined ? undefined : repoConfigRoot(opts.cwd)).config;
|
|
219
|
+
// F4: the person's PII patterns screen every excerpt and their deny paths filter every path, whoever appends (hook, ingest or library). A host's own `screen` replaces only the secret part.
|
|
220
|
+
const patterns = configured.piiPatterns.map(fold).filter(Boolean);
|
|
221
|
+
const base = opts.screen ?? looksSecret;
|
|
222
|
+
this.screen = patterns.length ? (text: string) => base(text) || patterns.some(pattern => fold(text).includes(pattern)) : base;
|
|
223
|
+
const lowest = (a: number | undefined, b: number | undefined) => a === undefined ? b : b === undefined ? a : Math.min(a, b);
|
|
224
|
+
this.caps = { excerptBytes: lowest(opts.excerptBytes, configured.excerptBytes), paths: lowest(opts.capturePaths, configured.capturePaths),
|
|
225
|
+
...(configured.denyPaths.length ? { denyPaths: configured.denyPaths } : {}) };
|
|
178
226
|
const file = join(this.dir, "capture.db");
|
|
227
|
+
if (this.readOnly) {
|
|
228
|
+
// Refuse absent/cold state without authoring machine, shard, schema, FTS or spool metadata.
|
|
229
|
+
for (const directory of [this.base, join(this.base, "projects"), join(this.base, "projects", shardKeyHash(this.key)),
|
|
230
|
+
join(this.base, "projects", shardKeyHash(this.key), "machine"), this.dir]) {
|
|
231
|
+
const stat = lstatSync(directory);
|
|
232
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("capture read path unavailable");
|
|
233
|
+
}
|
|
234
|
+
const stat = lstatSync(file);
|
|
235
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("capture database unavailable");
|
|
236
|
+
const timeout = opts.busyTimeoutMs ?? 0;
|
|
237
|
+
if (!Number.isSafeInteger(timeout) || timeout < 0 || timeout > 3000) throw new Error("invalid capture busy timeout");
|
|
238
|
+
const db = this.db = new Database(file, { readonly: true });
|
|
239
|
+
try {
|
|
240
|
+
db.run(`PRAGMA busy_timeout = ${timeout}`); db.run("PRAGMA cache_size = -2048"); db.run("PRAGMA mmap_size = 0");
|
|
241
|
+
db.run("BEGIN");
|
|
242
|
+
if (opts.recentReadRows) {
|
|
243
|
+
// The existing rows_at index supplies the reverse time/id walk; retain ids only.
|
|
244
|
+
// One sentinel records excluded history without counting or loading its bodies.
|
|
245
|
+
const sql = "SELECT id FROM rows INDEXED BY rows_at ORDER BY at DESC, id DESC LIMIT ?";
|
|
246
|
+
const plan = db.query(`EXPLAIN QUERY PLAN ${sql}`).all(maxReadRows + 1) as { detail: string }[];
|
|
247
|
+
if (plan.some(row => row.detail.includes("TEMP B-TREE"))) throw new Error("capture recent index unavailable");
|
|
248
|
+
const recent = db.query(sql).all(maxReadRows + 1) as { id: number }[];
|
|
249
|
+
this.recentReadIds = recent.slice(0, maxReadRows).map(row => row.id);
|
|
250
|
+
this.recentReadTruncated = recent.length > maxReadRows;
|
|
251
|
+
} else {
|
|
252
|
+
const count = (db.query("SELECT COUNT(*) AS n FROM (SELECT 1 FROM rows LIMIT ?)").get(maxReadRows + 1) as { n: number }).n;
|
|
253
|
+
if (count > maxReadRows) throw new Error("capture brief row bound exceeded");
|
|
254
|
+
}
|
|
255
|
+
if (resolveAlias(this.base, this.requestedKey) !== this.key) throw new Error("capture alias binding changed");
|
|
256
|
+
} catch (error) { db.close(); throw error; }
|
|
257
|
+
this.migrated = 0;
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
179
260
|
const db = new Database(file, { create: true });
|
|
180
261
|
this.db = db;
|
|
181
262
|
try { chmodSync(file, 0o600); } catch { /* best effort */ }
|
|
@@ -195,6 +276,20 @@ export class CaptureStore {
|
|
|
195
276
|
this.migrated = this.contended ? 0 : this.migrateLegacy();
|
|
196
277
|
}
|
|
197
278
|
|
|
279
|
+
private existingMachine(): string {
|
|
280
|
+
const file = join(this.base, "machine.json"), stat = lstatSync(file);
|
|
281
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4096) throw new Error("capture machine identity unavailable");
|
|
282
|
+
const value = JSON.parse(readFileSync(file, "utf8")) as { id?: unknown };
|
|
283
|
+
if (typeof value?.id !== "string" || !/^[a-f0-9]{16}$/.test(value.id)) throw new Error("capture machine identity unavailable");
|
|
284
|
+
return value.id;
|
|
285
|
+
}
|
|
286
|
+
private writable(): void {
|
|
287
|
+
if (this.readOnly) throw new Error("capture store is read-only");
|
|
288
|
+
}
|
|
289
|
+
private checkReadBinding(): void {
|
|
290
|
+
if (this.readOnly && resolveAlias(this.base, this.requestedKey) !== this.key) throw new Error("capture alias binding changed");
|
|
291
|
+
}
|
|
292
|
+
|
|
198
293
|
/** The pre-T1 machine-wide store hands this project key's rows AND spool lines to the shard, once; an emptied legacy store goes away. */
|
|
199
294
|
private migrateLegacy(): number {
|
|
200
295
|
const legacyDir = join(this.base, "capture"); // the pre-T1 machine-wide location (this branch never shipped)
|
|
@@ -270,6 +365,7 @@ export class CaptureStore {
|
|
|
270
365
|
private mcpReady = false;
|
|
271
366
|
/** One-time MCP read cost only: hooks neither create this derived index nor scan history to fill it. */
|
|
272
367
|
private ensureMcpFts(): boolean {
|
|
368
|
+
if (this.readOnly) return !!this.db.query("SELECT 1 FROM sqlite_master WHERE name = 'mcp_fts' AND type = 'table'").get();
|
|
273
369
|
if (!fts5Available()) return false;
|
|
274
370
|
if (this.mcpReady) return true;
|
|
275
371
|
try {
|
|
@@ -303,46 +399,54 @@ export class CaptureStore {
|
|
|
303
399
|
* and a later drainer — under any screen — inserts exactly that row. On lock contention the row goes to the spool
|
|
304
400
|
* and the caller exits fast (R2); the next uncontended writer replays it with its original timestamp.
|
|
305
401
|
*/
|
|
306
|
-
append(input: CaptureInput): CaptureAppend {
|
|
402
|
+
append(input: CaptureInput, options: CaptureAppendOptions = {}): CaptureAppend {
|
|
403
|
+
this.writable();
|
|
307
404
|
const str = (v: unknown, max: number) => typeof v === "string" && v.length > 0 && v.length <= max;
|
|
308
405
|
if (!str(input.project, 400) || !str(input.agent, 64) || !str(input.event, 64)) throw new Error("capture rows need a project, an agent and an event");
|
|
309
406
|
if (input.at !== undefined && (typeof input.at !== "string" || !Number.isFinite(Date.parse(input.at)))) throw new Error("capture row time must be a parseable time string");
|
|
407
|
+
const receipt = options.receipt;
|
|
408
|
+
if (receipt !== undefined && (typeof receipt !== "string" || !/^[a-f0-9]{64}$/.test(receipt) || input.at === undefined)) throw new Error("capture receipt needs a stable time and a 64-hex digest");
|
|
310
409
|
const row = this.normalize(input);
|
|
410
|
+
// Reuse the immutable spool format and its existing unique index: a crash between capture and cursor
|
|
411
|
+
// advance replays exactly the same screened line, whether first written directly or under contention.
|
|
412
|
+
const spoolId = receipt === undefined ? null : createHash("sha256").update(this.spoolLine(row, receipt)).digest("hex");
|
|
311
413
|
if (this.contended) {
|
|
312
|
-
this.spool(row);
|
|
414
|
+
this.spool(row, receipt);
|
|
313
415
|
return { spooled: true };
|
|
314
416
|
}
|
|
315
417
|
try {
|
|
316
418
|
this.drain(); // replay earlier spooled rows first, so row ids stay in hook order
|
|
317
419
|
// The row and its running byte count commit or roll back together: a lock after the INSERT cannot spool a duplicate.
|
|
318
|
-
const
|
|
420
|
+
const result = this.db.transaction((): CaptureAppend => {
|
|
421
|
+
const id = this.insertRow(row, spoolId);
|
|
422
|
+
if (id !== undefined) return { id };
|
|
423
|
+
const prior = this.db.query("SELECT id FROM rows WHERE spool_id = ?").get(spoolId) as { id: number } | null;
|
|
424
|
+
if (!prior) throw new Error("capture receipt lookup failed");
|
|
425
|
+
return { id: prior.id, already: true };
|
|
426
|
+
}).immediate();
|
|
319
427
|
try { this.evict(); } catch (error) {
|
|
320
428
|
if (!lockError(error)) throw error; // the row is already committed; a locked eviction simply resumes on a later append
|
|
321
429
|
}
|
|
322
|
-
return
|
|
430
|
+
return result;
|
|
323
431
|
} catch (error) {
|
|
324
432
|
if (!lockError(error)) throw error;
|
|
325
|
-
this.spool(row);
|
|
433
|
+
this.spool(row, receipt);
|
|
326
434
|
return { spooled: true };
|
|
327
435
|
}
|
|
328
436
|
}
|
|
329
437
|
|
|
330
438
|
/** The one place input becomes a row: caps fields, deny-filters paths, screens the excerpt, counts its bytes. */
|
|
331
439
|
private normalize(input: CaptureInput): NormalizedCapture {
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
const at = new Date(input.at === undefined ? this.now() : Date.parse(input.at)).toISOString();
|
|
335
|
-
const tool = str(input.tool, 64) ? input.tool! : null;
|
|
336
|
-
const status = str(input.status, 64) ? input.status! : null;
|
|
337
|
-
const paths = (input.paths ?? []).map(String).filter((p) => p && !isDenied(p)).slice(0, CAPTURE_MAX_PATHS).map((p) => p.slice(0, 400));
|
|
338
|
-
let excerpt = (input.excerpt ?? "").slice(0, CAPTURE_MAX_EXCERPT);
|
|
339
|
-
if (excerpt && this.screen(excerpt)) excerpt = "[secret-screened]";
|
|
340
|
-
return { at, project: input.project, agent: input.agent, event: input.event, tool, paths, status, excerpt,
|
|
341
|
-
bytes: Buffer.byteLength(excerpt) + Buffer.byteLength(JSON.stringify(paths)) + ROW_OVERHEAD_BYTES };
|
|
440
|
+
const { at, agent, event, tool, paths, status, excerpt, bytes } = normalizeCaptureFields(input, () => this.now(), text => this.screen(text), this.caps);
|
|
441
|
+
return { at, project: input.project, agent, event, tool, paths, status, excerpt, bytes };
|
|
342
442
|
}
|
|
343
443
|
|
|
344
|
-
private
|
|
345
|
-
|
|
444
|
+
private spoolLine(row: NormalizedCapture, nonce = randomBytes(12).toString("hex")): string {
|
|
445
|
+
return JSON.stringify({ ...row, nonce });
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private spool(row: NormalizedCapture, nonce?: string): void {
|
|
449
|
+
this.spoolRaw(this.spoolLine(row, nonce));
|
|
346
450
|
}
|
|
347
451
|
|
|
348
452
|
/**
|
|
@@ -355,6 +459,7 @@ export class CaptureStore {
|
|
|
355
459
|
* already-open shared inode cannot be made immutable by a newer reader.
|
|
356
460
|
*/
|
|
357
461
|
drain(): { drained: number; skipped: number } {
|
|
462
|
+
this.writable();
|
|
358
463
|
let budget = CAPTURE_DRAIN_MAX_LINES, byteBudget = SPOOL_MAX_BYTES, drained = 0, skipped = 0;
|
|
359
464
|
if (this.contended) return { drained, skipped };
|
|
360
465
|
const started = performance.now();
|
|
@@ -469,6 +574,7 @@ export class CaptureStore {
|
|
|
469
574
|
* deleted; the curated store is a different file this class never opens (AC5).
|
|
470
575
|
*/
|
|
471
576
|
evict(): { expired: number; trimmed: number } {
|
|
577
|
+
this.writable();
|
|
472
578
|
if (this.contended) return { expired: 0, trimmed: 0 };
|
|
473
579
|
const start = performance.now();
|
|
474
580
|
const expired = this.evictWhere("at < ?", [new Date(this.now() - this.retentionDays * 24 * 3600_000).toISOString()], start);
|
|
@@ -501,7 +607,9 @@ export class CaptureStore {
|
|
|
501
607
|
}
|
|
502
608
|
|
|
503
609
|
rows(f: { project?: string; sinceId?: number; limit?: number } = {}): CaptureRow[] {
|
|
610
|
+
this.checkReadBinding();
|
|
504
611
|
const where: string[] = [], bind: (string | number)[] = [];
|
|
612
|
+
if (this.recentReadIds) { where.push("id IN (SELECT value FROM json_each(?))"); bind.push(JSON.stringify(this.recentReadIds)); }
|
|
505
613
|
if (f.project) { where.push("project = ?"); bind.push(f.project); }
|
|
506
614
|
if (f.sinceId !== undefined) { where.push("id > ?"); bind.push(f.sinceId); }
|
|
507
615
|
const rows = this.db.query(`SELECT * FROM rows${where.length ? ` WHERE ${where.join(" AND ")}` : ""} ORDER BY id LIMIT ?`).all(...bind, f.limit ?? 1000) as RawRow[];
|
|
@@ -510,7 +618,12 @@ export class CaptureStore {
|
|
|
510
618
|
|
|
511
619
|
/** Exact-project, bounded body pages; MCP screens before filling its public candidate window. */
|
|
512
620
|
readPage(window: ReadWindow & { query?: string; match?: "substring" }): { rows: CaptureRow[]; next?: ReadRef; engine?: "fts5" | "substring" } {
|
|
621
|
+
this.checkReadBinding();
|
|
513
622
|
const { columns, where, bind } = readWindowSql(window, "id");
|
|
623
|
+
if (this.recentReadIds) {
|
|
624
|
+
if (window.query !== undefined) throw new Error("recent capture brief does not support query indexes");
|
|
625
|
+
where.push("id IN (SELECT value FROM json_each(?))"); bind.push(JSON.stringify(this.recentReadIds));
|
|
626
|
+
}
|
|
514
627
|
const engine = window.query === undefined ? undefined : window.match !== "substring" && this.ensureMcpFts() ? "fts5" : "substring";
|
|
515
628
|
if (window.query !== undefined && engine === "fts5") {
|
|
516
629
|
const match = ftsTerms(window.query); if (!match) return { rows: [], engine };
|
|
@@ -522,6 +635,7 @@ export class CaptureStore {
|
|
|
522
635
|
}
|
|
523
636
|
|
|
524
637
|
stats(): { rows: number; bytes: number; capMb: number; retentionDays: number; fileBytes: number } {
|
|
638
|
+
this.checkReadBinding();
|
|
525
639
|
let fileBytes = 0;
|
|
526
640
|
try { fileBytes = statSync(join(this.dir, "capture.db")).size; } catch { /* not created yet */ }
|
|
527
641
|
return { rows: (this.db.query("SELECT COUNT(*) AS n FROM rows").get() as { n: number }).n, bytes: this.bytes(), capMb: this.capMb, retentionDays: this.retentionDays, fileBytes };
|
package/src/memory/catalog.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
-
import { chmodSync, existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { chmodSync, existsSync, lstatSync, readdirSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { memoryBase } from "./home.ts";
|
|
5
5
|
import { effectiveAliases, readShardDescriptor, SHARDED_SCHEMA } from "./shard.ts";
|
|
6
6
|
import { readProfileJSON, validGeneration } from "./generation.ts";
|
|
7
7
|
import { captureShardStats, readCaptureConfig } from "./capture.ts";
|
|
8
8
|
import { openShardStore } from "./store.ts";
|
|
9
|
+
import { lifecycleResolver, type LifecycleState } from "./lifecycle.ts";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* #15 D3 (storage part only): the user-level catalog of project shards. `catalog.db` in the memory home is derived
|
|
@@ -20,12 +21,16 @@ export interface CatalogProject {
|
|
|
20
21
|
captureRows: number; captureBytes: number; captureLastAt: string | null;
|
|
21
22
|
curatedEntries: number | null; curatedLastAt: string | null; curatedAvailable: boolean;
|
|
22
23
|
capMb: number; retentionDays: number;
|
|
24
|
+
/** #15 T5: the person's lifecycle decision (`lifecycle.jsonl`); forgotten projects stay in the scan so the state is visible, and listings leave them out. */
|
|
25
|
+
state: LifecycleState;
|
|
26
|
+
/** Bytes the shard directory holds on disk (its logs, indexes, capture and work lists), counting files and never following a link. */
|
|
27
|
+
diskBytes: number;
|
|
23
28
|
}
|
|
24
29
|
|
|
25
|
-
interface CatalogRow { hash: string; key: string; local: number; aliases: string; capture_rows: number; capture_bytes: number; capture_last_at: string | null; curated_entries: number | null; curated_last_at: string | null; cap_mb: number; retention_days: number }
|
|
30
|
+
interface CatalogRow { hash: string; key: string; local: number; aliases: string; capture_rows: number; capture_bytes: number; capture_last_at: string | null; curated_entries: number | null; curated_last_at: string | null; cap_mb: number; retention_days: number; state: string; disk_bytes: number }
|
|
26
31
|
|
|
27
32
|
const fromRow = (r: CatalogRow): CatalogProject => ({ hash: r.hash, key: r.key, local: r.local === 1, aliases: JSON.parse(r.aliases) as string[],
|
|
28
|
-
captureRows: r.capture_rows, captureBytes: r.capture_bytes, captureLastAt: r.capture_last_at, curatedEntries: r.curated_entries, curatedLastAt: r.curated_last_at, curatedAvailable: r.curated_entries !== null, capMb: r.cap_mb, retentionDays: r.retention_days });
|
|
33
|
+
captureRows: r.capture_rows, captureBytes: r.capture_bytes, captureLastAt: r.capture_last_at, curatedEntries: r.curated_entries, curatedLastAt: r.curated_last_at, curatedAvailable: r.curated_entries !== null, capMb: r.cap_mb, retentionDays: r.retention_days, state: r.state === "archived" || r.state === "forgotten" ? r.state : "active", diskBytes: r.disk_bytes });
|
|
29
34
|
|
|
30
35
|
/** Interrupted splits expose capture facts, but cannot authorize any curated store open or initialization. */
|
|
31
36
|
function capturedSelection(base: string): boolean {
|
|
@@ -41,7 +46,7 @@ function curatedStats(base: string, hash: string, key: string, captured: boolean
|
|
|
41
46
|
const dir = join(base, "projects", hash);
|
|
42
47
|
// A capture-only descriptor is not permission to initialize a curated store while listing the catalog.
|
|
43
48
|
if (!existsSync(join(dir, "generation.json")) && !existsSync(join(dir, "generation-seal.json"))) return { entries: 0, lastAt: null };
|
|
44
|
-
const store = openShardStore({ memoryDir: base, project: key, deferPromotions: true });
|
|
49
|
+
const store = openShardStore({ memoryDir: base, project: key, deferPromotions: true, lifecycle: "ignore" });
|
|
45
50
|
try {
|
|
46
51
|
const db = new Database(join(dir, "index.db"), { readonly: true });
|
|
47
52
|
try {
|
|
@@ -52,6 +57,23 @@ function curatedStats(base: string, hash: string, key: string, captured: boolean
|
|
|
52
57
|
} finally { store.close(); }
|
|
53
58
|
}
|
|
54
59
|
|
|
60
|
+
/** Bytes of regular files under a shard directory. A link is not followed (counted as nothing), an unreadable entry as nothing: the figure is a lower bound, never a guess. */
|
|
61
|
+
export function directoryBytes(dir: string, depth = 0): number {
|
|
62
|
+
if (depth > 12) return 0;
|
|
63
|
+
let total = 0;
|
|
64
|
+
let entries: import("node:fs").Dirent[] = [];
|
|
65
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return 0; }
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
const path = join(dir, entry.name);
|
|
68
|
+
try {
|
|
69
|
+
if (entry.isSymbolicLink()) continue;
|
|
70
|
+
if (entry.isDirectory()) total += directoryBytes(path, depth + 1);
|
|
71
|
+
else if (entry.isFile()) total += lstatSync(path).size;
|
|
72
|
+
} catch { /* vanished or unreadable: not counted */ }
|
|
73
|
+
}
|
|
74
|
+
return total;
|
|
75
|
+
}
|
|
76
|
+
|
|
55
77
|
/** Every readable shard with its machine-scope directories; the walk behind the catalog and the capacity advisor. */
|
|
56
78
|
export function walkShards(base: string): { hash: string; key: string; local: boolean; machineDirs: string[] }[] {
|
|
57
79
|
const projectsDir = join(base, "projects");
|
|
@@ -75,6 +97,7 @@ export function scanCatalog(base: string): CatalogProject[] {
|
|
|
75
97
|
const captured = capturedSelection(base);
|
|
76
98
|
// The latest op per alias (a removal included): an alias lists only under the key it currently points at, never under a past target or after `alias remove`.
|
|
77
99
|
const latest = effectiveAliases(base);
|
|
100
|
+
const stateOf = lifecycleResolver(base);
|
|
78
101
|
const out: CatalogProject[] = [];
|
|
79
102
|
for (const shard of walkShards(base)) {
|
|
80
103
|
let rows = 0, bytes = 0, lastAt: string | null = null;
|
|
@@ -88,11 +111,17 @@ export function scanCatalog(base: string): CatalogProject[] {
|
|
|
88
111
|
const curated = curatedStats(base, shard.hash, shard.key, captured);
|
|
89
112
|
out.push({ hash: shard.hash, key: shard.key, local: shard.local,
|
|
90
113
|
aliases: [...latest.entries()].filter(([, key]) => key === shard.key).map(([alias]) => alias).sort(),
|
|
91
|
-
captureRows: rows, captureBytes: bytes, captureLastAt: lastAt, curatedEntries: curated.entries, curatedLastAt: curated.lastAt, curatedAvailable: curated.entries !== null, capMb: config.capMb, retentionDays: config.retentionDays
|
|
114
|
+
captureRows: rows, captureBytes: bytes, captureLastAt: lastAt, curatedEntries: curated.entries, curatedLastAt: curated.lastAt, curatedAvailable: curated.entries !== null, capMb: config.capMb, retentionDays: config.retentionDays,
|
|
115
|
+
state: stateOf(shard.key), diskBytes: directoryBytes(join(base, "projects", shard.hash)) });
|
|
92
116
|
}
|
|
93
117
|
return out.sort((a, b) => a.key.localeCompare(b.key));
|
|
94
118
|
}
|
|
95
119
|
|
|
120
|
+
/** A fresh scan of the shards for a read-only command (`amem usage`, `amem projects`): it writes nothing, `catalog.db` included. */
|
|
121
|
+
export function readCatalogFresh(base: string): CatalogProject[] {
|
|
122
|
+
return scanCatalog(base);
|
|
123
|
+
}
|
|
124
|
+
|
|
96
125
|
/** Rewrite `catalog.db` from the shards in one transaction: the same shards always reproduce the same rows. */
|
|
97
126
|
export function rebuildCatalog(opts: { home?: string } = {}): CatalogProject[] {
|
|
98
127
|
const base = memoryBase(opts.home);
|
|
@@ -105,12 +134,13 @@ export function rebuildCatalog(opts: { home?: string } = {}): CatalogProject[] {
|
|
|
105
134
|
db.transaction(() => {
|
|
106
135
|
const columns = db.query("PRAGMA table_info(projects)").all() as { name: string; notnull: number }[];
|
|
107
136
|
// This database is disposable. Rebuild older derived schemas instead of coercing unavailable into zero.
|
|
108
|
-
if (columns.length && (!columns.some(column => column.name === "curated_entries" && column.notnull === 0) || !columns.some(column => column.name === "curated_last_at")
|
|
137
|
+
if (columns.length && (!columns.some(column => column.name === "curated_entries" && column.notnull === 0) || !columns.some(column => column.name === "curated_last_at")
|
|
138
|
+
|| !columns.some(column => column.name === "state") || !columns.some(column => column.name === "disk_bytes"))) db.run("DROP TABLE projects");
|
|
109
139
|
db.run(`CREATE TABLE IF NOT EXISTS projects (hash TEXT PRIMARY KEY, key TEXT NOT NULL, local INTEGER NOT NULL, aliases TEXT NOT NULL,
|
|
110
|
-
capture_rows INTEGER NOT NULL, capture_bytes INTEGER NOT NULL, capture_last_at TEXT, curated_entries INTEGER, curated_last_at TEXT, cap_mb REAL NOT NULL, retention_days REAL NOT NULL)`);
|
|
140
|
+
capture_rows INTEGER NOT NULL, capture_bytes INTEGER NOT NULL, capture_last_at TEXT, curated_entries INTEGER, curated_last_at TEXT, cap_mb REAL NOT NULL, retention_days REAL NOT NULL, state TEXT NOT NULL DEFAULT 'active', disk_bytes INTEGER NOT NULL DEFAULT 0)`);
|
|
111
141
|
db.run("DELETE FROM projects");
|
|
112
|
-
for (const p of projects) db.query("INSERT INTO projects (hash, key, local, aliases, capture_rows, capture_bytes, capture_last_at, curated_entries, curated_last_at, cap_mb, retention_days) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
|
113
|
-
.run(p.hash, p.key, p.local ? 1 : 0, JSON.stringify(p.aliases), p.captureRows, p.captureBytes, p.captureLastAt, p.curatedEntries, p.curatedLastAt, p.capMb, p.retentionDays);
|
|
142
|
+
for (const p of projects) db.query("INSERT INTO projects (hash, key, local, aliases, capture_rows, capture_bytes, capture_last_at, curated_entries, curated_last_at, cap_mb, retention_days, state, disk_bytes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
|
143
|
+
.run(p.hash, p.key, p.local ? 1 : 0, JSON.stringify(p.aliases), p.captureRows, p.captureBytes, p.captureLastAt, p.curatedEntries, p.curatedLastAt, p.capMb, p.retentionDays, p.state, p.diskBytes);
|
|
114
144
|
}).immediate();
|
|
115
145
|
} finally { db.close(); }
|
|
116
146
|
return projects;
|
|
@@ -127,8 +157,11 @@ export function readCatalog(opts: { home?: string } = {}): CatalogProject[] {
|
|
|
127
157
|
db = new Database(file, { readonly: true });
|
|
128
158
|
db.run("PRAGMA busy_timeout = 3000");
|
|
129
159
|
const columns = new Set((db.query("PRAGMA table_info(projects)").all() as { name: string }[]).map(column => column.name));
|
|
130
|
-
if (!columns.has("curated_entries") || !columns.has("curated_last_at")) throw new Error("catalog schema needs rebuild");
|
|
160
|
+
if (!columns.has("curated_entries") || !columns.has("curated_last_at") || !columns.has("state") || !columns.has("disk_bytes")) throw new Error("catalog schema needs rebuild");
|
|
161
|
+
// The state is the person's decision in lifecycle.jsonl, which changes without touching a shard: read it live, never from the cached row.
|
|
162
|
+
const stateOf = lifecycleResolver(base);
|
|
131
163
|
return (db.query("SELECT * FROM projects ORDER BY key").all() as CatalogRow[]).map(fromRow)
|
|
164
|
+
.map(project => ({ ...project, state: stateOf(project.key) }))
|
|
132
165
|
.map(project => captured ? { ...project, curatedEntries: null, curatedLastAt: null, curatedAvailable: false } : project);
|
|
133
166
|
} catch { return rebuildCatalog(opts); }
|
|
134
167
|
finally { db?.close(); }
|