@staix/agent-mem 0.17.0 → 0.19.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 +1 -1
- package/README.md +26 -5
- package/package.json +4 -2
- package/src/cli.ts +27 -4
- package/src/commands-table.ts +3 -1
- package/src/hook-contract.ts +9 -0
- package/src/hook-jcode.ts +56 -0
- package/src/hook-native.ts +66 -13
- package/src/hook-project.ts +27 -4
- package/src/hook-resources.ts +54 -12
- package/src/hook-rss-wire.ts +85 -0
- package/src/hook-spool.ts +80 -20
- package/src/hook-transcript.ts +189 -0
- package/src/hooks.ts +132 -61
- package/src/mcp-memory.ts +13 -3
- package/src/memory/candidates.ts +13 -4
- package/src/memory/capture-input.ts +34 -0
- package/src/memory/capture.ts +46 -0
- package/src/memory/catalog.ts +2 -1
- package/src/memory/cross-project.ts +2 -1
- package/src/memory/doctor.ts +88 -17
- package/src/memory/formats.ts +65 -0
- package/src/memory/index-file.ts +15 -0
- package/src/memory/metrics.ts +4 -3
- package/src/memory/public-prune-activation.ts +2 -1
- package/src/memory/search-fts.ts +63 -15
- package/src/memory/search-score.ts +14 -0
- package/src/memory/shard.ts +2 -1
- package/src/memory/store.ts +29 -6
- package/src/memory/sync.ts +2 -0
- package/src/memory/work-lists.ts +2 -0
- package/src/session-brief-worker.ts +38 -37
- package/src/session-brief.ts +26 -12
- package/src/setup-drift.ts +217 -0
- package/src/setup-jcode.ts +65 -0
- package/src/setup-owned.ts +18 -1
- package/src/setup-paths.ts +76 -0
- package/src/setup.ts +92 -47
- package/src/ui/panel.js +25 -0
- package/src/ui/plugin.ts +17 -5
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { combineRssSources, processImageMemory, rssSource, selectHookRss, type HookRssReading, type HookRssSources } from "./hook-resources.ts";
|
|
2
|
+
|
|
3
|
+
const SOURCES = ["VmHWM", "vmpeak-bound", "platform-rusage", "raw-fallback", "unavailable"] as const;
|
|
4
|
+
export const rssSourceCode = (value: HookRssReading): number => SOURCES.indexOf(value.source);
|
|
5
|
+
function reading(kb: unknown, raw: unknown, code: unknown): HookRssReading | undefined {
|
|
6
|
+
if (!Number.isSafeInteger(kb) || (kb as number) < 0 || !Number.isSafeInteger(raw) || (raw as number) < 0 || !Number.isSafeInteger(code) || (code as number) < 0 || (code as number) >= SOURCES.length) return;
|
|
7
|
+
const source = SOURCES[code as number]!;
|
|
8
|
+
if ((code as number) < 3 && (kb as number) <= 0) return;
|
|
9
|
+
return { kb: kb as number, rawKB: raw as number, source, known: (code as number) < 3 };
|
|
10
|
+
}
|
|
11
|
+
type Image = ReturnType<typeof processImageMemory>;
|
|
12
|
+
interface Native { pid: number; image?: Image; result?: HookRssReading }
|
|
13
|
+
|
|
14
|
+
/** Bounded numeric progress survives worker timeout; no native arguments or project data enter it. */
|
|
15
|
+
export class HookWorkerRss {
|
|
16
|
+
private entered = false;
|
|
17
|
+
private invalid = false;
|
|
18
|
+
private readonly native = new Map<number, Native>();
|
|
19
|
+
private readonly active = new Set<number>();
|
|
20
|
+
private readonly overlaps: number[][] = [];
|
|
21
|
+
private image: Image = {};
|
|
22
|
+
private self?: HookRssReading;
|
|
23
|
+
constructor(private readonly pid: number) {}
|
|
24
|
+
invalidate(): void { this.invalid = true; }
|
|
25
|
+
frame(value: unknown): void {
|
|
26
|
+
if (!Array.isArray(value)) { this.invalid = true; return; }
|
|
27
|
+
const [kind, id, pid] = value;
|
|
28
|
+
if (kind === 0 && value.length === 1 && !this.entered) { this.entered = true; return; }
|
|
29
|
+
if (!this.entered) { this.invalid = true; return; }
|
|
30
|
+
if (kind === 3 && value.length === 4) {
|
|
31
|
+
const selected = reading(value[1], value[2], value[3]);
|
|
32
|
+
if (!selected) this.invalid = true; else this.self = selected;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (!Number.isSafeInteger(id) || id < 1 || id > 64) { this.invalid = true; return; }
|
|
36
|
+
if (kind === 1 && value.length === 3 && Number.isSafeInteger(pid) && pid >= 0) {
|
|
37
|
+
const prior = this.native.get(id);
|
|
38
|
+
if (!prior && this.native.size < 64 && pid === 0) {
|
|
39
|
+
this.native.set(id, { pid }); this.active.add(id); this.overlaps.push([...this.active]); return;
|
|
40
|
+
}
|
|
41
|
+
if (prior && prior.pid === 0 && !prior.result && pid > 0) { prior.pid = pid; return; }
|
|
42
|
+
}
|
|
43
|
+
if (kind === 2 && value.length === 6) {
|
|
44
|
+
const prior = this.native.get(id), selected = reading(value[2], value[3], value[4]);
|
|
45
|
+
// Reserved final slot must remain zero, so protocol extensions cannot be misread.
|
|
46
|
+
if (prior && !prior.result && selected && value[5] === 0) { prior.result = selected; this.active.delete(id); return; }
|
|
47
|
+
}
|
|
48
|
+
this.invalid = true;
|
|
49
|
+
}
|
|
50
|
+
captureLive(read: typeof processImageMemory = processImageMemory): void {
|
|
51
|
+
const retain = (before: Image | undefined, after: Image): Image => ({
|
|
52
|
+
hwmKB: Math.max(before?.hwmKB ?? 0, after.hwmKB ?? 0) || undefined,
|
|
53
|
+
virtualKB: Math.max(before?.virtualKB ?? 0, after.virtualKB ?? 0) || undefined,
|
|
54
|
+
});
|
|
55
|
+
this.image = retain(this.image, read(this.pid));
|
|
56
|
+
for (const id of this.active) {
|
|
57
|
+
const value = this.native.get(id)!;
|
|
58
|
+
if (value.pid > 0) value.image = retain(value.image, read(value.pid));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
finish(rawWorkerKB: number, platform = process.platform): { rssKB: number; rawRssKB: number; rssSources: HookRssSources } {
|
|
62
|
+
const self = this.self;
|
|
63
|
+
const hwm = Math.max(this.image.hwmKB ?? 0, self?.source === "VmHWM" ? self.kb : 0);
|
|
64
|
+
const virtual = Math.max(this.image.virtualKB ?? 0, self?.source === "vmpeak-bound" ? self.kb : 0);
|
|
65
|
+
const ownRaw = platform !== "linux" && self?.source === "platform-rusage" ? self.kb : rawWorkerKB;
|
|
66
|
+
const own = selectHookRss(platform, hwm, ownRaw, virtual);
|
|
67
|
+
const sources = [rssSource(own)];
|
|
68
|
+
const native = new Map<number, HookRssReading>();
|
|
69
|
+
for (const [id, value] of this.native) {
|
|
70
|
+
const given = value.result;
|
|
71
|
+
const selected = given && !given.known && !value.image?.hwmKB && !value.image?.virtualKB ? given : selectHookRss(platform,
|
|
72
|
+
Math.max(value.image?.hwmKB ?? 0, given?.source === "VmHWM" ? given.kb : 0), given?.rawKB ?? 0,
|
|
73
|
+
Math.max(value.image?.virtualKB ?? 0, given?.source === "vmpeak-bound" ? given.kb : 0));
|
|
74
|
+
native.set(id, selected); sources.push(rssSource(selected));
|
|
75
|
+
}
|
|
76
|
+
let nativeKB = 0;
|
|
77
|
+
for (const group of this.overlaps) {
|
|
78
|
+
nativeKB = Math.max(nativeKB, group.reduce((sum, id) => sum + native.get(id)!.kb, 0));
|
|
79
|
+
}
|
|
80
|
+
const rssSources = combineRssSources(...sources);
|
|
81
|
+
if (this.invalid) rssSources.unavailable++;
|
|
82
|
+
const combined = own.kb + nativeKB;
|
|
83
|
+
return { rssKB: platform === "linux" ? combined : Math.max(combined, rawWorkerKB), rawRssKB: rawWorkerKB, rssSources };
|
|
84
|
+
}
|
|
85
|
+
}
|
package/src/hook-spool.ts
CHANGED
|
@@ -1,31 +1,45 @@
|
|
|
1
|
+
import { assertFormatFloor, FormatFloorError } from "./memory/formats.ts";
|
|
1
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
3
|
import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, opendirSync, readSync, renameSync, rmSync, writeFileSync, type Stats } from "node:fs";
|
|
3
4
|
import { dirname, join, resolve } from "node:path";
|
|
4
5
|
import { homedir } from "node:os";
|
|
5
6
|
import type { CaptureAppend, CaptureInput } from "./memory/capture.ts";
|
|
6
|
-
import { normalizeCaptureFields, type NormalizedCaptureFields } from "./memory/capture-input.ts";
|
|
7
|
+
import { CAPTURE_MAX_EXCERPT, clipCaptureUtf8, normalizeCaptureFields, toolCaptureExcerpt, toolInputLine, type CaptureFields, type NormalizedCaptureFields } from "./memory/capture-input.ts";
|
|
7
8
|
import { canonicalPath, realPath } from "./memory/platform.ts";
|
|
8
9
|
import { createStandalonePolicy, type StandalonePolicyOptions } from "./memory/policy.ts";
|
|
9
10
|
import { configuredPolicyOptions, loadAmemConfig, repoConfigRoot } from "./memory/config.ts";
|
|
10
11
|
import { fold } from "./memory/policy.ts";
|
|
12
|
+
import { stripPrivateSpans } from "./memory/private-spans.ts";
|
|
11
13
|
import { looksSecret } from "./memory/screen.ts";
|
|
12
14
|
import { appendMetrics, type CaptureDropReason } from "./memory/metrics.ts";
|
|
13
15
|
import { projectState } from "./memory/lifecycle.ts";
|
|
14
16
|
import { HOOK_EVENTS, HOOK_INPUT_BYTES } from "./hook-contract.ts";
|
|
15
17
|
|
|
16
|
-
|
|
18
|
+
// #199: v1 records (closed key set, no format field) are read, never written. Every 0.14-0.17 decoder deletes a record it cannot verify,
|
|
19
|
+
// so a record that adds a field publishes under a name the v1 pattern does not match (`hook-v2-`); old versions then ignore it
|
|
20
|
+
// (they still count it toward their file and byte caps). A later format that adds fields takes a new prefix again.
|
|
21
|
+
// #203: format 3 (`hook-v3-`) carries every record 0.18.0 cannot decode, today jcode-agent rows; 0.14-0.18 match neither
|
|
22
|
+
// `hook-` nor `hook-v2-` for it, so they leave it alone. Claude native rows stay format 2, byte for byte, so 0.18.0 keeps draining them.
|
|
23
|
+
const SCHEMA = "agent-mem.hook-capture/v1", FAMILY = "agent-mem.hook-capture", FORMAT = 2, PREFIX = "hook-v2-", FORMAT_V3 = 3, PREFIX_V3 = "hook-v3-";
|
|
17
24
|
const RECORD_BYTES = HOOK_INPUT_BYTES + 8192, MAX_FILES = 256, MAX_BYTES = 2 * 1024 * 1024;
|
|
18
25
|
const DRAIN_FILES = 32, DRAIN_BYTES = 256 * 1024;
|
|
19
26
|
// ponytail: a finite native admission reserve covers deferred replay, not unlimited locked intake.
|
|
20
27
|
// Upgrade: coordinated append-only capacity accounting and observable backpressure at exhaustion.
|
|
21
28
|
const CAPACITY_FILES = 32, CAPACITY_BYTES = 256 * 1024;
|
|
22
|
-
const namePattern = /^(hook-([a-f0-9]{64})-([a-f0-9]{64})-([a-f0-9]{32})\.json)(?:\.draining-[a-f0-9]{24})?$/;
|
|
29
|
+
const namePattern = /^(hook-(?:v[23]-)?([a-f0-9]{64})-([a-f0-9]{64})-([a-f0-9]{32})\.json)(?:\.draining-[a-f0-9]{24})?$/;
|
|
23
30
|
const hash = (value: string | Uint8Array) => createHash("sha256").update(value).digest("hex");
|
|
24
|
-
interface Record { schema:
|
|
31
|
+
interface Record { schema: string; format?: number; cwd: string; scope: string | null; root?: string; nonce: string; capture: NormalizedCaptureFields }
|
|
25
32
|
export interface PendingHookCapture { name: string; cwd: string; receipt: string; deferred?: true; backlog?: true }
|
|
26
33
|
export class HookSpoolFull extends Error { constructor() { super("hook storage full"); } }
|
|
27
|
-
|
|
34
|
+
/** #203: the agent a staged record belongs to. Claude hooks and jcode lifecycle observers share this spool, budget and replay. */
|
|
35
|
+
export const HOOK_AGENTS = ["claude", "jcode"] as const;
|
|
36
|
+
export type HookAgent = typeof HOOK_AGENTS[number];
|
|
37
|
+
interface Options { cwd: string; base: string; policy?: StandalonePolicyOptions; deferAtCapacity?: true; agent?: HookAgent }
|
|
28
38
|
const directory = (base: string) => join(base, "hook-spool");
|
|
39
|
+
/** A hook below the home's hook-spool floor (#199 AC5) stages, drains and maintains nothing; it still never denies a native action. */
|
|
40
|
+
function belowFloor(base: string): boolean {
|
|
41
|
+
try { assertFormatFloor(base, "hook-spool"); return false; } catch (error) { if (error instanceof FormatFloorError) return true; throw error; }
|
|
42
|
+
}
|
|
29
43
|
function physicalRoot(cwd: string): string {
|
|
30
44
|
const stat = lstatSync(cwd);
|
|
31
45
|
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("hook root unavailable");
|
|
@@ -264,21 +278,26 @@ function claimOwned(base: string, name: string, file: OwnedFile): OwnedFile | un
|
|
|
264
278
|
const owned = { ...file, file: claim };
|
|
265
279
|
return owns(owned) ? owned : undefined;
|
|
266
280
|
}
|
|
267
|
-
|
|
281
|
+
/** `unknown`: a valid envelope (cwd, nonce, scope) under an unknown or newer schema. It stays pending until the TTL, never read as corrupt. */
|
|
282
|
+
function decoded(file: OwnedFile): { value: Record; bytes: Buffer; unknown?: true } | undefined {
|
|
268
283
|
if (file.initial.size > RECORD_BYTES) return undefined; // Positively oversized: do not materialize its body.
|
|
269
284
|
const bytes = Buffer.alloc(file.initial.size);
|
|
270
285
|
if (readSync(file.fd, bytes, 0, bytes.length, 0) !== bytes.length || !owns(file)) throw new Error("hook record changed");
|
|
271
286
|
try {
|
|
272
287
|
const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as Record;
|
|
273
|
-
if (!value || typeof value !== "object" || Array.isArray(value) ||
|
|
274
|
-
|| Object.keys(value).some(key => !["schema", "cwd", "scope", "root", "nonce", "capture"].includes(key))
|
|
275
|
-
|| value.root !== undefined && (typeof value.root !== "string" || !/^[a-f0-9]{64}$/.test(value.root))
|
|
276
|
-
|| value.schema !== SCHEMA || typeof value.cwd !== "string" || !/^[a-f0-9]{32}$/.test(value.nonce)
|
|
288
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || typeof value.cwd !== "string" || typeof value.nonce !== "string" || !/^[a-f0-9]{32}$/.test(value.nonce)
|
|
277
289
|
|| value.scope !== null && (typeof value.scope !== "string" || !/^[a-f0-9]{64}$/.test(value.scope))) return undefined;
|
|
290
|
+
const v1 = value.schema === SCHEMA && !("format" in value), v3 = value.schema === FAMILY && value.format === FORMAT_V3;
|
|
291
|
+
const current = value.schema === FAMILY && value.format === FORMAT || v3;
|
|
292
|
+
if (!v1 && !current) return typeof value.schema === "string" ? { value, bytes, unknown: true } : undefined;
|
|
293
|
+
const keys = current ? 1 : 0;
|
|
294
|
+
if (![5 + keys, 6 + keys].includes(Object.keys(value).length)
|
|
295
|
+
|| Object.keys(value).some(key => !["schema", "cwd", "scope", "root", "nonce", "capture", ...current ? ["format"] : []].includes(key))
|
|
296
|
+
|| value.root !== undefined && (typeof value.root !== "string" || !/^[a-f0-9]{64}$/.test(value.root))) return undefined;
|
|
278
297
|
const c = value.capture;
|
|
279
298
|
if (!c || typeof c !== "object" || Array.isArray(c) || Object.keys(c).length < 8 || Object.keys(c).length > 10
|
|
280
299
|
|| Object.keys(c).some(key => !["at", "agent", "event", "tool", "paths", "status", "excerpt", "bytes", "session", "subagent"].includes(key))
|
|
281
|
-
|| c.agent
|
|
300
|
+
|| !(HOOK_AGENTS as readonly string[]).includes(c.agent) || (c.agent === "claude" && !["Title", "Recap", "Compact"].includes(c.event)) === v3 || ![...HOOK_EVENTS, "Title", "Recap", "Compact"].includes(c.event)
|
|
282
301
|
|| typeof c.at !== "string" || !Number.isFinite(Date.parse(c.at)) || typeof c.excerpt !== "string"
|
|
283
302
|
|| !Array.isArray(c.paths) || c.paths.some(path => typeof path !== "string")) return undefined;
|
|
284
303
|
const normalized = normalizeCaptureFields({ agent: c.agent, event: c.event, at: c.at, paths: c.paths, excerpt: c.excerpt,
|
|
@@ -394,7 +413,11 @@ function retireUnresolved(base: string, _admittingCwd: string | undefined, inven
|
|
|
394
413
|
const record = decoded(file);
|
|
395
414
|
const valid = record && hash(record.value.cwd) === match[2] && record.value.nonce === match[4] && (record.value.scope ?? "0".repeat(64)) === match[3];
|
|
396
415
|
let reason: CaptureDropReason | undefined;
|
|
397
|
-
if (valid) {
|
|
416
|
+
if (valid && record.unknown) {
|
|
417
|
+
// #199: a newer writer's record waits for a reader that knows it, then leaves with a counted reason, never at once.
|
|
418
|
+
if (Date.now() - row.modified < RETIRE_AFTER_MS) continue;
|
|
419
|
+
reason = "unknown_schema_expired";
|
|
420
|
+
} else if (valid) {
|
|
398
421
|
if (Date.now() - Date.parse(record.value.capture.at) < RETIRE_AFTER_MS) continue;
|
|
399
422
|
try { const cwd = lstatSync(record.value.cwd); if (!cwd.isDirectory() || cwd.isSymbolicLink()) reason = "cwd_removed"; }
|
|
400
423
|
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") reason = "cwd_removed"; }
|
|
@@ -425,7 +448,7 @@ function retireUnresolved(base: string, _admittingCwd: string | undefined, inven
|
|
|
425
448
|
* discard a valid pending record; only positively invalid publications leave admission. */
|
|
426
449
|
export function quarantineHookScope(options: { cwd: string; base: string; scope: string }): { retired: number } {
|
|
427
450
|
let retired = 0;
|
|
428
|
-
if (!/^[a-f0-9]{64}$/.test(options.scope)) return { retired };
|
|
451
|
+
if (belowFloor(options.base) || !/^[a-f0-9]{64}$/.test(options.scope)) return { retired };
|
|
429
452
|
const cwd = realPath(options.cwd), base = canonicalPath(options.base), cwdHash = hash(cwd);
|
|
430
453
|
trimQuarantine(base);
|
|
431
454
|
const rows = entries(base).filter(row => {
|
|
@@ -451,7 +474,7 @@ export function quarantineHookScope(options: { cwd: string; base: string; scope:
|
|
|
451
474
|
|
|
452
475
|
/** Only screened normalized capture fields are retained, never the native input envelope. */
|
|
453
476
|
export async function stageHookCapture(event: string, input: unknown, options: Options): Promise<PendingHookCapture | undefined> {
|
|
454
|
-
if (!(HOOK_EVENTS as readonly string[]).includes(event) || options.policy?.host) return undefined;
|
|
477
|
+
if (!(HOOK_EVENTS as readonly string[]).includes(event) || options.policy?.host || belowFloor(options.base)) return undefined;
|
|
455
478
|
const policy = createStandalonePolicy(configuredPolicyOptions(options.base, options.cwd, options.policy));
|
|
456
479
|
if (!await policy.screen(input)) return undefined;
|
|
457
480
|
const value = structuredClone(input) as {[key: string]: unknown};
|
|
@@ -465,19 +488,47 @@ export async function stageHookCapture(event: string, input: unknown, options: O
|
|
|
465
488
|
// F4: the person's configured caps apply at staging. Replay (`decoded`) re-normalizes with the built-in caps, which are the ceiling, so a record
|
|
466
489
|
// staged under a tighter cap still verifies after the cap is raised or removed.
|
|
467
490
|
const configured = loadAmemConfig(base, repoConfigRoot(cwd)).config; // the repository's .amem/config narrows here too
|
|
491
|
+
const toolEvent = event === "PreToolUse" || event === "PostToolUse" || event === "PostToolUseFailure";
|
|
492
|
+
const contentText = typeof content === "string" ? content : JSON.stringify(content) ?? "";
|
|
493
|
+
const excerpt = toolEvent
|
|
494
|
+
? clipCaptureUtf8(toolCaptureExcerpt(toolInputLine(tool), contentText), Math.min(CAPTURE_MAX_EXCERPT, configured.excerptBytes ?? CAPTURE_MAX_EXCERPT))
|
|
495
|
+
: contentText;
|
|
468
496
|
// #173: attribution. Only a fixed-width hash of the native session id is kept, never the id or transcript path;
|
|
469
497
|
// a non-empty agent_id marks a subagent (or in-process teammate) event. Both are read from the already-screened input.
|
|
470
498
|
const session = typeof value.session_id === "string" && value.session_id.length > 0 && value.session_id.length <= 256 ? hash(`amem-session\0${value.session_id}`).slice(0, 16) : undefined;
|
|
471
499
|
const subagent = typeof value.agent_id === "string" && value.agent_id.length > 0;
|
|
472
|
-
const capture = normalizeCaptureFields({ agent: "claude", event, paths, status: event === "PostToolUseFailure" ? "failed" : "ok",
|
|
500
|
+
const capture = normalizeCaptureFields({ agent: options.agent ?? "claude", event, paths, status: event === "PostToolUseFailure" ? "failed" : "ok",
|
|
473
501
|
...(session ? { session } : {}), ...(subagent ? { subagent: true } : {}),
|
|
474
|
-
...(typeof value.tool_name === "string" ? { tool: value.tool_name } : {}), excerpt
|
|
502
|
+
...(typeof value.tool_name === "string" ? { tool: value.tool_name } : {}), excerpt },
|
|
475
503
|
undefined,
|
|
476
|
-
(text: string) => looksSecret(text) || configured.piiPatterns.some(pattern => fold(text).includes(fold(pattern))), // the
|
|
504
|
+
(text: string) => looksSecret(text) || configured.piiPatterns.some(pattern => fold(text).includes(fold(pattern))), // keep the normalizer's screen as well as the full envelope and bounded tool text screens
|
|
477
505
|
{ excerptBytes: configured.excerptBytes, paths: configured.capturePaths, ...(configured.denyPaths.length ? { denyPaths: configured.denyPaths } : {}) });
|
|
506
|
+
// The unchanged normalizer screen and final whole-capture policy also cover the joined input/content.
|
|
507
|
+
// Enriched tool rows are refused rather than persisting the normalizer's redaction marker.
|
|
508
|
+
if (toolEvent && capture.excerpt === "[secret-screened]") return undefined;
|
|
478
509
|
if (!await policy.screen({ cwd, capture })) return undefined;
|
|
479
|
-
|
|
480
|
-
|
|
510
|
+
return publishCapture(event, capture, { ...options, cwd, base });
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Transcript text is screened in full before caps, then shares the ordinary durable spool. */
|
|
514
|
+
export async function stageTranscriptCapture(input: CaptureFields, options: Options, receiptIdentity?: string): Promise<PendingHookCapture | undefined> {
|
|
515
|
+
if (options.policy?.host || belowFloor(options.base) || input.agent !== "claude" || !["Title", "Recap", "Compact"].includes(input.event)) return;
|
|
516
|
+
const cwd = realPath(options.cwd), base = canonicalPath(options.base);
|
|
517
|
+
const policy = createStandalonePolicy(configuredPolicyOptions(base, cwd, options.policy));
|
|
518
|
+
if (!await policy.screen({ cwd, input })) return;
|
|
519
|
+
const configured = loadAmemConfig(base, repoConfigRoot(cwd)).config;
|
|
520
|
+
const capture = normalizeCaptureFields({ ...input, excerpt: clipCaptureUtf8(stripPrivateSpans(input.excerpt ?? ""), Math.min(CAPTURE_MAX_EXCERPT, configured.excerptBytes ?? CAPTURE_MAX_EXCERPT)) }, undefined,
|
|
521
|
+
text => looksSecret(text) || configured.piiPatterns.some(pattern => fold(text).includes(fold(pattern))),
|
|
522
|
+
{ excerptBytes: configured.excerptBytes, paths: configured.capturePaths, denyPaths: configured.denyPaths });
|
|
523
|
+
if (capture.excerpt === "[secret-screened]" || !await policy.screen({ cwd, capture })) return;
|
|
524
|
+
return publishCapture(input.event, capture, { ...options, cwd, base }, hash(receiptIdentity === undefined ? JSON.stringify(capture) : JSON.stringify([capture, receiptIdentity])).slice(0, 32));
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function publishCapture(event: string, capture: NormalizedCaptureFields, options: Options, nonce = randomBytes(16).toString("hex")): PendingHookCapture {
|
|
528
|
+
const { cwd, base } = options;
|
|
529
|
+
const scope = hookCaptureScope(cwd, base) ?? null;
|
|
530
|
+
const v3 = capture.agent !== "claude" || ["Title", "Recap", "Compact"].includes(event);
|
|
531
|
+
const record: Record = { schema: FAMILY, format: v3 ? FORMAT_V3 : FORMAT, cwd, scope, root: physicalRoot(cwd), nonce, capture }, bytes = JSON.stringify(record);
|
|
481
532
|
if (Buffer.byteLength(bytes) > RECORD_BYTES) throw new Error("hook record bound");
|
|
482
533
|
const dir = directory(base), writing = join(dir, ".writing"); privateDirectory(dir); privateDirectory(writing);
|
|
483
534
|
let destination = dir, deferred = false, backlog = false;
|
|
@@ -506,7 +557,7 @@ export async function stageHookCapture(event: string, input: unknown, options: O
|
|
|
506
557
|
deferred = true;
|
|
507
558
|
}
|
|
508
559
|
}
|
|
509
|
-
const name =
|
|
560
|
+
const name = `${v3 ? PREFIX_V3 : PREFIX}${hash(cwd)}-${scope ?? "0".repeat(64)}-${nonce}.json`, temporary = join(writing, name);
|
|
510
561
|
try { writeFileSync(temporary, bytes, { mode: 0o600, flag: "wx" }); renameSync(temporary, join(destination, name)); }
|
|
511
562
|
finally { rmSync(temporary, { force: true }); }
|
|
512
563
|
return { name, cwd, receipt: hash(bytes), ...(deferred ? { deferred: true as const } : {}), ...(backlog ? { backlog: true as const } : {}) };
|
|
@@ -524,6 +575,7 @@ export function hookCapturePending(base: string, name: string): boolean {
|
|
|
524
575
|
export async function drainHookCaptures(options: Options & { project: string; expectedScope?: string; current?: string; timeRemaining?: () => number },
|
|
525
576
|
append: (input: CaptureInput, receipt: string) => CaptureAppend): Promise<{ captured: string[]; blocked: number }> {
|
|
526
577
|
const cwd = realPath(options.cwd), base = canonicalPath(options.base), stamp = hookCaptureScope(cwd, base), captured: string[] = [];
|
|
578
|
+
if (belowFloor(base)) return { captured, blocked: 1 };
|
|
527
579
|
if (!stamp || options.expectedScope !== undefined && options.expectedScope !== stamp) return { captured, blocked: 1 };
|
|
528
580
|
// F4: the configured policy applies at drain too. `options.policy` carries it, so the anchored line below (a test diagnostic matches its exact text) stays as it was.
|
|
529
581
|
options = { ...options, policy: configuredPolicyOptions(base, cwd, options.policy) };
|
|
@@ -548,6 +600,14 @@ export async function drainHookCaptures(options: Options & { project: string; ex
|
|
|
548
600
|
if (!record || record.value.cwd !== cwd || record.value.scope !== null && record.value.scope !== stamp || record.value.nonce !== match[4]) {
|
|
549
601
|
retireClaim(base, claim, false); blocked++; continue;
|
|
550
602
|
}
|
|
603
|
+
// #199: an unknown or newer schema stays claimed and pending (later passes reclaim it) until the TTL, then retires with a counted reason.
|
|
604
|
+
// ponytail: pending unknown records still take drain slots oldest first; a writer that adds fields uses a new prefix, so this is the fallback only.
|
|
605
|
+
if (record.unknown) {
|
|
606
|
+
if (Date.now() - row.modified < RETIRE_AFTER_MS) { blocked++; continue; }
|
|
607
|
+
appendMetrics(base, [{ at: new Date().toISOString(), kind: "hook", captureDrops: 1, captureDropReason: "unknown_schema_expired" }]);
|
|
608
|
+
if (!retireClaim(base, claim, false)) blocked++;
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
551
611
|
const value = record.value, c = value.capture;
|
|
552
612
|
if (value.scope === null) { blocked++; continue; }
|
|
553
613
|
// #15 T5: a project the person archived or forgot takes no new capture. Retire the record here with a counted reason instead of leaving it pending:
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { assertFormatFloor } from "./memory/formats.ts";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { canonicalPath, realPath } from "./memory/platform.ts";
|
|
7
|
+
import { configuredPolicyOptions } from "./memory/config.ts";
|
|
8
|
+
import { createStandalonePolicy, type StandalonePolicyOptions } from "./memory/policy.ts";
|
|
9
|
+
import { HookSpoolFull, stageTranscriptCapture } from "./hook-spool.ts";
|
|
10
|
+
import { appendMetrics } from "./memory/metrics.ts";
|
|
11
|
+
import type { HookTranscriptProgress } from "./hook-contract.ts";
|
|
12
|
+
|
|
13
|
+
export const HOOK_TRANSCRIPT_BYTES = 256 * 1024;
|
|
14
|
+
const hash = (value: string) => createHash("sha256").update(value).digest("hex");
|
|
15
|
+
type SummaryEvent = "Title" | "Recap" | "Compact";
|
|
16
|
+
interface Cursor { offset: number; dev: string; ino: string; size: number; title: string; at: string; readStart: number; readAt: string; skipping: number }
|
|
17
|
+
interface Options { cwd: string; base: string; deadline: number; policy?: StandalonePolicyOptions }
|
|
18
|
+
const object = (value: unknown): Record<string, unknown> | undefined => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
19
|
+
const timestamp = (value: unknown) => typeof value === "string" && Number.isFinite(Date.parse(value)) ? new Date(value).toISOString() : undefined;
|
|
20
|
+
|
|
21
|
+
/** Claude's three free texts only; ordinary transcript lines never enter JSON.parse. */
|
|
22
|
+
export function transcriptSummary(line: string): { event: SummaryEvent; text: string; at?: string; subagent?: true } | undefined {
|
|
23
|
+
if (!line.includes('"ai-title"') && !line.includes('"away_summary"') && !line.includes('"isCompactSummary"')) return undefined;
|
|
24
|
+
const row = object(JSON.parse(line)); if (!row) return undefined;
|
|
25
|
+
let event: SummaryEvent, text: unknown;
|
|
26
|
+
if (row.type === "ai-title") { event = "Title"; text = row.aiTitle; }
|
|
27
|
+
else if (row.type === "system" && row.subtype === "away_summary") { event = "Recap"; text = row.content; }
|
|
28
|
+
else if (row.type === "user" && row.isCompactSummary === true) {
|
|
29
|
+
event = "Compact"; text = object(row.message)?.content;
|
|
30
|
+
if (Array.isArray(text)) text = text.flatMap(part => { const value = object(part); return value?.type === "text" && typeof value.text === "string" ? [value.text] : []; }).join("\n");
|
|
31
|
+
} else return undefined;
|
|
32
|
+
if (typeof text !== "string" || !text.trim()) return undefined;
|
|
33
|
+
const visibleText = event === "Recap" ? text.replace(/\s*\(disable recaps in \/config\)\s*$/, "") : text;
|
|
34
|
+
return { event, text: visibleText, ...(timestamp(row.timestamp) ? { at: timestamp(row.timestamp)! } : {}), ...(row.isSidechain === true || typeof row.agentId === "string" && row.agentId.length > 0 ? { subagent: true as const } : {}) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Read only a top-level timestamp token, without parsing ordinary message bodies. */
|
|
38
|
+
function transcriptTimestamp(line: string): string | undefined {
|
|
39
|
+
if (!line.includes('"timestamp"')) return;
|
|
40
|
+
let depth = 0, at: string | undefined;
|
|
41
|
+
for (let index = 0; index < line.length; index++) {
|
|
42
|
+
const character = line[index];
|
|
43
|
+
if (character === '"') {
|
|
44
|
+
const start = index++;
|
|
45
|
+
for (; index < line.length; index++) {
|
|
46
|
+
if (line[index] === "\\") index++;
|
|
47
|
+
else if (line[index] === '"') break;
|
|
48
|
+
}
|
|
49
|
+
if (depth === 1 && line.slice(start, index + 1) === '"timestamp"') {
|
|
50
|
+
const value = /^\s*:\s*"([^"\\]+)"/.exec(line.slice(index + 1));
|
|
51
|
+
if (value) at = timestamp(value[1]);
|
|
52
|
+
}
|
|
53
|
+
} else if (character === "{" || character === "[") depth++;
|
|
54
|
+
else if (character === "}" || character === "]") depth--;
|
|
55
|
+
}
|
|
56
|
+
return at;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function privateDirectory(dir: string): void {
|
|
60
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
61
|
+
const stat = lstatSync(dir);
|
|
62
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("transcript cursor unavailable");
|
|
63
|
+
chmodSync(dir, 0o700);
|
|
64
|
+
}
|
|
65
|
+
function cursorDatabase(file: string, dev: string, ino: string, size: number): Database {
|
|
66
|
+
const fd = openSync(file, constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
|
|
67
|
+
try {
|
|
68
|
+
const stat = fstatSync(fd), named = lstatSync(file);
|
|
69
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > 1024 * 1024 || named.isSymbolicLink() || stat.dev !== named.dev || stat.ino !== named.ino) throw new Error("transcript cursor unavailable");
|
|
70
|
+
chmodSync(file, 0o600);
|
|
71
|
+
for (const suffix of ["-journal", "-wal", "-shm"]) {
|
|
72
|
+
const sidecar = lstatSync(file + suffix, { throwIfNoEntry: false });
|
|
73
|
+
if (sidecar && (!sidecar.isFile() || sidecar.isSymbolicLink() || sidecar.nlink !== 1 || sidecar.size > 1024 * 1024)) throw new Error("transcript cursor unavailable");
|
|
74
|
+
if (sidecar) chmodSync(file + suffix, 0o600);
|
|
75
|
+
}
|
|
76
|
+
} finally { closeSync(fd); }
|
|
77
|
+
const db = new Database(file);
|
|
78
|
+
try {
|
|
79
|
+
db.run("PRAGMA busy_timeout = 0");
|
|
80
|
+
db.transaction(() => {
|
|
81
|
+
db.run("CREATE TABLE IF NOT EXISTS fallback (digest TEXT PRIMARY KEY, at TEXT NOT NULL)");
|
|
82
|
+
db.run("CREATE TABLE IF NOT EXISTS cursor (id INTEGER PRIMARY KEY CHECK(id=1), offset INTEGER NOT NULL, dev TEXT NOT NULL, ino TEXT NOT NULL, size INTEGER NOT NULL, title TEXT NOT NULL, at TEXT NOT NULL, readStart INTEGER NOT NULL, readAt TEXT NOT NULL, skipping INTEGER NOT NULL CHECK(skipping IN (0,1)))");
|
|
83
|
+
// One durable initialization commit; persist fallback time and known identity before publication.
|
|
84
|
+
db.query("INSERT OR IGNORE INTO cursor VALUES (1,0,?,?,?,'','',0,?,0)").run(dev, ino, size, new Date().toISOString());
|
|
85
|
+
}).immediate();
|
|
86
|
+
return db;
|
|
87
|
+
} catch (error) { db.close(); throw error; }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Private per-session transaction serializes screens and cursor advancement. No path or raw id is persisted. */
|
|
91
|
+
export async function scanHookTranscriptTail(input: unknown, options: Options): Promise<HookTranscriptProgress | undefined> {
|
|
92
|
+
const value = object(input);
|
|
93
|
+
if (!value || options.policy?.host || typeof value.transcript_path !== "string" || typeof value.session_id !== "string" || !value.session_id || value.session_id.length > 256) return;
|
|
94
|
+
if (performance.now() >= options.deadline) return { stopReason: "deadline" };
|
|
95
|
+
const cwd = realPath(options.cwd), base = canonicalPath(options.base);
|
|
96
|
+
assertFormatFloor(base, "hook-spool");
|
|
97
|
+
if (value.cwd !== undefined && (typeof value.cwd !== "string" || realPath(value.cwd) !== cwd)) return;
|
|
98
|
+
const policy = createStandalonePolicy(configuredPolicyOptions(base, cwd, options.policy));
|
|
99
|
+
if (!await policy.screen(value)) return;
|
|
100
|
+
const session = hash(`amem-session\0${value.session_id}`).slice(0, 16);
|
|
101
|
+
if (lstatSync(value.transcript_path).isSymbolicLink()) throw new Error("transcript unavailable");
|
|
102
|
+
const transcript = realPath(value.transcript_path);
|
|
103
|
+
// Parent aliases must not hide a configured deny path behind an allowed spelling.
|
|
104
|
+
if (!await policy.screen({ transcript_path: transcript })) return;
|
|
105
|
+
const fd = openSync(transcript, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
106
|
+
let db: Database | undefined;
|
|
107
|
+
try {
|
|
108
|
+
const stat = fstatSync(fd), named = lstatSync(value.transcript_path);
|
|
109
|
+
if (!stat.isFile() || stat.nlink !== 1 || named.isSymbolicLink() || stat.dev !== named.dev || stat.ino !== named.ino) return;
|
|
110
|
+
const dir = join(base, "hook-transcripts"); privateDirectory(dir);
|
|
111
|
+
db = cursorDatabase(join(dir, `${session}.db`), String(stat.dev), String(stat.ino), stat.size); db.run("BEGIN IMMEDIATE");
|
|
112
|
+
let cursor = db.query("SELECT * FROM cursor WHERE id=1").get() as Cursor;
|
|
113
|
+
if (!Number.isSafeInteger(cursor.offset) || cursor.offset < 0 || !Number.isSafeInteger(cursor.size) || cursor.size < 0 || cursor.at !== "" && !timestamp(cursor.at)
|
|
114
|
+
|| !Number.isSafeInteger(cursor.readStart) || cursor.readStart < 0 || !timestamp(cursor.readAt) || ![0, 1].includes(cursor.skipping) || !/^(?:[a-f0-9]{64})?$/.test(cursor.title)) throw new Error("transcript cursor unavailable");
|
|
115
|
+
const reset = cursor.dev !== String(stat.dev) || cursor.ino !== String(stat.ino) || stat.size < cursor.size || stat.size < cursor.offset;
|
|
116
|
+
if (reset || cursor.offset !== cursor.readStart || cursor.size === 0 && stat.size > 0) {
|
|
117
|
+
// Commit reset metadata before staging too: titles without a native timestamp
|
|
118
|
+
// retain their fallback time if the process dies between publication and cursor advance.
|
|
119
|
+
if (reset) { cursor.offset = 0; cursor.at = ""; cursor.skipping = 0; }
|
|
120
|
+
db.query("UPDATE cursor SET offset=?,dev=?,ino=?,size=?,at=?,readStart=?,readAt=?,skipping=? WHERE id=1")
|
|
121
|
+
.run(cursor.offset, String(stat.dev), String(stat.ino), stat.size, cursor.at, cursor.offset, new Date().toISOString(), cursor.skipping);
|
|
122
|
+
db.run("COMMIT"); db.run("BEGIN IMMEDIATE");
|
|
123
|
+
cursor = db.query("SELECT * FROM cursor WHERE id=1").get() as Cursor;
|
|
124
|
+
}
|
|
125
|
+
const bytes = Buffer.alloc(Math.min(HOOK_TRANSCRIPT_BYTES, Math.max(0, stat.size - cursor.offset)));
|
|
126
|
+
const length = readSync(fd, bytes, 0, bytes.length, cursor.offset);
|
|
127
|
+
const after = fstatSync(fd), live = lstatSync(value.transcript_path);
|
|
128
|
+
if (length !== bytes.length || after.size < stat.size || live.dev !== stat.dev || live.ino !== stat.ino || realPath(value.transcript_path) !== transcript) return;
|
|
129
|
+
let start = 0, stopReason: HookTranscriptProgress["stopReason"];
|
|
130
|
+
while (start < length && performance.now() < options.deadline) {
|
|
131
|
+
const newline = bytes.indexOf(10, start);
|
|
132
|
+
if (cursor.skipping) {
|
|
133
|
+
start = newline < 0 ? length : newline + 1;
|
|
134
|
+
if (newline >= 0) cursor.skipping = 0;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (newline < 0) {
|
|
138
|
+
// ponytail: a JSONL line exceeding the invocation's 256 KiB ceiling is
|
|
139
|
+
// lost, not buffered in memory/home. Upgrade with a bounded streaming extractor.
|
|
140
|
+
if (start === 0 && length === HOOK_TRANSCRIPT_BYTES && stat.size - cursor.offset > length) {
|
|
141
|
+
cursor.skipping = 1; start = length;
|
|
142
|
+
appendMetrics(base, [{ at: new Date().toISOString(), kind: "hook", captureDrops: 1, captureDropReason: "transcript_line_bound" }]);
|
|
143
|
+
}
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
let line: string;
|
|
147
|
+
try { line = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(start, newline)); }
|
|
148
|
+
catch { start = newline + 1; continue; }
|
|
149
|
+
let summary: ReturnType<typeof transcriptSummary>;
|
|
150
|
+
try { summary = transcriptSummary(line); } catch { /* Malformed native JSON is not capture. */ }
|
|
151
|
+
if (summary && !await policy.screen(JSON.parse(line))) summary = undefined;
|
|
152
|
+
if (summary) {
|
|
153
|
+
const title = summary.event === "Title" ? hash(summary.text) : "";
|
|
154
|
+
if (!title || title !== cursor.title) {
|
|
155
|
+
try {
|
|
156
|
+
let at = summary.at ?? cursor.at;
|
|
157
|
+
if (!at) {
|
|
158
|
+
// Native timestamps normally identify summaries. Untimed content retains its
|
|
159
|
+
// first fallback across rotation, so an unchanged row cannot get a new receipt.
|
|
160
|
+
const digest = hash(JSON.stringify([summary.event, summary.text, summary.subagent ?? false]));
|
|
161
|
+
db.query("INSERT OR IGNORE INTO fallback VALUES (?,?)").run(digest, cursor.readAt);
|
|
162
|
+
at = (db.query("SELECT at FROM fallback WHERE digest=?").get(digest) as { at: string }).at;
|
|
163
|
+
if (!timestamp(at)) throw new Error("transcript cursor unavailable");
|
|
164
|
+
}
|
|
165
|
+
const staged = await stageTranscriptCapture({ agent: "claude", event: summary.event, excerpt: summary.text, at, session,
|
|
166
|
+
...(summary.subagent || typeof value.agent_id === "string" && value.agent_id.length > 0 ? { subagent: true } : {}) }, { cwd, base, policy: options.policy, deferAtCapacity: true }, summary.event === "Title" ? `title:${cursor.offset + start}` : undefined);
|
|
167
|
+
if (staged && title) cursor.title = title;
|
|
168
|
+
} catch (error) {
|
|
169
|
+
// Advance only through durable/refused rows; a full spool retries this line
|
|
170
|
+
// after the ordinary hook worker drains earlier publications.
|
|
171
|
+
if (error instanceof HookSpoolFull) { stopReason = "spool_full"; break; }
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// Carry timestamps without parsing ordinary messages (including across invocation boundaries).
|
|
177
|
+
const at = transcriptTimestamp(line); if (at) cursor.at = at;
|
|
178
|
+
start = newline + 1;
|
|
179
|
+
}
|
|
180
|
+
if (!stopReason && start < length && performance.now() >= options.deadline) stopReason = "deadline";
|
|
181
|
+
if (!stopReason && cursor.offset + start < stat.size && length === HOOK_TRANSCRIPT_BYTES) stopReason = "cap";
|
|
182
|
+
assertFormatFloor(base, "hook-spool");
|
|
183
|
+
db.query("UPDATE cursor SET offset=?,dev=?,ino=?,size=?,title=?,at=?,skipping=? WHERE id=1").run(cursor.offset + start, String(stat.dev), String(stat.ino), stat.size, cursor.title, cursor.at, cursor.skipping);
|
|
184
|
+
db.run("COMMIT");
|
|
185
|
+
return { offset: cursor.offset + start, ...(stopReason ? { stopReason } : {}) };
|
|
186
|
+
} finally {
|
|
187
|
+
try { if (db) { try { db.run("ROLLBACK"); } catch { /* Already committed. */ } db.close(); } } finally { closeSync(fd); }
|
|
188
|
+
}
|
|
189
|
+
}
|