@staix/agent-mem 0.5.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 +53 -5
- package/bin/amem.js +26 -3
- package/hooks/hooks.json +81 -0
- package/package.json +3 -1
- package/src/cli.ts +16 -0
- package/src/commands-table.ts +3 -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/memory/aliases.ts +83 -0
- package/src/memory/capacity.ts +23 -48
- package/src/memory/capture-input.ts +31 -0
- package/src/memory/capture.ts +124 -35
- 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/shard.ts +3 -80
- package/src/memory/store.ts +4 -29
- package/src/memory/work-lists.ts +105 -34
- package/src/session-brief-worker.ts +81 -0
- package/src/session-brief.ts +202 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export const SHARD_SCHEMA = "agent-hub.memory.shard/v1";
|
|
5
|
+
|
|
6
|
+
export interface AliasOp { schema: typeof SHARD_SCHEMA; op: "alias"; at: string; machine: string; alias: string; key: string; actor: string }
|
|
7
|
+
/**
|
|
8
|
+
* #27 option 2: the person's removal of one alias. An append-only op in the same local `aliases.jsonl` (never a shared-v1 segment, so no shared-v1 reader sees a new op
|
|
9
|
+
* kind there: an older reader skips the line and keeps resolving the alias, which is the C1 "local only" placement). The latest op for an alias wins, so after an
|
|
10
|
+
* `unalias` the key resolves to itself again, and a later `alias` op maps it again.
|
|
11
|
+
*/
|
|
12
|
+
export interface UnaliasOp { schema: typeof SHARD_SCHEMA; op: "unalias"; at: string; machine: string; alias: string; actor: string }
|
|
13
|
+
export type AliasLogOp = AliasOp | UnaliasOp;
|
|
14
|
+
|
|
15
|
+
export function aliasLogPath(base: string): string {
|
|
16
|
+
return join(base, "aliases.jsonl");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One person-reviewed alias op, appended to `aliases.jsonl`. An alias never chains (its target must not itself be
|
|
21
|
+
* an alias), and a machine that races the append keeps both ops: the latest one wins at read time.
|
|
22
|
+
*/
|
|
23
|
+
export function appendAlias(base: string, input: { alias: string; key: string; actor: string; machine: string; at?: string }): AliasOp {
|
|
24
|
+
const alias = input.alias.trim(), key = input.key.trim();
|
|
25
|
+
if (!alias || !key) throw new Error("an alias needs both keys");
|
|
26
|
+
if (alias === key) throw new Error("an alias cannot target itself");
|
|
27
|
+
if (resolveAlias(base, key) !== key) throw new Error("an alias cannot target another alias");
|
|
28
|
+
// The other direction too, using only the latest op per alias: a target abandoned by a later retarget or a removal is free again.
|
|
29
|
+
const currentTargets = new Set(effectiveAliases(base).values());
|
|
30
|
+
if (currentTargets.has(alias)) throw new Error("an alias target cannot become an alias");
|
|
31
|
+
const op: AliasOp = { schema: SHARD_SCHEMA, op: "alias", at: input.at ?? new Date().toISOString(), machine: input.machine, alias, key, actor: input.actor };
|
|
32
|
+
mkdirSync(base, { recursive: true, mode: 0o700 });
|
|
33
|
+
appendFileSync(aliasLogPath(base), JSON.stringify(op) + "\n", { mode: 0o600 });
|
|
34
|
+
return op;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Every alias and unalias op in order (the full history); a malformed or unknown line is skipped, never fatal. */
|
|
38
|
+
export function listAliases(base: string): AliasLogOp[] {
|
|
39
|
+
let text = "";
|
|
40
|
+
try { text = readFileSync(aliasLogPath(base), "utf8"); } catch { return []; }
|
|
41
|
+
const ops: AliasLogOp[] = [];
|
|
42
|
+
for (const line of text.split("\n").filter(Boolean)) {
|
|
43
|
+
try {
|
|
44
|
+
const op = JSON.parse(line) as AliasLogOp;
|
|
45
|
+
if (op?.schema !== SHARD_SCHEMA || typeof op.alias !== "string") continue;
|
|
46
|
+
if (op.op === "alias" && typeof op.key === "string") ops.push(op);
|
|
47
|
+
else if (op.op === "unalias") ops.push(op);
|
|
48
|
+
} catch { /* a malformed line is skipped */ }
|
|
49
|
+
}
|
|
50
|
+
return ops;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The aliases in force: for each alias key the latest op decides, and an `unalias` removes it. Map of alias key to canonical key. */
|
|
54
|
+
export function effectiveAliases(base: string): Map<string, string> {
|
|
55
|
+
const latest = new Map<string, string>();
|
|
56
|
+
for (const op of listAliases(base)) { if (op.op === "alias") latest.set(op.alias, op.key); else latest.delete(op.alias); }
|
|
57
|
+
return latest;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The canonical key for a possibly aliased one: the latest op for it, one hop (aliases never chain). An unaliased key is its own canonical key. */
|
|
61
|
+
export function resolveAlias(base: string, key: string): string {
|
|
62
|
+
return effectiveAliases(base).get(key) ?? key;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Every key currently aliased to `canonical`, sorted (the keys whose curated rows the CLI and MCP project filter now covers, #27 option 1). */
|
|
66
|
+
export function aliasedKeys(base: string, canonical: string): string[] {
|
|
67
|
+
return [...effectiveAliases(base)].filter(([, key]) => key === canonical).map(([alias]) => alias).sort();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* One person-reviewed removal, appended to `aliases.jsonl`. Nothing is rewritten: the alias line stays in the history and the later op wins. Refused when the key is
|
|
72
|
+
* not aliased now, so a typo never records a removal that means nothing.
|
|
73
|
+
*/
|
|
74
|
+
export function appendUnalias(base: string, input: { alias: string; actor: string; machine: string; at?: string }): UnaliasOp {
|
|
75
|
+
const alias = input.alias.trim();
|
|
76
|
+
if (!alias) throw new Error("an alias removal needs the alias key");
|
|
77
|
+
if (!effectiveAliases(base).has(alias)) throw new Error(`no alias to remove: ${alias} is not aliased now`);
|
|
78
|
+
const op: UnaliasOp = { schema: SHARD_SCHEMA, op: "unalias", at: input.at ?? new Date().toISOString(), machine: input.machine, alias, actor: input.actor };
|
|
79
|
+
mkdirSync(base, { recursive: true, mode: 0o700 });
|
|
80
|
+
appendFileSync(aliasLogPath(base), JSON.stringify(op) + "\n", { mode: 0o600 });
|
|
81
|
+
return op;
|
|
82
|
+
}
|
|
83
|
+
|
package/src/memory/capacity.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import { mkdirSync, readFileSync } from "node:fs";
|
|
3
3
|
import { totalmem } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { memoryBase } from "./home.ts";
|
|
6
|
+
import { readMetrics, type MetricSample } from "./metrics.ts";
|
|
7
|
+
export { appendMetrics, readMetrics, metricsPath, type MetricSample } from "./metrics.ts";
|
|
6
8
|
import { walkShards } from "./catalog.ts";
|
|
7
9
|
import { captureShardGrowth, captureShardStats, readCaptureConfigFile, CAPTURE_DEFAULT_CAP_MB, CAPTURE_DEFAULT_RETENTION_DAYS, type CaptureConfig } from "./capture.ts";
|
|
8
10
|
|
|
@@ -11,49 +13,16 @@ import { captureShardGrowth, captureShardStats, readCaptureConfigFile, CAPTURE_D
|
|
|
11
13
|
* `amem capacity` CLI renders it and `amem capacity apply` writes the home default config after a preview. An
|
|
12
14
|
* agent may read the proposal through this library function; applying stays person-only at the CLI.
|
|
13
15
|
*
|
|
14
|
-
* Proposal rule (owner decision, 2026-10-17):
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* Proposal rule (owner decision, 2026-10-17, item 2): the proposed cap and retention are the largest values whose projection stays within the
|
|
17
|
+
* disk-share bound (default 2% of free space, configurable) and whose measured search and hook readings stay within R2/R3, and they may be above
|
|
18
|
+
* the current ones. The cap is the disk-share bound itself; retention is the share over the daily growth, up to a 365-day ceiling. R2 halves the cap
|
|
19
|
+
* and R3 halves the retention. The reason names every binding constraint: `disk-share retention` or `retention ceiling`, `disk-share cap`,
|
|
20
|
+
* `R3 search RSS`, `R2 hook cost`. The advisor never writes: applying stays person-only, preview first.
|
|
19
21
|
*
|
|
20
22
|
* Metrics: a small rolling `metrics.jsonl` in the memory home (not a memory log: it is trimmed, never synced).
|
|
21
23
|
* `scripts/measure-budget.ts` writes only to its synthetic home; the P3 hooks will append real samples per invocation.
|
|
22
24
|
*/
|
|
23
25
|
|
|
24
|
-
export interface MetricSample { at: string; kind: "hook" | "search"; wallMs?: number; rssMB?: number; idleMB?: number }
|
|
25
|
-
const METRICS_MAX_BYTES = 64 * 1024, METRICS_KEEP = 200;
|
|
26
|
-
|
|
27
|
-
export function metricsPath(base: string): string {
|
|
28
|
-
return join(base, "metrics.jsonl");
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Append samples, trimming the rolling file when it outgrows its bound (newest samples win). */
|
|
32
|
-
export function appendMetrics(base: string, samples: MetricSample[]): void {
|
|
33
|
-
mkdirSync(base, { recursive: true, mode: 0o700 });
|
|
34
|
-
const file = metricsPath(base);
|
|
35
|
-
appendFileSync(file, samples.map((s) => JSON.stringify(s)).join("\n") + "\n", { mode: 0o600 });
|
|
36
|
-
try {
|
|
37
|
-
if (statSync(file).size <= METRICS_MAX_BYTES) return;
|
|
38
|
-
const lines = readFileSync(file, "utf8").split("\n").filter(Boolean).slice(-METRICS_KEEP);
|
|
39
|
-
writeFileSync(file, lines.join("\n") + "\n", { mode: 0o600 });
|
|
40
|
-
} catch { /* a trim failure loses nothing: the file simply stays larger */ }
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** The newest samples first-preserved order (oldest to newest); malformed lines are skipped. */
|
|
44
|
-
export function readMetrics(base: string, limit = METRICS_KEEP): MetricSample[] {
|
|
45
|
-
let lines: string[] = [];
|
|
46
|
-
try { lines = readFileSync(metricsPath(base), "utf8").split("\n").filter(Boolean); } catch { return []; }
|
|
47
|
-
const out: MetricSample[] = [];
|
|
48
|
-
for (const line of lines.slice(-limit)) {
|
|
49
|
-
try {
|
|
50
|
-
const s = JSON.parse(line) as MetricSample;
|
|
51
|
-
if ((s?.kind === "hook" || s?.kind === "search") && typeof s.at === "string") out.push(s);
|
|
52
|
-
} catch { /* a malformed line is skipped */ }
|
|
53
|
-
}
|
|
54
|
-
return out;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
26
|
const p95 = (samples: number[]): number | null => samples.length ? [...samples].sort((a, b) => a - b)[Math.max(0, Math.ceil(samples.length * 0.95) - 1)]! : null;
|
|
58
27
|
|
|
59
28
|
function diskReadings(path: string): { freeBytes: number; totalBytes: number } {
|
|
@@ -93,6 +62,8 @@ export interface CapacityReport {
|
|
|
93
62
|
}
|
|
94
63
|
|
|
95
64
|
const MB = 1048576, GROWTH_DAYS = 7, PROJECT_DAYS = 30;
|
|
65
|
+
// ponytail: the advisor never proposes more than a year of retention. The ceiling keeps a quiet home's proposal finite; the upgrade path is a person setting (a configured ceiling) once someone wants longer.
|
|
66
|
+
const RETENTION_CEILING_DAYS = 365;
|
|
96
67
|
|
|
97
68
|
export function capacityReport(opts: {
|
|
98
69
|
home?: string; memoryDir?: string; diskShare?: number; now?: () => number;
|
|
@@ -133,16 +104,20 @@ export function capacityReport(opts: {
|
|
|
133
104
|
// ponytail: the 2026-10-17 owner decision keeps R6 caps per shard in P1, while the disk-share budget is
|
|
134
105
|
// machine-wide; #15 T5 (D7) owns the machine-total split. Until then a home with N shards can hold N x cap.
|
|
135
106
|
const budgetBytes = diskShare * disk.freeBytes;
|
|
136
|
-
|
|
107
|
+
// Growth (owner decision 2026-10-17, item 2): propose the largest cap and retention that fit the disk share, above the current values when there is room.
|
|
108
|
+
// The cap's ceiling is the disk-share bound itself. A person's own setting above the retention ceiling is never proposed down for the ceiling alone.
|
|
109
|
+
const retentionCeiling = Math.max(RETENTION_CEILING_DAYS, current.retentionDays);
|
|
110
|
+
let capMb = Math.max(1, Math.floor(budgetBytes / MB));
|
|
111
|
+
let retentionDays = retentionCeiling;
|
|
137
112
|
const binding: string[] = [];
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
binding.push("disk-share cap: the cap exceeds the disk-share bound");
|
|
113
|
+
const fitDays = bytesPerDay > 0 ? Math.floor(budgetBytes / bytesPerDay) : Infinity;
|
|
114
|
+
if (fitDays < retentionCeiling) {
|
|
115
|
+
retentionDays = Math.max(1, fitDays);
|
|
116
|
+
binding.push("disk-share retention: steady-state capture at the current growth fills the disk-share bound");
|
|
117
|
+
} else {
|
|
118
|
+
binding.push(`retention ceiling: ${retentionCeiling} days fit the disk share, so the ceiling stops growth`);
|
|
145
119
|
}
|
|
120
|
+
binding.push("disk-share cap: the cap is the largest size the disk-share bound allows");
|
|
146
121
|
if (metrics.searchRssMB !== null && metrics.searchRssMB > 200) {
|
|
147
122
|
retentionDays = Math.max(1, Math.floor(retentionDays / 2));
|
|
148
123
|
binding.push("R3 search RSS: measured peak search memory exceeds 200 MB");
|
|
@@ -151,7 +126,7 @@ export function capacityReport(opts: {
|
|
|
151
126
|
capMb = Math.max(1, Math.floor(capMb / 2));
|
|
152
127
|
binding.push("R2 hook cost: measured hook latency or RSS exceeds the budget");
|
|
153
128
|
}
|
|
154
|
-
const reason = binding.
|
|
129
|
+
const reason = binding.join("; ");
|
|
155
130
|
return {
|
|
156
131
|
at: new Date(now()).toISOString(),
|
|
157
132
|
capture: { shards: shards.length, bytes, rows, rowsPerDay, bytesPerDay },
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { looksSecret } from "./screen.ts";
|
|
2
|
+
import { isDenied } from "./platform.ts";
|
|
3
|
+
|
|
4
|
+
export const CAPTURE_MAX_EXCERPT = 2000;
|
|
5
|
+
export const CAPTURE_MAX_PATHS = 32;
|
|
6
|
+
const ROW_OVERHEAD_BYTES = 96;
|
|
7
|
+
|
|
8
|
+
export interface CaptureFields {
|
|
9
|
+
agent: string; event: string;
|
|
10
|
+
tool?: string; paths?: string[]; status?: string; excerpt?: string;
|
|
11
|
+
/** Tests and the spool replay pin the time; hooks leave it to the store clock. */
|
|
12
|
+
at?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface NormalizedCaptureFields {
|
|
15
|
+
at: string; agent: string; event: string;
|
|
16
|
+
tool: string | null; paths: string[]; status: string | null; excerpt: string; bytes: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Pure capture shaping; no store, project lookup or filesystem operation is loaded or invoked here. */
|
|
20
|
+
export function normalizeCaptureFields(input: CaptureFields, now: () => number = Date.now, screen: (text: string) => boolean = looksSecret): NormalizedCaptureFields {
|
|
21
|
+
const str = (v: unknown, max: number) => typeof v === "string" && v.length > 0 && v.length <= max;
|
|
22
|
+
// Canonical UTC ISO text, so text order is time order for eviction (`at < cutoff`) and search (`at >= notBefore`).
|
|
23
|
+
const at = new Date(input.at === undefined ? now() : Date.parse(input.at)).toISOString();
|
|
24
|
+
const tool = str(input.tool, 64) ? input.tool! : null;
|
|
25
|
+
const status = str(input.status, 64) ? input.status! : null;
|
|
26
|
+
const paths = (input.paths ?? []).map(String).filter((p) => p && !isDenied(p)).slice(0, CAPTURE_MAX_PATHS).map((p) => p.slice(0, 400));
|
|
27
|
+
let excerpt = (input.excerpt ?? "").slice(0, CAPTURE_MAX_EXCERPT);
|
|
28
|
+
if (excerpt && screen(excerpt)) excerpt = "[secret-screened]";
|
|
29
|
+
return { at, agent: input.agent, event: input.event, tool, paths, status, excerpt,
|
|
30
|
+
bytes: Buffer.byteLength(excerpt) + Buffer.byteLength(JSON.stringify(paths)) + ROW_OVERHEAD_BYTES };
|
|
31
|
+
}
|
package/src/memory/capture.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
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 { machineId } from "./
|
|
9
|
+
import { captureShardDir, ensureShardDescriptor, readShardDescriptor, resolveAlias, shardKeyHash } from "./shard.ts";
|
|
10
|
+
import { machineId } from "./machine.ts";
|
|
10
11
|
import { readWindowSql, readRef, type ReadWindow, type ReadRef, ftsTerms, fts5Available } from "./search-fts.ts";
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -39,15 +40,12 @@ 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";
|
|
53
51
|
const SPOOL_DIR = "spool";
|
|
@@ -58,12 +56,7 @@ const segmentName = /^spool-[0-9a-f-]+\.jsonl(?:\.offset-(\d+)(-skip)?)?(?:\.dra
|
|
|
58
56
|
let spoolSequence = 0;
|
|
59
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")}`;
|
|
60
58
|
|
|
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
|
-
}
|
|
59
|
+
export interface CaptureInput extends CaptureFields { project: string }
|
|
67
60
|
export interface CaptureRow {
|
|
68
61
|
id: number; at: string; project: string; agent: string; event: string;
|
|
69
62
|
tool: string | null; paths: string[]; status: string | null; excerpt: string;
|
|
@@ -80,10 +73,20 @@ export interface CaptureOptions {
|
|
|
80
73
|
/** Host-supplied R6 values win over the shard and home config files. */
|
|
81
74
|
capMb?: number; retentionDays?: number;
|
|
82
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;
|
|
83
82
|
/** Excerpt screen; defaults to the built-in secret screen. A host passes its stricter policy. */
|
|
84
83
|
screen?: (text: string) => boolean;
|
|
85
84
|
}
|
|
86
|
-
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 };
|
|
87
90
|
|
|
88
91
|
/** One row after validation, deny-filtering, bounding and screening: what both the insert and the spool carry. */
|
|
89
92
|
interface NormalizedCapture {
|
|
@@ -158,24 +161,77 @@ export class CaptureStore {
|
|
|
158
161
|
private readonly retentionDays: number;
|
|
159
162
|
/** Set when the schema setup itself met the lock: appends spool without touching the database again. */
|
|
160
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;
|
|
161
168
|
|
|
162
169
|
constructor(opts: CaptureOptions) {
|
|
163
170
|
if (typeof opts.project !== "string" || !opts.project.trim()) throw new Error("capture shards need a project key");
|
|
164
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");
|
|
165
177
|
this.base = opts.memoryDir ?? memoryBase(opts.home);
|
|
166
|
-
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");
|
|
167
180
|
this.requestedKey = opts.project;
|
|
168
181
|
this.key = resolveAlias(this.base, opts.project);
|
|
169
|
-
|
|
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);
|
|
170
187
|
this.dir = captureShardDir(this.base, opts.project, machine);
|
|
171
|
-
|
|
172
|
-
|
|
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
|
+
}
|
|
173
196
|
const config = readCaptureConfig(this.dir, this.base); // each invocation: a config change applies at the next hook
|
|
174
197
|
this.capMb = opts.capMb ?? config.capMb;
|
|
175
198
|
this.retentionDays = opts.retentionDays ?? config.retentionDays;
|
|
176
199
|
this.now = opts.now ?? Date.now;
|
|
177
200
|
this.screen = opts.screen ?? looksSecret;
|
|
178
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
|
+
}
|
|
179
235
|
const db = new Database(file, { create: true });
|
|
180
236
|
this.db = db;
|
|
181
237
|
try { chmodSync(file, 0o600); } catch { /* best effort */ }
|
|
@@ -195,6 +251,20 @@ export class CaptureStore {
|
|
|
195
251
|
this.migrated = this.contended ? 0 : this.migrateLegacy();
|
|
196
252
|
}
|
|
197
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
|
+
|
|
198
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. */
|
|
199
269
|
private migrateLegacy(): number {
|
|
200
270
|
const legacyDir = join(this.base, "capture"); // the pre-T1 machine-wide location (this branch never shipped)
|
|
@@ -270,6 +340,7 @@ export class CaptureStore {
|
|
|
270
340
|
private mcpReady = false;
|
|
271
341
|
/** One-time MCP read cost only: hooks neither create this derived index nor scan history to fill it. */
|
|
272
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();
|
|
273
344
|
if (!fts5Available()) return false;
|
|
274
345
|
if (this.mcpReady) return true;
|
|
275
346
|
try {
|
|
@@ -303,46 +374,54 @@ export class CaptureStore {
|
|
|
303
374
|
* and a later drainer — under any screen — inserts exactly that row. On lock contention the row goes to the spool
|
|
304
375
|
* and the caller exits fast (R2); the next uncontended writer replays it with its original timestamp.
|
|
305
376
|
*/
|
|
306
|
-
append(input: CaptureInput): CaptureAppend {
|
|
377
|
+
append(input: CaptureInput, options: CaptureAppendOptions = {}): CaptureAppend {
|
|
378
|
+
this.writable();
|
|
307
379
|
const str = (v: unknown, max: number) => typeof v === "string" && v.length > 0 && v.length <= max;
|
|
308
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");
|
|
309
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");
|
|
310
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");
|
|
311
388
|
if (this.contended) {
|
|
312
|
-
this.spool(row);
|
|
389
|
+
this.spool(row, receipt);
|
|
313
390
|
return { spooled: true };
|
|
314
391
|
}
|
|
315
392
|
try {
|
|
316
393
|
this.drain(); // replay earlier spooled rows first, so row ids stay in hook order
|
|
317
394
|
// The row and its running byte count commit or roll back together: a lock after the INSERT cannot spool a duplicate.
|
|
318
|
-
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();
|
|
319
402
|
try { this.evict(); } catch (error) {
|
|
320
403
|
if (!lockError(error)) throw error; // the row is already committed; a locked eviction simply resumes on a later append
|
|
321
404
|
}
|
|
322
|
-
return
|
|
405
|
+
return result;
|
|
323
406
|
} catch (error) {
|
|
324
407
|
if (!lockError(error)) throw error;
|
|
325
|
-
this.spool(row);
|
|
408
|
+
this.spool(row, receipt);
|
|
326
409
|
return { spooled: true };
|
|
327
410
|
}
|
|
328
411
|
}
|
|
329
412
|
|
|
330
413
|
/** The one place input becomes a row: caps fields, deny-filters paths, screens the excerpt, counts its bytes. */
|
|
331
414
|
private normalize(input: CaptureInput): NormalizedCapture {
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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 };
|
|
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 };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
private spoolLine(row: NormalizedCapture, nonce = randomBytes(12).toString("hex")): string {
|
|
420
|
+
return JSON.stringify({ ...row, nonce });
|
|
342
421
|
}
|
|
343
422
|
|
|
344
|
-
private spool(row: NormalizedCapture): void {
|
|
345
|
-
this.spoolRaw(
|
|
423
|
+
private spool(row: NormalizedCapture, nonce?: string): void {
|
|
424
|
+
this.spoolRaw(this.spoolLine(row, nonce));
|
|
346
425
|
}
|
|
347
426
|
|
|
348
427
|
/**
|
|
@@ -355,6 +434,7 @@ export class CaptureStore {
|
|
|
355
434
|
* already-open shared inode cannot be made immutable by a newer reader.
|
|
356
435
|
*/
|
|
357
436
|
drain(): { drained: number; skipped: number } {
|
|
437
|
+
this.writable();
|
|
358
438
|
let budget = CAPTURE_DRAIN_MAX_LINES, byteBudget = SPOOL_MAX_BYTES, drained = 0, skipped = 0;
|
|
359
439
|
if (this.contended) return { drained, skipped };
|
|
360
440
|
const started = performance.now();
|
|
@@ -469,6 +549,7 @@ export class CaptureStore {
|
|
|
469
549
|
* deleted; the curated store is a different file this class never opens (AC5).
|
|
470
550
|
*/
|
|
471
551
|
evict(): { expired: number; trimmed: number } {
|
|
552
|
+
this.writable();
|
|
472
553
|
if (this.contended) return { expired: 0, trimmed: 0 };
|
|
473
554
|
const start = performance.now();
|
|
474
555
|
const expired = this.evictWhere("at < ?", [new Date(this.now() - this.retentionDays * 24 * 3600_000).toISOString()], start);
|
|
@@ -501,7 +582,9 @@ export class CaptureStore {
|
|
|
501
582
|
}
|
|
502
583
|
|
|
503
584
|
rows(f: { project?: string; sinceId?: number; limit?: number } = {}): CaptureRow[] {
|
|
585
|
+
this.checkReadBinding();
|
|
504
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)); }
|
|
505
588
|
if (f.project) { where.push("project = ?"); bind.push(f.project); }
|
|
506
589
|
if (f.sinceId !== undefined) { where.push("id > ?"); bind.push(f.sinceId); }
|
|
507
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[];
|
|
@@ -510,7 +593,12 @@ export class CaptureStore {
|
|
|
510
593
|
|
|
511
594
|
/** Exact-project, bounded body pages; MCP screens before filling its public candidate window. */
|
|
512
595
|
readPage(window: ReadWindow & { query?: string; match?: "substring" }): { rows: CaptureRow[]; next?: ReadRef; engine?: "fts5" | "substring" } {
|
|
596
|
+
this.checkReadBinding();
|
|
513
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
|
+
}
|
|
514
602
|
const engine = window.query === undefined ? undefined : window.match !== "substring" && this.ensureMcpFts() ? "fts5" : "substring";
|
|
515
603
|
if (window.query !== undefined && engine === "fts5") {
|
|
516
604
|
const match = ftsTerms(window.query); if (!match) return { rows: [], engine };
|
|
@@ -522,6 +610,7 @@ export class CaptureStore {
|
|
|
522
610
|
}
|
|
523
611
|
|
|
524
612
|
stats(): { rows: number; bytes: number; capMb: number; retentionDays: number; fileBytes: number } {
|
|
613
|
+
this.checkReadBinding();
|
|
525
614
|
let fileBytes = 0;
|
|
526
615
|
try { fileBytes = statSync(join(this.dir, "capture.db")).size; } catch { /* not created yet */ }
|
|
527
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 };
|
|
@@ -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
|
+
}
|