@staix/agent-mem 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +5 -0
- package/README.md +57 -7
- package/bin/amem.js +26 -3
- package/hooks/hooks.json +81 -0
- package/package.json +3 -1
- package/src/cli.ts +33 -7
- package/src/commands-table.ts +4 -0
- 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 +544 -0
- package/src/hooks.ts +249 -0
- package/src/index.ts +4 -0
- package/src/ingest.ts +350 -0
- package/src/mcp-memory.ts +71 -28
- package/src/memory/aliases.ts +83 -0
- package/src/memory/candidates.ts +11 -7
- package/src/memory/capacity.ts +23 -48
- package/src/memory/capture-input.ts +31 -0
- package/src/memory/capture.ts +245 -59
- package/src/memory/catalog.ts +3 -4
- package/src/memory/import.ts +2 -3
- package/src/memory/machine.ts +31 -0
- package/src/memory/metrics.ts +63 -0
- package/src/memory/mutation.ts +6 -4
- package/src/memory/platform.ts +9 -4
- package/src/memory/project.ts +129 -20
- package/src/memory/reader-lease.ts +22 -10
- package/src/memory/search-fts.ts +44 -3
- package/src/memory/shard.ts +3 -46
- package/src/memory/store.ts +108 -62
- package/src/memory/work-lists.ts +227 -68
- package/src/session-brief-worker.ts +81 -0
- package/src/session-brief.ts +202 -0
package/src/memory/capture.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
-
import {
|
|
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 { machineId } from "./
|
|
10
|
-
import { fts5Available } from "./search-fts.ts";
|
|
9
|
+
import { captureShardDir, ensureShardDescriptor, readShardDescriptor, resolveAlias, shardKeyHash } from "./shard.ts";
|
|
10
|
+
import { machineId } from "./machine.ts";
|
|
11
|
+
import { readWindowSql, readRef, type ReadWindow, type ReadRef, ftsTerms, fts5Available } from "./search-fts.ts";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* #13 design A, tier 2, born sharded (#15 T1): machine-local capture, one shard per project key. A shard lives at
|
|
@@ -25,10 +26,10 @@ import { fts5Available } from "./search-fts.ts";
|
|
|
25
26
|
* ponytail: the cap counts row payload bytes, not the sqlite file's page slack or WAL; a VACUUM blocks writers and
|
|
26
27
|
* stays out of the hook path, so file size can exceed the cap until pages are reused.
|
|
27
28
|
*
|
|
28
|
-
* R2 contention: a writer that cannot take its shard's lock within `busyTimeoutMs`
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* never doubles a row.
|
|
29
|
+
* R2 contention: a writer that cannot take its shard's lock within `busyTimeoutMs` closes a private segment
|
|
30
|
+
* before publishing it atomically in `spool/`. The next uncontended writer (or explicit `drain()`) replays it
|
|
31
|
+
* under a unique claim name. Segments are immutable; a claim's byte offset advances only after the rows commit.
|
|
32
|
+
* Replay is idempotent per spooled line, so a crash between insert and acknowledgement never doubles a row.
|
|
32
33
|
*
|
|
33
34
|
* Migration: the pre-T1 machine-wide `capture/` (this branch never shipped) is drained per project on open —
|
|
34
35
|
* each project key's rows move into its shard with their original timestamps, and the legacy store is removed
|
|
@@ -39,24 +40,23 @@ export const CAPTURE_DEFAULT_CAP_MB = 512;
|
|
|
39
40
|
export const CAPTURE_DEFAULT_RETENTION_DAYS = 90;
|
|
40
41
|
/** Well under the 150 ms R2 wall budget even with process startup: contention spools instead of waiting. */
|
|
41
42
|
export const CAPTURE_BUSY_TIMEOUT_MS = 25;
|
|
42
|
-
export const CAPTURE_MAX_EXCERPT = 2000;
|
|
43
|
-
export const CAPTURE_MAX_PATHS = 32;
|
|
44
43
|
/** Eviction and drain work per invocation, so a shrunk cap or a long outage is worked off over many hooks. */
|
|
45
44
|
export const CAPTURE_EVICT_BATCH_ROWS = 256;
|
|
46
45
|
export const CAPTURE_EVICT_MAX_BATCHES = 4;
|
|
47
46
|
/** Wall-time bound for one invocation's eviction: with the batch bound, an append stays inside R2 after a cap shrink. */
|
|
48
47
|
export const CAPTURE_EVICT_MAX_MS = 25;
|
|
49
48
|
export const CAPTURE_DRAIN_MAX_LINES = 1000;
|
|
50
|
-
const ROW_OVERHEAD_BYTES = 96;
|
|
51
49
|
const SPOOL = "spool.jsonl";
|
|
52
50
|
const DRAINING = "spool.jsonl.draining";
|
|
51
|
+
const SPOOL_DIR = "spool";
|
|
52
|
+
const SPOOL_MAX_FILES = 32;
|
|
53
|
+
const SPOOL_MAX_BYTES = 256 * 1024;
|
|
54
|
+
const SPOOL_MAX_MS = 25;
|
|
55
|
+
const segmentName = /^spool-[0-9a-f-]+\.jsonl(?:\.offset-(\d+)(-skip)?)?(?:\.draining-[0-9a-f]+)?$/;
|
|
56
|
+
let spoolSequence = 0;
|
|
57
|
+
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")}`;
|
|
53
58
|
|
|
54
|
-
export interface CaptureInput {
|
|
55
|
-
project: string; agent: string; event: string;
|
|
56
|
-
tool?: string; paths?: string[]; status?: string; excerpt?: string;
|
|
57
|
-
/** Tests and the spool replay pin the time; hooks leave it to the store clock. */
|
|
58
|
-
at?: string;
|
|
59
|
-
}
|
|
59
|
+
export interface CaptureInput extends CaptureFields { project: string }
|
|
60
60
|
export interface CaptureRow {
|
|
61
61
|
id: number; at: string; project: string; agent: string; event: string;
|
|
62
62
|
tool: string | null; paths: string[]; status: string | null; excerpt: string;
|
|
@@ -73,10 +73,20 @@ export interface CaptureOptions {
|
|
|
73
73
|
/** Host-supplied R6 values win over the shard and home config files. */
|
|
74
74
|
capMb?: number; retentionDays?: number;
|
|
75
75
|
busyTimeoutMs?: number;
|
|
76
|
+
/** Host-only brief view: open existing capture only, without schema/index/replay writes. */
|
|
77
|
+
readOnly?: boolean;
|
|
78
|
+
/** Host-only row ceiling for the read-only snapshot; overflow is unavailable, never empty. */
|
|
79
|
+
maxReadRows?: number;
|
|
80
|
+
/** Host-only optional brief: retain the newest maxReadRows ids, with explicit partial coverage. */
|
|
81
|
+
recentReadRows?: boolean;
|
|
76
82
|
/** Excerpt screen; defaults to the built-in secret screen. A host passes its stricter policy. */
|
|
77
83
|
screen?: (text: string) => boolean;
|
|
78
84
|
}
|
|
79
|
-
export
|
|
85
|
+
export interface CaptureAppendOptions {
|
|
86
|
+
/** Opaque stable source receipt for deterministic transcript replay. Requires an explicit source timestamp. */
|
|
87
|
+
receipt?: string;
|
|
88
|
+
}
|
|
89
|
+
export type CaptureAppend = { id: number; already?: true } | { spooled: true };
|
|
80
90
|
|
|
81
91
|
/** One row after validation, deny-filtering, bounding and screening: what both the insert and the spool carry. */
|
|
82
92
|
interface NormalizedCapture {
|
|
@@ -151,24 +161,77 @@ export class CaptureStore {
|
|
|
151
161
|
private readonly retentionDays: number;
|
|
152
162
|
/** Set when the schema setup itself met the lock: appends spool without touching the database again. */
|
|
153
163
|
private contended = false;
|
|
164
|
+
private readonly readOnly: boolean;
|
|
165
|
+
private recentReadIds?: number[];
|
|
166
|
+
/** True when the optional recent snapshot excludes older capture rows. */
|
|
167
|
+
readonly recentReadTruncated: boolean = false;
|
|
154
168
|
|
|
155
169
|
constructor(opts: CaptureOptions) {
|
|
156
170
|
if (typeof opts.project !== "string" || !opts.project.trim()) throw new Error("capture shards need a project key");
|
|
157
171
|
if (opts.memoryDir !== undefined && opts.home !== undefined) throw new Error("choose a home or an exact memory directory");
|
|
172
|
+
this.readOnly = opts.readOnly === true;
|
|
173
|
+
if (opts.readOnly !== undefined && typeof opts.readOnly !== "boolean") throw new Error("invalid capture read-only option");
|
|
174
|
+
if (opts.recentReadRows !== undefined && (typeof opts.recentReadRows !== "boolean" || !this.readOnly)) throw new Error("recent capture rows require a read-only store");
|
|
175
|
+
const maxReadRows = opts.maxReadRows ?? 128;
|
|
176
|
+
if (this.readOnly && (!Number.isSafeInteger(maxReadRows) || maxReadRows < 1 || maxReadRows >= Number.MAX_SAFE_INTEGER)) throw new Error("invalid capture read row bound");
|
|
158
177
|
this.base = opts.memoryDir ?? memoryBase(opts.home);
|
|
159
|
-
const machine = opts.machine ?? machineId(this.base).id;
|
|
178
|
+
const machine = opts.machine ?? (this.readOnly ? this.existingMachine() : machineId(this.base).id);
|
|
179
|
+
if (this.readOnly && !/^[a-f0-9]{16}$/.test(machine)) throw new Error("capture machine identity unavailable");
|
|
160
180
|
this.requestedKey = opts.project;
|
|
161
181
|
this.key = resolveAlias(this.base, opts.project);
|
|
162
|
-
|
|
182
|
+
if (this.readOnly) {
|
|
183
|
+
const descriptor = lstatSync(join(this.base, "projects", shardKeyHash(this.key), "project.json"));
|
|
184
|
+
if (!descriptor.isFile() || descriptor.isSymbolicLink() || descriptor.size > 4096
|
|
185
|
+
|| readShardDescriptor(this.base, shardKeyHash(this.key))?.key !== this.key) throw new Error("capture shard identity unavailable");
|
|
186
|
+
} else ensureShardDescriptor(this.base, this.key);
|
|
163
187
|
this.dir = captureShardDir(this.base, opts.project, machine);
|
|
164
|
-
|
|
165
|
-
|
|
188
|
+
if (!this.readOnly) {
|
|
189
|
+
mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
190
|
+
chmodSync(this.dir, 0o700); // mkdir's mode applies only when it creates the directory
|
|
191
|
+
}
|
|
192
|
+
if (this.readOnly) for (const file of [join(this.dir, "config.json"), join(this.base, "capture-config.json")]) {
|
|
193
|
+
const stat = lstatSync(file, { throwIfNoEntry: false });
|
|
194
|
+
if (stat && (!stat.isFile() || stat.isSymbolicLink() || stat.size > 64 * 1024)) throw new Error("capture configuration unavailable");
|
|
195
|
+
}
|
|
166
196
|
const config = readCaptureConfig(this.dir, this.base); // each invocation: a config change applies at the next hook
|
|
167
197
|
this.capMb = opts.capMb ?? config.capMb;
|
|
168
198
|
this.retentionDays = opts.retentionDays ?? config.retentionDays;
|
|
169
199
|
this.now = opts.now ?? Date.now;
|
|
170
200
|
this.screen = opts.screen ?? looksSecret;
|
|
171
201
|
const file = join(this.dir, "capture.db");
|
|
202
|
+
if (this.readOnly) {
|
|
203
|
+
// Refuse absent/cold state without authoring machine, shard, schema, FTS or spool metadata.
|
|
204
|
+
for (const directory of [this.base, join(this.base, "projects"), join(this.base, "projects", shardKeyHash(this.key)),
|
|
205
|
+
join(this.base, "projects", shardKeyHash(this.key), "machine"), this.dir]) {
|
|
206
|
+
const stat = lstatSync(directory);
|
|
207
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("capture read path unavailable");
|
|
208
|
+
}
|
|
209
|
+
const stat = lstatSync(file);
|
|
210
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("capture database unavailable");
|
|
211
|
+
const timeout = opts.busyTimeoutMs ?? 0;
|
|
212
|
+
if (!Number.isSafeInteger(timeout) || timeout < 0 || timeout > 3000) throw new Error("invalid capture busy timeout");
|
|
213
|
+
const db = this.db = new Database(file, { readonly: true });
|
|
214
|
+
try {
|
|
215
|
+
db.run(`PRAGMA busy_timeout = ${timeout}`); db.run("PRAGMA cache_size = -2048"); db.run("PRAGMA mmap_size = 0");
|
|
216
|
+
db.run("BEGIN");
|
|
217
|
+
if (opts.recentReadRows) {
|
|
218
|
+
// The existing rows_at index supplies the reverse time/id walk; retain ids only.
|
|
219
|
+
// One sentinel records excluded history without counting or loading its bodies.
|
|
220
|
+
const sql = "SELECT id FROM rows INDEXED BY rows_at ORDER BY at DESC, id DESC LIMIT ?";
|
|
221
|
+
const plan = db.query(`EXPLAIN QUERY PLAN ${sql}`).all(maxReadRows + 1) as { detail: string }[];
|
|
222
|
+
if (plan.some(row => row.detail.includes("TEMP B-TREE"))) throw new Error("capture recent index unavailable");
|
|
223
|
+
const recent = db.query(sql).all(maxReadRows + 1) as { id: number }[];
|
|
224
|
+
this.recentReadIds = recent.slice(0, maxReadRows).map(row => row.id);
|
|
225
|
+
this.recentReadTruncated = recent.length > maxReadRows;
|
|
226
|
+
} else {
|
|
227
|
+
const count = (db.query("SELECT COUNT(*) AS n FROM (SELECT 1 FROM rows LIMIT ?)").get(maxReadRows + 1) as { n: number }).n;
|
|
228
|
+
if (count > maxReadRows) throw new Error("capture brief row bound exceeded");
|
|
229
|
+
}
|
|
230
|
+
if (resolveAlias(this.base, this.requestedKey) !== this.key) throw new Error("capture alias binding changed");
|
|
231
|
+
} catch (error) { db.close(); throw error; }
|
|
232
|
+
this.migrated = 0;
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
172
235
|
const db = new Database(file, { create: true });
|
|
173
236
|
this.db = db;
|
|
174
237
|
try { chmodSync(file, 0o600); } catch { /* best effort */ }
|
|
@@ -182,12 +245,26 @@ export class CaptureStore {
|
|
|
182
245
|
db.run("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
|
|
183
246
|
this.ensureFts();
|
|
184
247
|
} catch (error) {
|
|
185
|
-
if (!lockError(error)) throw error;
|
|
248
|
+
if (!lockError(error)) { db.close(); throw error; }
|
|
186
249
|
this.contended = true; // R2: the hook spools and exits instead of waiting out another writer
|
|
187
250
|
}
|
|
188
251
|
this.migrated = this.contended ? 0 : this.migrateLegacy();
|
|
189
252
|
}
|
|
190
253
|
|
|
254
|
+
private existingMachine(): string {
|
|
255
|
+
const file = join(this.base, "machine.json"), stat = lstatSync(file);
|
|
256
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4096) throw new Error("capture machine identity unavailable");
|
|
257
|
+
const value = JSON.parse(readFileSync(file, "utf8")) as { id?: unknown };
|
|
258
|
+
if (typeof value?.id !== "string" || !/^[a-f0-9]{16}$/.test(value.id)) throw new Error("capture machine identity unavailable");
|
|
259
|
+
return value.id;
|
|
260
|
+
}
|
|
261
|
+
private writable(): void {
|
|
262
|
+
if (this.readOnly) throw new Error("capture store is read-only");
|
|
263
|
+
}
|
|
264
|
+
private checkReadBinding(): void {
|
|
265
|
+
if (this.readOnly && resolveAlias(this.base, this.requestedKey) !== this.key) throw new Error("capture alias binding changed");
|
|
266
|
+
}
|
|
267
|
+
|
|
191
268
|
/** 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. */
|
|
192
269
|
private migrateLegacy(): number {
|
|
193
270
|
const legacyDir = join(this.base, "capture"); // the pre-T1 machine-wide location (this branch never shipped)
|
|
@@ -239,7 +316,13 @@ export class CaptureStore {
|
|
|
239
316
|
|
|
240
317
|
/** A line moved from the legacy spool, byte for byte: the shard's drain normalizes whatever format it holds. */
|
|
241
318
|
private spoolRaw(line: string): void {
|
|
242
|
-
|
|
319
|
+
const directory = join(this.dir, SPOOL_DIR), writing = join(directory, ".writing");
|
|
320
|
+
mkdirSync(writing, { recursive: true, mode: 0o700 });
|
|
321
|
+
const name = `spool-${spoolToken()}.jsonl`, temporary = join(writing, name);
|
|
322
|
+
try {
|
|
323
|
+
writeFileSync(temporary, line + "\n", { mode: 0o600, flag: "wx" }); // closes before readers can see it
|
|
324
|
+
renameSync(temporary, join(directory, name));
|
|
325
|
+
} finally { rmSync(temporary, { force: true }); }
|
|
243
326
|
}
|
|
244
327
|
|
|
245
328
|
/** External-content FTS5 over the excerpts, kept in sync by triggers, so capture search needs no catch-up pass. */
|
|
@@ -254,6 +337,30 @@ export class CaptureStore {
|
|
|
254
337
|
if (!had && (this.db.query("SELECT COUNT(*) AS n FROM rows").get() as { n: number }).n > 0) this.db.run("INSERT INTO fts (fts) VALUES ('rebuild')");
|
|
255
338
|
}
|
|
256
339
|
|
|
340
|
+
private mcpReady = false;
|
|
341
|
+
/** One-time MCP read cost only: hooks neither create this derived index nor scan history to fill it. */
|
|
342
|
+
private ensureMcpFts(): boolean {
|
|
343
|
+
if (this.readOnly) return !!this.db.query("SELECT 1 FROM sqlite_master WHERE name = 'mcp_fts' AND type = 'table'").get();
|
|
344
|
+
if (!fts5Available()) return false;
|
|
345
|
+
if (this.mcpReady) return true;
|
|
346
|
+
try {
|
|
347
|
+
this.db.transaction(() => {
|
|
348
|
+
const hadMcp = !!this.db.query("SELECT 1 FROM sqlite_master WHERE name = 'mcp_fts' AND type = 'table'").get();
|
|
349
|
+
const body = (row: string) => `${row}.excerpt || char(10) || COALESCE((SELECT group_concat(value, ' ') FROM json_each(${row}.paths)), '') || char(10) || ${row}.event`;
|
|
350
|
+
this.db.run("CREATE VIRTUAL TABLE IF NOT EXISTS mcp_fts USING fts5(body)");
|
|
351
|
+
this.db.run(`CREATE TRIGGER IF NOT EXISTS rows_mcp_ai AFTER INSERT ON rows BEGIN INSERT INTO mcp_fts(rowid, body) VALUES (new.id, ${body("new")}); END`);
|
|
352
|
+
this.db.run("CREATE TRIGGER IF NOT EXISTS rows_mcp_ad AFTER DELETE ON rows BEGIN DELETE FROM mcp_fts WHERE rowid = old.id; END");
|
|
353
|
+
this.db.run(`CREATE TRIGGER IF NOT EXISTS rows_mcp_au AFTER UPDATE ON rows BEGIN DELETE FROM mcp_fts WHERE rowid = old.id; INSERT INTO mcp_fts(rowid, body) VALUES (new.id, ${body("new")}); END`);
|
|
354
|
+
if (!hadMcp) this.db.run(`INSERT INTO mcp_fts(rowid, body) SELECT rows.id, ${body("rows")} FROM rows`);
|
|
355
|
+
}).immediate();
|
|
356
|
+
this.mcpReady = true;
|
|
357
|
+
return true;
|
|
358
|
+
} catch {
|
|
359
|
+
// Missing/unavailable derived search is not a capture read failure. Retry on the next independent read.
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
257
364
|
private bytes(): number {
|
|
258
365
|
return Number((this.db.query("SELECT value FROM meta WHERE key = 'bytes'").get() as { value: string } | null)?.value ?? "0");
|
|
259
366
|
}
|
|
@@ -267,74 +374,127 @@ export class CaptureStore {
|
|
|
267
374
|
* and a later drainer — under any screen — inserts exactly that row. On lock contention the row goes to the spool
|
|
268
375
|
* and the caller exits fast (R2); the next uncontended writer replays it with its original timestamp.
|
|
269
376
|
*/
|
|
270
|
-
append(input: CaptureInput): CaptureAppend {
|
|
377
|
+
append(input: CaptureInput, options: CaptureAppendOptions = {}): CaptureAppend {
|
|
378
|
+
this.writable();
|
|
271
379
|
const str = (v: unknown, max: number) => typeof v === "string" && v.length > 0 && v.length <= max;
|
|
272
380
|
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");
|
|
273
381
|
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");
|
|
382
|
+
const receipt = options.receipt;
|
|
383
|
+
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");
|
|
274
384
|
const row = this.normalize(input);
|
|
385
|
+
// Reuse the immutable spool format and its existing unique index: a crash between capture and cursor
|
|
386
|
+
// advance replays exactly the same screened line, whether first written directly or under contention.
|
|
387
|
+
const spoolId = receipt === undefined ? null : createHash("sha256").update(this.spoolLine(row, receipt)).digest("hex");
|
|
275
388
|
if (this.contended) {
|
|
276
|
-
this.spool(row);
|
|
389
|
+
this.spool(row, receipt);
|
|
277
390
|
return { spooled: true };
|
|
278
391
|
}
|
|
279
392
|
try {
|
|
280
393
|
this.drain(); // replay earlier spooled rows first, so row ids stay in hook order
|
|
281
394
|
// The row and its running byte count commit or roll back together: a lock after the INSERT cannot spool a duplicate.
|
|
282
|
-
const
|
|
395
|
+
const result = this.db.transaction((): CaptureAppend => {
|
|
396
|
+
const id = this.insertRow(row, spoolId);
|
|
397
|
+
if (id !== undefined) return { id };
|
|
398
|
+
const prior = this.db.query("SELECT id FROM rows WHERE spool_id = ?").get(spoolId) as { id: number } | null;
|
|
399
|
+
if (!prior) throw new Error("capture receipt lookup failed");
|
|
400
|
+
return { id: prior.id, already: true };
|
|
401
|
+
}).immediate();
|
|
283
402
|
try { this.evict(); } catch (error) {
|
|
284
403
|
if (!lockError(error)) throw error; // the row is already committed; a locked eviction simply resumes on a later append
|
|
285
404
|
}
|
|
286
|
-
return
|
|
405
|
+
return result;
|
|
287
406
|
} catch (error) {
|
|
288
407
|
if (!lockError(error)) throw error;
|
|
289
|
-
this.spool(row);
|
|
408
|
+
this.spool(row, receipt);
|
|
290
409
|
return { spooled: true };
|
|
291
410
|
}
|
|
292
411
|
}
|
|
293
412
|
|
|
294
413
|
/** The one place input becomes a row: caps fields, deny-filters paths, screens the excerpt, counts its bytes. */
|
|
295
414
|
private normalize(input: CaptureInput): NormalizedCapture {
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
const at = new Date(input.at === undefined ? this.now() : Date.parse(input.at)).toISOString();
|
|
299
|
-
const tool = str(input.tool, 64) ? input.tool! : null;
|
|
300
|
-
const status = str(input.status, 64) ? input.status! : null;
|
|
301
|
-
const paths = (input.paths ?? []).map(String).filter((p) => p && !isDenied(p)).slice(0, CAPTURE_MAX_PATHS).map((p) => p.slice(0, 400));
|
|
302
|
-
let excerpt = (input.excerpt ?? "").slice(0, CAPTURE_MAX_EXCERPT);
|
|
303
|
-
if (excerpt && this.screen(excerpt)) excerpt = "[secret-screened]";
|
|
304
|
-
return { at, project: input.project, agent: input.agent, event: input.event, tool, paths, status, excerpt,
|
|
305
|
-
bytes: Buffer.byteLength(excerpt) + Buffer.byteLength(JSON.stringify(paths)) + ROW_OVERHEAD_BYTES };
|
|
415
|
+
const { at, agent, event, tool, paths, status, excerpt, bytes } = normalizeCaptureFields(input, () => this.now(), text => this.screen(text));
|
|
416
|
+
return { at, project: input.project, agent, event, tool, paths, status, excerpt, bytes };
|
|
306
417
|
}
|
|
307
418
|
|
|
308
|
-
private
|
|
309
|
-
|
|
419
|
+
private spoolLine(row: NormalizedCapture, nonce = randomBytes(12).toString("hex")): string {
|
|
420
|
+
return JSON.stringify({ ...row, nonce });
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
private spool(row: NormalizedCapture, nonce?: string): void {
|
|
424
|
+
this.spoolRaw(this.spoolLine(row, nonce));
|
|
310
425
|
}
|
|
311
426
|
|
|
312
427
|
/**
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
428
|
+
* Immutable segments with unique claims: a stale drainer can remove only its own old name. Recovery may
|
|
429
|
+
* steal a live claim; both readers can replay it safely because row ids dedupe and neither mutates its inode.
|
|
430
|
+
* Offset acknowledgement is another rename, after commit, so a crash always leaves replayable evidence.
|
|
431
|
+
* ponytail: unpublished files left in .writing by a killed writer are not replayed (they may be partial).
|
|
432
|
+
* Quiescent maintenance may remove them; durable recovery of an interrupted append needs a journal/fsync.
|
|
433
|
+
* Legacy fixed names are accepted for upgrade, but old-version writers must stop before upgrading: their
|
|
434
|
+
* already-open shared inode cannot be made immutable by a newer reader.
|
|
317
435
|
*/
|
|
318
436
|
drain(): { drained: number; skipped: number } {
|
|
319
|
-
|
|
437
|
+
this.writable();
|
|
438
|
+
let budget = CAPTURE_DRAIN_MAX_LINES, byteBudget = SPOOL_MAX_BYTES, drained = 0, skipped = 0;
|
|
320
439
|
if (this.contended) return { drained, skipped };
|
|
440
|
+
const started = performance.now();
|
|
441
|
+
const directory = join(this.dir, SPOOL_DIR);
|
|
442
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
443
|
+
const candidates: { file: string; name: string; offset: number; skip: boolean }[] = [];
|
|
444
|
+
// Fixed-name imports are only compatibility inputs; all acknowledgements use unique names thereafter.
|
|
445
|
+
for (const name of [DRAINING, SPOOL]) {
|
|
446
|
+
if (existsSync(join(this.dir, name))) candidates.push({ file: join(this.dir, name), name: `spool-${spoolToken()}.jsonl`, offset: 0, skip: false });
|
|
447
|
+
}
|
|
448
|
+
const entries = opendirSync(directory);
|
|
449
|
+
try {
|
|
450
|
+
for (let scanned = 0; scanned < 256 && candidates.length < SPOOL_MAX_FILES; scanned++) {
|
|
451
|
+
const entry = entries.readSync();
|
|
452
|
+
if (!entry) break;
|
|
453
|
+
const match = segmentName.exec(entry.name);
|
|
454
|
+
if (entry.isFile() && match) candidates.push({ file: join(directory, entry.name), name: entry.name.split(".offset-")[0]!.split(".draining-")[0]!,
|
|
455
|
+
offset: Number(match[1] ?? 0), skip: !!match[2] });
|
|
456
|
+
}
|
|
457
|
+
} finally { entries.closeSync(); }
|
|
321
458
|
const validRow = (row: Partial<NormalizedCapture>): row is NormalizedCapture =>
|
|
322
459
|
typeof row.at === "string" && row.at.length > 0 && typeof row.project === "string" && row.project.length > 0
|
|
323
460
|
&& typeof row.agent === "string" && row.agent.length > 0 && typeof row.event === "string" && row.event.length > 0
|
|
324
461
|
&& (row.tool === null || typeof row.tool === "string") && Array.isArray(row.paths) && row.paths.every((p) => typeof p === "string")
|
|
325
462
|
&& (row.status === null || typeof row.status === "string") && typeof row.excerpt === "string"
|
|
326
463
|
&& typeof row.bytes === "number" && Number.isFinite(row.bytes) && row.bytes >= 0;
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
if (
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
464
|
+
candidates.sort((a, b) => a.name.localeCompare(b.name));
|
|
465
|
+
for (const source of candidates) {
|
|
466
|
+
if (budget <= 0 || byteBudget <= 0 || performance.now() - started >= SPOOL_MAX_MS) break;
|
|
467
|
+
const file = join(directory, `${source.name}.offset-${source.offset}${source.skip ? "-skip" : ""}.draining-${randomBytes(12).toString("hex")}`);
|
|
468
|
+
try { renameSync(source.file, file); } catch { continue; } // another drainer claimed this exact name
|
|
469
|
+
let bytes: Buffer, size: number;
|
|
470
|
+
try {
|
|
471
|
+
const fd = openSync(file, "r");
|
|
472
|
+
try {
|
|
473
|
+
size = statSync(file).size;
|
|
474
|
+
bytes = Buffer.alloc(Math.min(byteBudget, Math.max(0, size - source.offset)));
|
|
475
|
+
bytes = bytes.subarray(0, readSync(fd, bytes, 0, bytes.length, source.offset));
|
|
476
|
+
} finally { closeSync(fd); }
|
|
477
|
+
} catch { continue; } // a recovery reader may already have moved our unique claim
|
|
478
|
+
byteBudget -= bytes.length;
|
|
479
|
+
let consumed = 0, skipping = source.skip;
|
|
480
|
+
const batch: string[] = [];
|
|
481
|
+
while (consumed < bytes.length && batch.length < budget) {
|
|
482
|
+
const newline = bytes.indexOf(10, consumed);
|
|
483
|
+
if (newline < 0) {
|
|
484
|
+
if (source.offset + bytes.length < size) {
|
|
485
|
+
// A short window may only be the allowance left after another file, not an oversized row.
|
|
486
|
+
if (consumed === 0 && bytes.length === SPOOL_MAX_BYTES) { consumed = bytes.length; skipping = true; }
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
489
|
+
if (!skipping) batch.push(bytes.subarray(consumed).toString("utf8"));
|
|
490
|
+
else skipped++;
|
|
491
|
+
consumed = bytes.length; skipping = false; break;
|
|
492
|
+
}
|
|
493
|
+
const line = bytes.subarray(consumed, newline).toString("utf8");
|
|
494
|
+
consumed = newline + 1;
|
|
495
|
+
if (skipping) { skipped++; skipping = false; }
|
|
496
|
+
else if (line) batch.push(line);
|
|
333
497
|
}
|
|
334
|
-
if (!existsSync(file)) continue;
|
|
335
|
-
const lines = readFileSync(file, "utf8").split("\n").filter(Boolean);
|
|
336
|
-
if (!lines.length) { rmSync(file, { force: true }); continue; }
|
|
337
|
-
const batch = lines.slice(0, Math.max(0, budget));
|
|
338
498
|
try {
|
|
339
499
|
this.db.transaction(() => {
|
|
340
500
|
for (const line of batch) {
|
|
@@ -356,8 +516,12 @@ export class CaptureStore {
|
|
|
356
516
|
break; // still contended: the spool stays for the next invocation
|
|
357
517
|
}
|
|
358
518
|
budget -= batch.length;
|
|
359
|
-
|
|
360
|
-
|
|
519
|
+
const next = source.offset + consumed;
|
|
520
|
+
if (next >= size) rmSync(file, { force: true });
|
|
521
|
+
else {
|
|
522
|
+
// Never rewrite a shared inode. Only the owner of this exact claim can acknowledge its prefix.
|
|
523
|
+
try { renameSync(file, join(directory, `${source.name}.offset-${next}${skipping ? "-skip" : ""}`)); } catch { /* recovered elsewhere */ }
|
|
524
|
+
}
|
|
361
525
|
}
|
|
362
526
|
return { drained, skipped };
|
|
363
527
|
}
|
|
@@ -385,6 +549,7 @@ export class CaptureStore {
|
|
|
385
549
|
* deleted; the curated store is a different file this class never opens (AC5).
|
|
386
550
|
*/
|
|
387
551
|
evict(): { expired: number; trimmed: number } {
|
|
552
|
+
this.writable();
|
|
388
553
|
if (this.contended) return { expired: 0, trimmed: 0 };
|
|
389
554
|
const start = performance.now();
|
|
390
555
|
const expired = this.evictWhere("at < ?", [new Date(this.now() - this.retentionDays * 24 * 3600_000).toISOString()], start);
|
|
@@ -417,14 +582,35 @@ export class CaptureStore {
|
|
|
417
582
|
}
|
|
418
583
|
|
|
419
584
|
rows(f: { project?: string; sinceId?: number; limit?: number } = {}): CaptureRow[] {
|
|
585
|
+
this.checkReadBinding();
|
|
420
586
|
const where: string[] = [], bind: (string | number)[] = [];
|
|
587
|
+
if (this.recentReadIds) { where.push("id IN (SELECT value FROM json_each(?))"); bind.push(JSON.stringify(this.recentReadIds)); }
|
|
421
588
|
if (f.project) { where.push("project = ?"); bind.push(f.project); }
|
|
422
589
|
if (f.sinceId !== undefined) { where.push("id > ?"); bind.push(f.sinceId); }
|
|
423
590
|
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[];
|
|
424
591
|
return rows.map(toRow);
|
|
425
592
|
}
|
|
426
593
|
|
|
594
|
+
/** Exact-project, bounded body pages; MCP screens before filling its public candidate window. */
|
|
595
|
+
readPage(window: ReadWindow & { query?: string; match?: "substring" }): { rows: CaptureRow[]; next?: ReadRef; engine?: "fts5" | "substring" } {
|
|
596
|
+
this.checkReadBinding();
|
|
597
|
+
const { columns, where, bind } = readWindowSql(window, "id");
|
|
598
|
+
if (this.recentReadIds) {
|
|
599
|
+
if (window.query !== undefined) throw new Error("recent capture brief does not support query indexes");
|
|
600
|
+
where.push("id IN (SELECT value FROM json_each(?))"); bind.push(JSON.stringify(this.recentReadIds));
|
|
601
|
+
}
|
|
602
|
+
const engine = window.query === undefined ? undefined : window.match !== "substring" && this.ensureMcpFts() ? "fts5" : "substring";
|
|
603
|
+
if (window.query !== undefined && engine === "fts5") {
|
|
604
|
+
const match = ftsTerms(window.query); if (!match) return { rows: [], engine };
|
|
605
|
+
where.push("id IN (SELECT rowid FROM mcp_fts WHERE mcp_fts MATCH ?)"); bind.push(match);
|
|
606
|
+
}
|
|
607
|
+
const rows = this.db.query(`SELECT *, ${columns} FROM rows WHERE ${where.join(" AND ")} ORDER BY candidate_position, candidate_id LIMIT ?`)
|
|
608
|
+
.all(...bind, window.limit) as (RawRow & { candidate_id: string; candidate_position: number })[];
|
|
609
|
+
return { rows: rows.map(toRow), ...(engine ? { engine } : {}), ...(rows.length ? { next: readRef(rows[rows.length - 1]!) } : {}) };
|
|
610
|
+
}
|
|
611
|
+
|
|
427
612
|
stats(): { rows: number; bytes: number; capMb: number; retentionDays: number; fileBytes: number } {
|
|
613
|
+
this.checkReadBinding();
|
|
428
614
|
let fileBytes = 0;
|
|
429
615
|
try { fileBytes = statSync(join(this.dir, "capture.db")).size; } catch { /* not created yet */ }
|
|
430
616
|
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
|
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
|
|
|
2
2
|
import { chmodSync, existsSync, readdirSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { memoryBase } from "./home.ts";
|
|
5
|
-
import {
|
|
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";
|
|
@@ -73,9 +73,8 @@ export function walkShards(base: string): { hash: string; key: string; local: bo
|
|
|
73
73
|
/** What the shards and the alias log say, before anything is persisted. */
|
|
74
74
|
export function scanCatalog(base: string): CatalogProject[] {
|
|
75
75
|
const captured = capturedSelection(base);
|
|
76
|
-
// The latest op per alias: an alias lists only under the key it currently points at, never under a past target
|
|
77
|
-
const latest =
|
|
78
|
-
for (const op of listAliases(base)) latest.set(op.alias, op.key);
|
|
76
|
+
// 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
|
+
const latest = effectiveAliases(base);
|
|
79
78
|
const out: CatalogProject[] = [];
|
|
80
79
|
for (const shard of walkShards(base)) {
|
|
81
80
|
let rows = 0, bytes = 0, lastAt: string | null = null;
|
package/src/memory/import.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { realPath } from "./platform.ts";
|
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { KINDS, looksSecret, type Kind, type MemoryStore } from "./store.ts";
|
|
8
|
-
import {
|
|
8
|
+
import { aliasedKeys, resolveAlias } from "./shard.ts";
|
|
9
9
|
import { WorkListStore, type WorkListFields, type WorkListPolicy } from "./work-lists.ts";
|
|
10
10
|
|
|
11
11
|
/** The claude-mem store is another program's: opened read-only, never written, its locks waited on briefly. */
|
|
@@ -27,8 +27,7 @@ export async function importClaudeMemWorkState(opts: { base: string; project: st
|
|
|
27
27
|
const limit = opts.limit ?? 10_000;
|
|
28
28
|
if (!Number.isSafeInteger(limit) || limit <= 0) throw new Error("--limit takes a positive integer");
|
|
29
29
|
const project = resolveAlias(opts.base, opts.project);
|
|
30
|
-
const aliases =
|
|
31
|
-
.filter(([, key]) => key === project).map(([alias]) => alias).sort();
|
|
30
|
+
const aliases = aliasedKeys(opts.base, project);
|
|
32
31
|
const result: WorkStateImportResult = { read: 0, added: 0, skipped: { invalid: 0, policy: 0 }, more: false, cursor: 0 };
|
|
33
32
|
if (!aliases.length) return result;
|
|
34
33
|
const db = new Database(file, { readonly: true });
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { linkSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export interface Machine { id: string; label: string }
|
|
6
|
+
/** Random per `~/.agenthub`, with a person-given label; created on first use. */
|
|
7
|
+
export function machineId(dir: string): Machine {
|
|
8
|
+
const file = join(dir, "machine.json");
|
|
9
|
+
const read = (): Machine | undefined => {
|
|
10
|
+
try {
|
|
11
|
+
const m = JSON.parse(readFileSync(file, "utf8")) as Machine;
|
|
12
|
+
if (/^[0-9a-f]{16}$/.test(m?.id)) return { id: m.id, label: typeof m.label === "string" ? m.label : m.id };
|
|
13
|
+
} catch { /* none, or unreadable */ }
|
|
14
|
+
return undefined;
|
|
15
|
+
};
|
|
16
|
+
const found = read();
|
|
17
|
+
if (found) return found;
|
|
18
|
+
// Hubs of several projects can start at once on a fresh home: each writes its own temp file and links it into place,
|
|
19
|
+
// which fails if another got there first; then everyone reads the one identity that won.
|
|
20
|
+
const id = randomBytes(8).toString("hex");
|
|
21
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
22
|
+
const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
23
|
+
writeFileSync(tmp, JSON.stringify({ id, label: id }) + "\n", { mode: 0o600 });
|
|
24
|
+
try { linkSync(tmp, file); } catch { /* another process created it: its identity wins */ } finally { rmSync(tmp, { force: true }); }
|
|
25
|
+
// A file that is there but holds no valid id is never overwritten (another process may be reading it): the store
|
|
26
|
+
// fails to open, so the hub says hub memory is unavailable instead of filing notes under no machine.
|
|
27
|
+
const won = read();
|
|
28
|
+
if (!won) throw new Error(`${file} holds no valid machine id; remove it and a new one is made`);
|
|
29
|
+
return won;
|
|
30
|
+
}
|
|
31
|
+
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, openSync, readSync, appendFileSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Rolling resource samples; importing this leaf never loads a memory store or capacity probe. */
|
|
5
|
+
export interface MetricSample { at: string; kind: "hook" | "search"; wallMs?: number; rssMB?: number; rawRssMB?: number; idleMB?: number; captureDrops?: number; captureDropReason?: CaptureDropReason }
|
|
6
|
+
const METRICS_MAX_BYTES = 64 * 1024, METRICS_KEEP = 200;
|
|
7
|
+
|
|
8
|
+
export function metricsPath(base: string): string {
|
|
9
|
+
return join(base, "metrics.jsonl");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Append samples, trimming the rolling file when it outgrows its bound (newest samples win). */
|
|
13
|
+
export function appendMetrics(base: string, samples: MetricSample[]): void {
|
|
14
|
+
mkdirSync(base, { recursive: true, mode: 0o700 });
|
|
15
|
+
const file = metricsPath(base);
|
|
16
|
+
appendFileSync(file, samples.map((s) => JSON.stringify(s)).join("\n") + "\n", { mode: 0o600 });
|
|
17
|
+
try {
|
|
18
|
+
if (statSync(file).size <= METRICS_MAX_BYTES) return;
|
|
19
|
+
const lines = readFileSync(file, "utf8").split("\n").filter(Boolean).slice(-METRICS_KEEP);
|
|
20
|
+
writeFileSync(file, lines.join("\n") + "\n", { mode: 0o600 });
|
|
21
|
+
} catch { /* a trim failure loses nothing: the file simply stays larger */ }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The newest samples first-preserved order (oldest to newest); malformed lines are skipped. */
|
|
25
|
+
export function readMetrics(base: string, limit = METRICS_KEEP): MetricSample[] {
|
|
26
|
+
let lines: string[] = [];
|
|
27
|
+
try { lines = readFileSync(metricsPath(base), "utf8").split("\n").filter(Boolean); } catch { return []; }
|
|
28
|
+
const out: MetricSample[] = [];
|
|
29
|
+
for (const line of lines.slice(-limit)) {
|
|
30
|
+
try {
|
|
31
|
+
const s = JSON.parse(line) as MetricSample;
|
|
32
|
+
if ((s?.kind === "hook" || s?.kind === "search") && typeof s.at === "string") out.push(s);
|
|
33
|
+
} catch { /* a malformed line is skipped */ }
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
export type CaptureDropReason = "spool_full" | "cwd_removed" | "undrainable_expired" | "scope_unknown";
|
|
40
|
+
const DROP_REASONS: readonly CaptureDropReason[] = ["spool_full", "cwd_removed", "undrainable_expired", "scope_unknown"];
|
|
41
|
+
/** Numeric notice only, bounded to the rolling metric tail; never an all-time counter. */
|
|
42
|
+
export function captureDropSummary(base: string): Partial<Record<CaptureDropReason, number>> {
|
|
43
|
+
const counts: Partial<Record<CaptureDropReason, number>> = {};
|
|
44
|
+
let fd: number | undefined;
|
|
45
|
+
try {
|
|
46
|
+
fd = openSync(metricsPath(base), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
47
|
+
const stat = fstatSync(fd); if (!stat.isFile()) return counts;
|
|
48
|
+
const start = Math.max(0, stat.size - METRICS_MAX_BYTES), bytes = Buffer.alloc(stat.size - start);
|
|
49
|
+
const length = readSync(fd, bytes, 0, bytes.length, start);
|
|
50
|
+
let text = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, length));
|
|
51
|
+
if (start) text = text.slice(text.indexOf("\n") + 1);
|
|
52
|
+
for (const line of text.split("\n").filter(Boolean).slice(-METRICS_KEEP)) {
|
|
53
|
+
try {
|
|
54
|
+
const sample = JSON.parse(line) as MetricSample;
|
|
55
|
+
if (sample.kind !== "hook" || !DROP_REASONS.includes(sample.captureDropReason!) || !Number.isSafeInteger(sample.captureDrops) || sample.captureDrops! <= 0) continue;
|
|
56
|
+
const reason = sample.captureDropReason!;
|
|
57
|
+
counts[reason] = (counts[reason] ?? 0) + sample.captureDrops!;
|
|
58
|
+
} catch { /* A malformed metric is never a notice. */ }
|
|
59
|
+
}
|
|
60
|
+
} catch { /* Metric availability never denies the native call. */ }
|
|
61
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
62
|
+
return counts;
|
|
63
|
+
}
|
package/src/memory/mutation.ts
CHANGED
|
@@ -6,10 +6,11 @@ export const MUTATION_BUSY_TIMEOUT_MS = 3000;
|
|
|
6
6
|
// ponytail: older hubs (before consuming this agent-mem), raw/legacy log writers and any segment editor
|
|
7
7
|
// that bypasses this lock are not serialized. Require every writer to take it, or make segment appends API-only.
|
|
8
8
|
/** An empty, machine-local SQLite file used only for crash-safe writer exclusion, never for memory data. */
|
|
9
|
-
export function mutationDatabase(dir: string): Database {
|
|
9
|
+
export function mutationDatabase(dir: string, busyTimeoutMs = MUTATION_BUSY_TIMEOUT_MS): Database {
|
|
10
|
+
if (!Number.isSafeInteger(busyTimeoutMs) || busyTimeoutMs < 0 || busyTimeoutMs > MUTATION_BUSY_TIMEOUT_MS) throw new Error("invalid mutation busy timeout");
|
|
10
11
|
const file = join(dir, "mutation-lock.db"), db = new Database(file, { create: true });
|
|
11
12
|
chmodSync(file, 0o600);
|
|
12
|
-
db.run(`PRAGMA busy_timeout = ${
|
|
13
|
+
db.run(`PRAGMA busy_timeout = ${busyTimeoutMs}`);
|
|
13
14
|
return db;
|
|
14
15
|
}
|
|
15
16
|
|
|
@@ -22,13 +23,14 @@ export function acquireMutation(dir: string): (() => void) | undefined {
|
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
/** Short local admission fence, never held across sync/network or data mutations. */
|
|
25
|
-
export function readerAdmissionDatabase(dir: string): Database {
|
|
26
|
+
export function readerAdmissionDatabase(dir: string, busyTimeoutMs = MUTATION_BUSY_TIMEOUT_MS): Database {
|
|
27
|
+
if (!Number.isSafeInteger(busyTimeoutMs) || busyTimeoutMs < 0 || busyTimeoutMs > MUTATION_BUSY_TIMEOUT_MS) throw new Error("invalid reader admission busy timeout");
|
|
26
28
|
const file = join(dir, "reader-admission.db");
|
|
27
29
|
for (const suffix of ["", "-wal", "-shm", "-journal"]) {
|
|
28
30
|
const stat = lstatSync(file + suffix, { throwIfNoEntry: false });
|
|
29
31
|
if (stat && (stat.isSymbolicLink() || !stat.isFile())) throw new Error("reader admission fence is not a regular file");
|
|
30
32
|
}
|
|
31
33
|
const db = new Database(file, { create: true });
|
|
32
|
-
try { chmodSync(file, 0o600); db.run(`PRAGMA busy_timeout = ${
|
|
34
|
+
try { chmodSync(file, 0o600); db.run(`PRAGMA busy_timeout = ${busyTimeoutMs}`); return db; }
|
|
33
35
|
catch (error) { db.close(); throw error; }
|
|
34
36
|
}
|