@staix/agent-mem 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +13 -0
- package/.claude-plugin/plugin.json +15 -0
- package/README.md +186 -21
- package/bin/amem.js +26 -3
- package/hooks/hooks.json +81 -0
- package/package.json +3 -1
- package/src/cli.ts +492 -39
- package/src/commands-table.ts +56 -15
- package/src/hook-contract.ts +3 -0
- package/src/hook-git.ts +42 -0
- package/src/hook-native.ts +92 -0
- package/src/hook-project.ts +206 -0
- package/src/hook-resources.ts +15 -0
- package/src/hook-spool.ts +566 -0
- package/src/hooks.ts +253 -0
- package/src/index.ts +18 -0
- package/src/ingest.ts +351 -0
- package/src/mcp-memory.ts +141 -33
- package/src/mcp.ts +42 -7
- package/src/memory/aliases.ts +87 -0
- package/src/memory/bridge.ts +8 -2
- package/src/memory/candidate-pending.ts +119 -0
- package/src/memory/candidates.ts +144 -47
- package/src/memory/capacity-split.ts +76 -0
- package/src/memory/capacity.ts +162 -59
- package/src/memory/capture-input.ts +36 -0
- package/src/memory/capture.ts +153 -39
- package/src/memory/catalog.ts +43 -10
- package/src/memory/commands.ts +98 -44
- package/src/memory/config.ts +167 -0
- package/src/memory/cross-project.ts +75 -0
- package/src/memory/curated-read.ts +29 -0
- package/src/memory/decay-review.ts +136 -0
- package/src/memory/doctor.ts +412 -0
- package/src/memory/editor.ts +58 -0
- package/src/memory/eval-fixture.ts +137 -0
- package/src/memory/eval-set.json +443 -0
- package/src/memory/eval.ts +150 -0
- package/src/memory/gc.ts +97 -0
- package/src/memory/generation-index.ts +22 -17
- package/src/memory/generation-sync.ts +3 -0
- package/src/memory/goal-promotion.ts +71 -0
- package/src/memory/home.ts +15 -4
- package/src/memory/intent-brief.ts +89 -0
- package/src/memory/lifecycle-log.ts +54 -0
- package/src/memory/lifecycle.ts +61 -0
- package/src/memory/machine.ts +31 -0
- package/src/memory/metrics.ts +63 -0
- package/src/memory/mutation.ts +6 -4
- package/src/memory/note-add.ts +100 -0
- package/src/memory/path-glob.ts +94 -0
- package/src/memory/plan-guard.ts +47 -0
- package/src/memory/platform.ts +9 -4
- package/src/memory/policy.ts +44 -13
- package/src/memory/private-spans.ts +32 -0
- package/src/memory/project-export.ts +122 -0
- package/src/memory/project.ts +129 -20
- package/src/memory/reader-lease.ts +22 -10
- package/src/memory/recall-staleness.ts +56 -0
- package/src/memory/recall.ts +74 -0
- package/src/memory/repo-scope.ts +404 -0
- package/src/memory/shard-sync-format.ts +116 -0
- package/src/memory/shard-sync.ts +421 -0
- package/src/memory/shard.ts +3 -80
- package/src/memory/store.ts +286 -56
- package/src/memory/sync-cadence.ts +139 -0
- package/src/memory/sync-screen.ts +86 -0
- package/src/memory/sync.ts +79 -11
- package/src/memory/usage.ts +35 -0
- package/src/memory/use-telemetry.ts +51 -0
- package/src/memory/work-lists.ts +115 -38
- package/src/session-brief-worker.ts +82 -0
- package/src/session-brief.ts +261 -0
- package/src/setup-owned.ts +120 -0
- package/src/setup-toml.ts +291 -0
- package/src/setup.ts +327 -0
package/src/commands-table.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { HOOK_EVENTS } from "./hook-contract.ts";
|
|
1
2
|
import { GOAL_STATES, KINDS } from "./memory/store.ts";
|
|
2
3
|
|
|
3
4
|
export interface OptionDefinition { description: string; value?: "text" | "path" | readonly string[] }
|
|
@@ -7,36 +8,71 @@ export interface CommandDefinition {
|
|
|
7
8
|
options?: Record<string, OptionDefinition>;
|
|
8
9
|
args?: readonly ArgumentDefinition[];
|
|
9
10
|
commands?: Record<string, CommandDefinition>;
|
|
10
|
-
mutates
|
|
11
|
+
/** `confirm`: previews without `--confirm` (a read), and mutates with it (#15 T5: gc, project forget, capacity apply). */
|
|
12
|
+
mutates?: boolean | "apply" | "label" | "confirm";
|
|
11
13
|
policyFree?: boolean;
|
|
12
14
|
defaultCommand?: string;
|
|
13
15
|
}
|
|
14
16
|
const flag = (description: string): OptionDefinition => ({ description });
|
|
15
17
|
const text = (description: string): OptionDefinition => ({ description, value: "text" });
|
|
16
18
|
const json = { "--json": flag("Print JSON where supported") };
|
|
17
|
-
const
|
|
19
|
+
const history = { "--history": flag("Read retained supersession/expiry history; forgotten text stays hidden"), "--as-of": text("Past canonical UTC date or timestamp; current forget/prune masks still apply") };
|
|
20
|
+
const policy = { "--policy-free": flag("Accepted and ignored: the configured privacy policy always applies") };
|
|
18
21
|
const filters = { ...json, "--tier": { description: "Memory tier", value: ["short", "long"] } as OptionDefinition,
|
|
19
22
|
"--kind": { description: "Note kind", value: KINDS }, "--peer": text("Author peer"), "--machine": text("Machine ID"),
|
|
20
23
|
"--since": text("ISO date or time"), "--limit": text("Maximum results") };
|
|
21
24
|
export const GLOBAL_OPTIONS = { "--help": flag("Show help") };
|
|
22
25
|
export const ROOT_OPTIONS = { "--version": flag("Show version"), ...json };
|
|
23
26
|
export const COMMANDS: Record<string, CommandDefinition> = {
|
|
27
|
+
setup: { description: "Preview reversible MCP and hook setup for native coding CLIs; apply and undo are person-only", options: { "--apply": flag("Apply after terminal confirmation"), "--undo": flag("Restore original configuration after confirmation") }, args: [{ name: "target", values: ["claude", "codex", "kimi", "pi", "all"] }] },
|
|
24
28
|
mcp: { description: "Run the project-bound MCP stdio server for this session" },
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
hook: { description: "Run a fail-open, context-only Claude Code hook", args: [{ name: "event", values: [...HOOK_EVENTS] }] },
|
|
30
|
+
ingest: { description: "Incrementally capture native session transcripts without a watcher", options: { ...json, "--since": text("ISO date or time") }, args: [{ name: "kind", values: ["codex", "kimi", "pi"] }] },
|
|
31
|
+
"vault-candidates": { description: "List screened durable curated vault candidates (per project, or across projects)", options: { ...json, "--limit": text("Maximum candidates per project (1..100)"),
|
|
32
|
+
"--all-projects": flag("List every active project's candidates; the reply says it crossed projects"), "--project": text("List one named project's candidates") } },
|
|
33
|
+
list: { description: "List memory", options: { ...filters, ...history } },
|
|
34
|
+
search: { description: "Search memory", options: { ...filters, ...history, "--all-projects": flag("Search every active project; the reply says it crossed projects"),
|
|
35
|
+
"--include-archived": flag("With --all-projects, also read archived projects") }, args: [{ name: "words", variadic: true }] },
|
|
36
|
+
brief: { description: "Read intent/path memory: fails first, then decisions and contracts; untrusted data within a UTF-8 byte cap", options: { ...json,
|
|
37
|
+
"--for": text("Explicit intent"), "--paths": text("Repeatable named path"), "--symbols": text("Repeatable named symbol"), "--bytes": text("Complete brief-body byte cap (1..8192, default 2048)") } },
|
|
38
|
+
projects: { description: "List the project shards of this memory home (#15 D3)", options: { ...json, "--all": flag("Include forgotten projects (archived ones are always listed)") } },
|
|
39
|
+
usage: { description: "Totals and the largest project shards", options: { ...json, "--limit": text("Largest shards to list (1..100)"), "--all": flag("Include forgotten projects") } },
|
|
40
|
+
eval: { description: "Replay a local question set against the ranked store.search (the ranking MCP and hub recall use, not the `search` command's substring filter) and report recall@k and MRR (no model, no network; the built-in synthetic set without --set)", options: { ...json,
|
|
41
|
+
"--set": { description: "A question set file (amem.eval/v1); with entries it is self-contained, without them its ids name entries of this project", value: "path" }, "--k": text("Cutoffs, comma separated (default 1,5,10; each 1..100)") } },
|
|
42
|
+
gc: { description: "Apply each shard's retention and cap across shards in bounded batches (preview without --confirm)", mutates: "confirm", options: {
|
|
43
|
+
...json, "--confirm": flag("Evict now; without it, preview only"), "--batches": text("evict() windows per shard in this run (1..1000, default 1)") } },
|
|
44
|
+
project: { description: "Per-project lifecycle and export (#15 D3): one project only", options: json, commands: {
|
|
45
|
+
export: { description: "Write one project's capture, curated notes, goals and work lists to a new bundle file (never overwrites)", options: { "--to": { description: "Destination file, outside the memory home", value: "path" }, ...policy },
|
|
46
|
+
args: [{ name: "key" }] },
|
|
47
|
+
archive: { description: "Take a cold project out of recall (append-only op; unarchive brings it back)", args: [{ name: "key" }], mutates: true },
|
|
48
|
+
unarchive: { description: "Bring an archived project back into recall (append-only op)", args: [{ name: "key" }], mutates: true },
|
|
49
|
+
forget: { description: "Forget one project for good (append-only op; preview without --confirm)", options: { "--confirm": flag("Record the forget after the preview") }, args: [{ name: "key" }], mutates: "confirm" },
|
|
50
|
+
} },
|
|
51
|
+
show: { description: "Show a note", options: { ...json, ...history }, args: [{ name: "id" }] },
|
|
52
|
+
graph: { description: "Show a graph", options: { ...json, ...history, "--hops": { description: "Graph depth", value: ["1", "2"] } }, args: [{ name: "id-or-node" }] },
|
|
30
53
|
suggestions: { description: "List pending suggestions", options: json },
|
|
31
54
|
confirm: { description: "Confirm a suggestion", options: json, args: [{ name: "id" }], mutates: true },
|
|
32
55
|
reject: { description: "Reject a suggestion", options: json, args: [{ name: "id" }], mutates: true },
|
|
33
|
-
promote: { description: "Promote a note", options: { ...json, ...policy
|
|
56
|
+
promote: { description: "Promote a note (--to repo: append its screened line to this repository's .amem/log.jsonl)", options: { ...json, ...policy, "--to": { description: "Promote into the repository scope", value: ["repo"] },
|
|
57
|
+
"--confirm": flag("With --to repo: append the previewed line"), "--confirm-public": text("With --to repo, unless the repository is verified private: the first 8 characters of the entry id") }, args: [{ name: "id" }], mutates: true, policyFree: true },
|
|
58
|
+
repo: { description: "The repository scope (#15 D6): this repository's .amem/log.jsonl", options: json, defaultCommand: "status", commands: {
|
|
59
|
+
status: { description: "Show the repository log, its entries and what the screen withheld" },
|
|
60
|
+
attributes: { description: "Add the merge=union entry for .amem/log.jsonl to .gitattributes (preview without --confirm; never committed)", mutates: "confirm", options: { "--confirm": flag("Write after the preview") } },
|
|
61
|
+
} },
|
|
34
62
|
demote: { description: "Demote a note", options: json, args: [{ name: "id" }], mutates: true },
|
|
35
63
|
forget: { description: "Forget a note", options: json, args: [{ name: "id" }], mutates: true },
|
|
64
|
+
note: { description: "Write notes as the person (F6)", options: json, commands: {
|
|
65
|
+
add: { description: "Add one curated note: text as arguments, `-` to type it on the terminal until end of input, or no text to open $EDITOR. A fail note takes --approach, --symptom, --cause, --evidence (repeatable) and --retry-when", mutates: true, options: {
|
|
66
|
+
"--kind": { description: "Note kind (default finding)", value: KINDS }, "--tier": { description: "Memory tier (default long)", value: ["short", "long"] },
|
|
67
|
+
"--scope": { description: "Scope (default project)", value: ["project", "shared"] }, "--title": text("One-line title"),
|
|
68
|
+
"--approach": text("fail: what was tried"), "--symptom": text("fail: what went wrong"), "--cause": text("fail: why"), "--evidence": text("fail: a reference (repeatable)"), "--retry-when": text("fail: what would make the approach worth retrying") },
|
|
69
|
+
args: [{ name: "text", variadic: true }] },
|
|
70
|
+
} },
|
|
36
71
|
supersede: { description: "Replace a note", options: json, args: [{ name: "old-id" }, { name: "new-id" }], mutates: true },
|
|
37
72
|
rebuild: { description: "Rebuild the derived index", options: json, mutates: true },
|
|
38
73
|
dedupe: { description: "Preview duplicate reconciliation", options: { ...json, "--apply": flag("Append reconciliation operations") }, mutates: "apply" },
|
|
39
|
-
sync: { description: "Sync a private remote", options: { ...json, ...policy, "--remote": text("Private git URL")
|
|
74
|
+
sync: { description: "Sync a private remote (--if-due: only when the schedule and the failure backoff allow it; --force ignores both)", options: { ...json, ...policy, "--remote": text("Private git URL"),
|
|
75
|
+
"--if-due": flag("Sync only when due (hourly after a success, 15 minutes doubling to a day after failures); stops after 30 seconds"), "--force": flag("With --if-due: ignore the schedule and the backoff") }, mutates: true, policyFree: true },
|
|
40
76
|
machine: { description: "Show or label this machine", options: { ...json, "--label": text("Machine label") }, mutates: "label" },
|
|
41
77
|
import: { description: "Import external memory", options: { ...json, ...policy, "--db": { description: "SQLite database file", value: "path" }, "--limit": text("Maximum observations"), "--restart": flag("Restart import cursor") }, mutates: true, policyFree: true,
|
|
42
78
|
commands: { "claude-mem": { description: "Import claude-mem observations" } } },
|
|
@@ -44,7 +80,7 @@ export const COMMANDS: Record<string, CommandDefinition> = {
|
|
|
44
80
|
list: { description: "List goals", options: { "--all": flag("Include completed goals") } },
|
|
45
81
|
show: { description: "Show a goal", args: [{ name: "id" }] },
|
|
46
82
|
new: { description: "Create a goal", options: { "--milestone": text("Milestone"), "--depends": text("Dependency goal ID"), "--shared": flag("Share across projects") }, args: [{ name: "title", variadic: true }], mutates: true },
|
|
47
|
-
link: { description: "Link
|
|
83
|
+
link: { description: "Link a goal to a task of a host that has a task board (not available in amem alone)", args: [{ name: "id" }, { name: "task-id" }], mutates: true },
|
|
48
84
|
state: { description: "Set goal state", args: [{ name: "id" }, { name: "state", values: GOAL_STATES }], mutates: true },
|
|
49
85
|
done: { description: "Complete a goal", args: [{ name: "id" }], mutates: true },
|
|
50
86
|
take: { description: "Take a goal lease", options: { "--force": flag("Override the prior lease") }, args: [{ name: "id" }], mutates: true },
|
|
@@ -52,25 +88,30 @@ export const COMMANDS: Record<string, CommandDefinition> = {
|
|
|
52
88
|
review: { description: "Review machine-local non-evidentiary candidates", options: json, defaultCommand: "list", commands: {
|
|
53
89
|
list: { description: "List review candidates", options: {
|
|
54
90
|
"--status": { description: "Candidate status", value: ["pending", "accepting", "accepted", "rejected", "all"] },
|
|
55
|
-
"--kind": { description: "Candidate kind", value: ["correction", "fail", "decay", "memory"] }, "--limit": text("Maximum candidates (1..500)"),
|
|
91
|
+
"--kind": { description: "Candidate kind", value: ["correction", "fail", "decay", "memory", "promotion"] }, "--limit": text("Maximum candidates (1..500)"),
|
|
56
92
|
} },
|
|
57
|
-
accept: { description: "Accept one candidate through the current memory policy", options: { ...policy, "--kind": { description: "
|
|
93
|
+
accept: { description: "Accept one candidate through the current memory policy", options: { ...policy, "--kind": { description: "Require the selected candidate kind", value: ["correction", "fail", "decay", "memory", "promotion"] } }, args: [{ name: "full-candidate-id" }], mutates: true, policyFree: true },
|
|
58
94
|
reject: { description: "Reject one candidate", args: [{ name: "full-candidate-id" }], mutates: true },
|
|
95
|
+
walk: { description: "Walk the pending candidates one by one: accept, edit (as a new note), reject, skip or quit", mutates: true, options: { "--kind": { description: "Candidate kind", value: ["correction", "fail", "decay", "memory", "promotion"] } } },
|
|
59
96
|
} },
|
|
97
|
+
decay: { description: "Preview old live long-term notes unprotected by open goals; apply only after person review", mutates: "apply", options: {
|
|
98
|
+
...json, "--days": text("Age in whole days (default 90)"), "--apply": flag("Show the exact revision preview and ask before applying"), "--forget": flag("Explicitly forget instead of demoting; confirmation names this action") }, args: [{ name: "id", variadic: true }] },
|
|
60
99
|
migrate: { description: "Select a person-approved generation or deactivate it", mutates: true, options: {
|
|
61
100
|
...json, "--dry-run": flag("Read-only migration receipt"), "--generation": text("Explicit 32-hex generation ID"),
|
|
62
101
|
"--resume": flag("Resume the recorded migration"), "--deactivate": flag("Select shared v1 without deleting a generation"),
|
|
63
102
|
"--shards": flag("Migrate the curated home into project and user shards"),
|
|
64
103
|
} },
|
|
65
104
|
status: { description: "Report shard migration and pending old-layout appends", options: json },
|
|
105
|
+
doctor: { description: "One read-only report (F15): duplicates and stale notes, pending candidates, capture freshness per CLI, the brief's token cost, index equals rebuild, reader leases, the policy in effect, ingest and sync cadence", options: json },
|
|
66
106
|
prune: { description: "Plan or apply exact-entry durable expiry in a generation", mutates: true, options: {
|
|
67
107
|
...json, "--cutoff": text("Canonical UTC expiry cutoff"), "--dry-run": flag("Print a local sealed plan without writes"),
|
|
68
108
|
"--user": flag("Select the user shard explicitly after a curated split (default: current project)"),
|
|
69
109
|
"--apply": flag("Apply the exact local plan"), "--plan": { description: "Sealed local plan JSON file", value: "path" },
|
|
70
110
|
} },
|
|
71
111
|
capacity: { description: "Report capture use and propose R6 bounds (R7)", options: { ...json, "--disk-share": text("Disk-share bound for the proposal (default 0.02)") }, commands: {
|
|
72
|
-
apply: { description: "Write the proposed (or given) bounds to the home capture config", mutates: true, options: {
|
|
73
|
-
...json, "--cap-mb": text("Capture cap in MB"), "--retention-days": text("Capture retention in days"), "--confirm": flag("Write after the preview")
|
|
112
|
+
apply: { description: "Write the proposed (or given) bounds to the home capture config, or with --split each shard's share of the global budget", mutates: true, options: {
|
|
113
|
+
...json, "--cap-mb": text("Capture cap in MB"), "--retention-days": text("Capture retention in days"), "--confirm": flag("Write after the preview"),
|
|
114
|
+
"--split": flag("Write the per-shard split of the global budget to each shard's own config") } },
|
|
74
115
|
} },
|
|
75
116
|
alias: { description: "Person-reviewed project key aliases (#15 D2)", options: json, defaultCommand: "list", commands: {
|
|
76
117
|
list: { description: "List alias ops" },
|
|
@@ -149,5 +190,5 @@ export function commandHelp(): string {
|
|
|
149
190
|
return ["usage: amem <command> [options]", " [--help] [--version] [--json]", ...completionDefinitions().filter(row => row.path.length).map(row => {
|
|
150
191
|
const names = [...row.path, ...row.args.map(arg => `<${arg.name}>`)].join(" ");
|
|
151
192
|
return ` ${names}${Object.entries(row.options).map(([name, option]) => ` [${name}${option.value === undefined ? "" : ` <${Array.isArray(option.value) ? option.value.join("|") : option.value}>`}]`).join("")}`;
|
|
152
|
-
}), "--policy-free:
|
|
193
|
+
}), "--policy-free: accepted and ignored; the configured privacy policy (amem.config.json in the memory home, narrowed by .amem/config) always applies.", "Mutating commands require a terminal on stdin. Agents use the `amem mcp` server; the person reviews what agents propose with `amem review`."].join("\n");
|
|
153
194
|
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export const HOOK_EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PostToolUseFailure", "Stop", "SessionEnd"] as const;
|
|
2
|
+
export type HookEvent = typeof HOOK_EVENTS[number];
|
|
3
|
+
export const HOOK_INPUT_BYTES = 64 * 1024, HOOK_CONTEXT_BYTES = 1200, HOOK_OUTPUT_BYTES = 8192;
|
package/src/hook-git.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstatSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { realPath } from "./memory/platform.ts";
|
|
5
|
+
import { memoryGitArgv, memoryGitEnv } from "./memory/platform.ts";
|
|
6
|
+
import type { ProjectGitRead } from "./memory/project.ts";
|
|
7
|
+
|
|
8
|
+
/** Synchronous compatibility port for library callers, with shared Git hardening and
|
|
9
|
+
* bounded output. Its deadline check precedes each read but cannot interrupt a blocked
|
|
10
|
+
* spawnSync. Native hook workers use resolveHookProjectAsync and hookNative instead,
|
|
11
|
+
* keeping the JS deadline responsive and the native self-watchdog independent of Bun. */
|
|
12
|
+
export function hookGit(timeRemaining: () => number, binary?: string): ProjectGitRead {
|
|
13
|
+
return (args, env) => {
|
|
14
|
+
const remaining = timeRemaining();
|
|
15
|
+
if (!Number.isFinite(remaining) || remaining <= 0) throw new Error("Git project identity unavailable");
|
|
16
|
+
const argv = memoryGitArgv(args); argv[0] = binary ?? hookGitBinary(env).file;
|
|
17
|
+
const result = Bun.spawnSync(argv, {
|
|
18
|
+
env: memoryGitEnv(env), stdin: "ignore", stdout: "pipe", stderr: "ignore", maxBuffer: 64 * 1024,
|
|
19
|
+
});
|
|
20
|
+
if (result.stdout.byteLength >= 64 * 1024) throw new Error("Git project identity unavailable");
|
|
21
|
+
return { status: result.exitCode, signal: result.signalCode ? String(result.signalCode) : undefined,
|
|
22
|
+
stdout: new TextDecoder("utf-8", { fatal: true }).decode(result.stdout) };
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Only bypass Apple's selected /usr/bin/git shim, never a caller's custom PATH binary.
|
|
27
|
+
* Resolve the root-owned developer selection on disk, without launching xcrun on the hook deadline. */
|
|
28
|
+
export function hookGitBinary(env: NodeJS.ProcessEnv = process.env): { file: string; revision: string } {
|
|
29
|
+
let file = realPath(Bun.which("git", { PATH: env.PATH ?? process.env.PATH ?? "" }) ?? (() => { throw new Error("Git unavailable"); })());
|
|
30
|
+
if (process.platform === "darwin" && file === "/usr/bin/git" && !env.DEVELOPER_DIR) {
|
|
31
|
+
try {
|
|
32
|
+
const selected = "/var/db/xcode_select_link", link = lstatSync(selected);
|
|
33
|
+
if (link.isSymbolicLink() && link.uid === 0) {
|
|
34
|
+
const candidate = realPath(join(realPath(selected), "usr", "bin", "git")), stat = lstatSync(candidate);
|
|
35
|
+
if (stat.isFile() && stat.uid === 0 && (stat.mode & 0o111)) file = candidate;
|
|
36
|
+
}
|
|
37
|
+
} catch { /* A missing/unproven selection keeps the PATH-selected Git and its normal refusal. */ }
|
|
38
|
+
}
|
|
39
|
+
const stat = lstatSync(file);
|
|
40
|
+
if (!stat.isFile() || !(stat.mode & 0o111)) throw new Error("Git unavailable");
|
|
41
|
+
return { file, revision: createHash("sha256").update(`${file}:${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`).digest("hex") };
|
|
42
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
export interface HookNativeUsage { rssKB: number; rawRssKB: number }
|
|
5
|
+
// One native supervisor, with a native sleep watchdog rather than another Bun boot.
|
|
6
|
+
// The leader waits for its watchdog even after stdin EOF, so losing the calling CLI
|
|
7
|
+
// cannot let the owned command outlive the same remaining per-call deadline.
|
|
8
|
+
const SUPERVISOR = [
|
|
9
|
+
'marker=$1; seconds=$2; shift 2; leader=$$',
|
|
10
|
+
// Fixed ps plus the unguessable invocation marker guards an externally killed/reused
|
|
11
|
+
// leader too. Normal completion never exits the leader before its group is reaped.
|
|
12
|
+
'owned() { identity=$(/bin/ps -ww -p "$leader" -o pgid=,command=) || return 1; case "$identity" in *"$marker"*) ;; *) return 1 ;; esac; set -f; set -- $identity; [ "$1" = "$leader" ]; }',
|
|
13
|
+
'reap() { if owned; then kill -KILL "-$leader"; fi; }',
|
|
14
|
+
'[ -x /bin/sleep ] && [ -x /bin/ps ] && owned || exit 125',
|
|
15
|
+
'trap reap EXIT HUP INT TERM',
|
|
16
|
+
'( /bin/sleep "$seconds" & sleeper=$!; kill -0 "$sleeper" 2>/dev/null || { reap; exit 125; }; printf "\\n%s:ARMED\\n" "$marker"; wait "$sleeper"; reap ) & watchdog=$!',
|
|
17
|
+
'kill -0 "$watchdog" 2>/dev/null || { reap; exit 125; }',
|
|
18
|
+
'"$@" </dev/null; code=$?; printf "\\n%s:%s\\n" "$marker" "$code"',
|
|
19
|
+
'read -r ignored || :',
|
|
20
|
+
'wait "$watchdog"',
|
|
21
|
+
].join("; ");
|
|
22
|
+
|
|
23
|
+
export async function hookNative(argv: readonly string[], env: NodeJS.ProcessEnv, timeRemaining: () => number, usage: HookNativeUsage, signal?: AbortSignal): Promise<{ status: number; stdout: string }> {
|
|
24
|
+
if (signal?.aborted) throw new Error("native metadata cancelled");
|
|
25
|
+
const remaining = timeRemaining();
|
|
26
|
+
if (!Number.isFinite(remaining) || remaining <= 1) throw new Error("native metadata deadline");
|
|
27
|
+
const marker = `AMEM_META_${randomBytes(16).toString("hex")}`, prefix = `\n${marker}:`, armedFrame = `${prefix}ARMED\n`;
|
|
28
|
+
const child = Bun.spawn(["/bin/sh", "-c", SUPERVISOR, "amem-hook-native", marker, String(remaining / 1000), ...argv],
|
|
29
|
+
{ env, detached: true, stdin: "pipe", stdout: "pipe", stderr: "ignore" });
|
|
30
|
+
let exited = false, ownPeak = 0;
|
|
31
|
+
const exit = child.exited.then(code => { exited = true; return code; });
|
|
32
|
+
const stop = () => {
|
|
33
|
+
if (exited) return;
|
|
34
|
+
if (process.platform === "linux") try {
|
|
35
|
+
const match = /^VmHWM:\s+(\d+)\s+kB$/m.exec(readFileSync(`/proc/${child.pid}/status`, "utf8"));
|
|
36
|
+
if (match && Number.isSafeInteger(Number(match[1]))) ownPeak = Number(match[1]);
|
|
37
|
+
} catch { /* Fall back conservatively to reaped rusage. */ }
|
|
38
|
+
try { process.kill(-child.pid, "SIGKILL"); } catch { try { child.kill("SIGKILL"); } catch { /* already exited */ } }
|
|
39
|
+
};
|
|
40
|
+
const reader = child.stdout.getReader();
|
|
41
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
42
|
+
let aborted: (() => void) | undefined;
|
|
43
|
+
try {
|
|
44
|
+
const read = async () => {
|
|
45
|
+
let text = "", bytes = 0, armed = false;
|
|
46
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
47
|
+
for (;;) {
|
|
48
|
+
const next = await reader.read();
|
|
49
|
+
if (next.done) throw new Error("native metadata incomplete");
|
|
50
|
+
bytes += next.value.byteLength;
|
|
51
|
+
if (bytes > 64 * 1024 + 128) throw new Error("native metadata bound");
|
|
52
|
+
text += decoder.decode(next.value, { stream: true });
|
|
53
|
+
if (text.includes(armedFrame)) { text = text.replace(armedFrame, ""); armed = true; }
|
|
54
|
+
const at = text.indexOf(prefix);
|
|
55
|
+
if (at < 0) continue;
|
|
56
|
+
const code = /^(\d{1,3})\n/.exec(text.slice(at + prefix.length));
|
|
57
|
+
if (!code || !armed) continue;
|
|
58
|
+
const status = Number(code[1]);
|
|
59
|
+
if (status > 255 || Buffer.byteLength(text.slice(0, at)) >= 64 * 1024) throw new Error("native metadata invalid");
|
|
60
|
+
return { status, stdout: text.slice(0, at) };
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
return await Promise.race([read(), new Promise<never>((_, reject) => {
|
|
64
|
+
timer = setTimeout(() => { stop(); reject(new Error("native metadata deadline")); }, remaining);
|
|
65
|
+
}), new Promise<never>((_, reject) => {
|
|
66
|
+
aborted = () => { stop(); reject(new Error("native metadata cancelled")); };
|
|
67
|
+
signal?.addEventListener("abort", aborted, { once: true });
|
|
68
|
+
if (signal?.aborted) aborted();
|
|
69
|
+
})]);
|
|
70
|
+
} finally {
|
|
71
|
+
if (aborted) signal?.removeEventListener("abort", aborted);
|
|
72
|
+
if (timer) clearTimeout(timer);
|
|
73
|
+
stop();
|
|
74
|
+
// SIGKILL was delivered to every member before waiting for our owned leader.
|
|
75
|
+
// Init can briefly retain zombie grandchildren; they cannot execute, and only
|
|
76
|
+
// their adopting reaper can remove those PIDs (we cannot waitpid a non-child).
|
|
77
|
+
await exit;
|
|
78
|
+
try { child.stdin.end(); } catch { /* pipe closed with the owned leader */ }
|
|
79
|
+
await reader.cancel().catch(() => {}); reader.releaseLock();
|
|
80
|
+
const raw = (child.resourceUsage()?.maxRSS ?? 0) / 1024;
|
|
81
|
+
usage.rawRssKB = Math.max(usage.rawRssKB, raw);
|
|
82
|
+
usage.rssKB = Math.max(usage.rssKB, process.platform === "linux" ? ownPeak || raw : Math.max(ownPeak, raw));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Same pinned ps format/hash as the shared process identity helper, with bounded owned native work. */
|
|
87
|
+
export async function hookProcessSignature(timeRemaining: () => number, usage: HookNativeUsage): Promise<string> {
|
|
88
|
+
const result = await hookNative(["/bin/ps", "-p", String(process.pid), "-o", "lstart=,comm="],
|
|
89
|
+
{ PATH: process.env.PATH ?? "", LC_ALL: "C", TZ: "UTC" }, timeRemaining, usage);
|
|
90
|
+
if (result.status !== 0 || !result.stdout.trim()) throw new Error("native process identity unavailable");
|
|
91
|
+
return createHash("sha256").update(result.stdout.trim()).digest("hex");
|
|
92
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, opendirSync, readSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { hookGit, hookGitBinary } from "./hook-git.ts";
|
|
5
|
+
import { hookCaptureScope } from "./hook-spool.ts";
|
|
6
|
+
import { canonicalPath, realPath } from "./memory/platform.ts";
|
|
7
|
+
import { memoryProjectKey, type ProjectGitRead } from "./memory/project.ts";
|
|
8
|
+
import { memoryGitArgv, memoryGitEnv } from "./memory/platform.ts";
|
|
9
|
+
import { hookNative, type HookNativeUsage } from "./hook-native.ts";
|
|
10
|
+
|
|
11
|
+
// Bump the derived cache schema if the shared native project-key mapping changes.
|
|
12
|
+
const SCHEMA = "agent-mem.hook-project/v1", CACHE_BYTES = 4096, CACHE_CWDS = 128;
|
|
13
|
+
const hash = (text: string) => createHash("sha256").update(text).digest("hex");
|
|
14
|
+
interface Binding { schema: typeof SCHEMA; cwd: string; scope: string; validity: string; binary: string; project: string }
|
|
15
|
+
function read(file: string): Binding | undefined {
|
|
16
|
+
let fd: number | undefined;
|
|
17
|
+
try {
|
|
18
|
+
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
19
|
+
const stat = fstatSync(fd);
|
|
20
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > CACHE_BYTES || stat.mode & 0o077 || stat.uid !== process.getuid?.()) return undefined;
|
|
21
|
+
const bytes = Buffer.alloc(stat.size);
|
|
22
|
+
if (readSync(fd, bytes, 0, bytes.length, 0) !== bytes.length) return undefined;
|
|
23
|
+
const after = lstatSync(file);
|
|
24
|
+
if (after.dev !== stat.dev || after.ino !== stat.ino || after.size !== stat.size || after.mtimeMs !== stat.mtimeMs) return undefined;
|
|
25
|
+
const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
26
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).sort().join() !== "binary,cwd,project,schema,scope,validity"
|
|
27
|
+
|| value.schema !== SCHEMA || typeof value.cwd !== "string" || typeof value.scope !== "string" || !/^[a-f0-9]{64}$/.test(value.scope)
|
|
28
|
+
|| typeof value.validity !== "string" || !/^[a-f0-9]{64}$/.test(value.validity) || typeof value.binary !== "string" || !/^[a-f0-9]{64}$/.test(value.binary) || typeof value.project !== "string" || !value.project || value.project.length > 2048 || /[\x00-\x1f\x7f]/.test(value.project)) return undefined;
|
|
29
|
+
return value;
|
|
30
|
+
} catch { return undefined; }
|
|
31
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
32
|
+
}
|
|
33
|
+
function publish(dir: string, file: string, value: Binding, maintain: boolean): void {
|
|
34
|
+
let temporary: string | undefined;
|
|
35
|
+
try {
|
|
36
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
37
|
+
const stat = lstatSync(dir); if (!stat.isDirectory() || stat.isSymbolicLink()) return;
|
|
38
|
+
chmodSync(dir, 0o700);
|
|
39
|
+
// Only old, owned publication names are eligible; a recent writer keeps its temp.
|
|
40
|
+
if (maintain) {
|
|
41
|
+
const scan = opendirSync(dir);
|
|
42
|
+
try {
|
|
43
|
+
for (let count = 0; count < CACHE_CWDS * 2; count++) {
|
|
44
|
+
const entry = scan.readSync(); if (!entry) break;
|
|
45
|
+
if (!/^\.[a-f0-9]{64}-[a-f0-9]{24}$/.test(entry.name)) continue;
|
|
46
|
+
const stale = join(dir, entry.name), before = lstatSync(stale, { throwIfNoEntry: false });
|
|
47
|
+
if (!before?.isFile() || before.nlink !== 1 || before.uid !== process.getuid?.() || Date.now() - before.mtimeMs < 60_000) continue;
|
|
48
|
+
const after = lstatSync(stale, { throwIfNoEntry: false });
|
|
49
|
+
if (after?.dev === before.dev && after.ino === before.ino && after.size === before.size && after.mtimeMs === before.mtimeMs) rmSync(stale);
|
|
50
|
+
}
|
|
51
|
+
} finally { scan.closeSync(); }
|
|
52
|
+
}
|
|
53
|
+
// ponytail: 128 cached cwd identities, one bounded derived value each. New cwd misses
|
|
54
|
+
// still resolve natively; a future cache policy may retire old entries without touching capture.
|
|
55
|
+
if (!lstatSync(file, { throwIfNoEntry: false })) {
|
|
56
|
+
const entries = opendirSync(dir);
|
|
57
|
+
try { for (let count = 0; count <= CACHE_CWDS; count++) { if (!entries.readSync()) break; if (count === CACHE_CWDS - 1) return; } }
|
|
58
|
+
finally { entries.closeSync(); }
|
|
59
|
+
}
|
|
60
|
+
const text = JSON.stringify(value); if (Buffer.byteLength(text) > CACHE_BYTES) return;
|
|
61
|
+
temporary = join(dir, `.${hash(value.cwd)}-${randomBytes(12).toString("hex")}`);
|
|
62
|
+
writeFileSync(temporary, text, { mode: 0o600, flag: "wx" }); renameSync(temporary, file);
|
|
63
|
+
} catch { /* Derived cache I/O never changes a successfully resolved identity. */ }
|
|
64
|
+
finally { if (temporary) try { rmSync(temporary, { force: true }); } catch { /* Cache cleanup stays advisory too. */ } }
|
|
65
|
+
}
|
|
66
|
+
interface ResolverOptions { cwd: string; base: string; scope?: string; timeRemaining: () => number; maintainCache?: boolean; gitRead?: ProjectGitRead; assertComplete?: () => void; env?: NodeJS.ProcessEnv }
|
|
67
|
+
|
|
68
|
+
/** Prepare filesystem/cache binding once; suspended native reads replay only the shared resolver. */
|
|
69
|
+
function prepareHookProject(options: ResolverOptions) {
|
|
70
|
+
const checkDeadline = () => {
|
|
71
|
+
const remaining = options.timeRemaining();
|
|
72
|
+
if (!Number.isFinite(remaining) || remaining <= 0) throw new Error("project deadline");
|
|
73
|
+
};
|
|
74
|
+
checkDeadline();
|
|
75
|
+
const cwd = realPath(options.cwd), base = canonicalPath(options.base), binary = hookGitBinary(options.env);
|
|
76
|
+
const scope = options.scope;
|
|
77
|
+
if (scope !== undefined && (!scope || hookCaptureScope(cwd, base) !== scope)) throw new Error("project scope changed");
|
|
78
|
+
const cwdStat = lstatSync(cwd);
|
|
79
|
+
const prefixConfig = join(dirname(dirname(binary.file)), "etc", "gitconfig");
|
|
80
|
+
// Unknown semantic scope cannot certify a reusable identity, but native Git can still
|
|
81
|
+
// resolve the current project for a read-only brief. Never read or publish cache in this case.
|
|
82
|
+
const validity = scope === undefined ? undefined : hookCaptureScope(cwd, base, true, prefixConfig);
|
|
83
|
+
const dir = join(base, "hook-project"), named = lstatSync(dir, { throwIfNoEntry: false });
|
|
84
|
+
const file = join(dir, `${hash(cwd)}.json`);
|
|
85
|
+
const cached = scope !== undefined && (!named || named.isDirectory() && !named.isSymbolicLink() && !(named.mode & 0o077)) ? read(file) : undefined;
|
|
86
|
+
const hit = cached?.cwd === cwd && cached.scope === scope && validity && cached.validity === validity && cached.binary === binary.revision && cacheableLocal(cwd, cached.project);
|
|
87
|
+
const verifyBinary = () => {
|
|
88
|
+
checkDeadline();
|
|
89
|
+
const currentBinary = hookGitBinary(options.env);
|
|
90
|
+
if (currentBinary.file !== binary.file || currentBinary.revision !== binary.revision) throw new Error("project binary changed");
|
|
91
|
+
checkDeadline();
|
|
92
|
+
};
|
|
93
|
+
const verify = () => {
|
|
94
|
+
verifyBinary();
|
|
95
|
+
// A known strong binding includes every semantic field. Only the uncached,
|
|
96
|
+
// unknown-validity case also needs its independent semantic scope check.
|
|
97
|
+
if (scope === undefined) {
|
|
98
|
+
const currentCwd = lstatSync(cwd);
|
|
99
|
+
if (realPath(options.cwd) !== cwd || currentCwd.dev !== cwdStat.dev || currentCwd.ino !== cwdStat.ino) throw new Error("project cwd changed");
|
|
100
|
+
} else {
|
|
101
|
+
const currentValidity = hookCaptureScope(cwd, base, true, prefixConfig);
|
|
102
|
+
if (currentValidity !== validity || !validity && hookCaptureScope(cwd, base) !== scope) throw new Error("project scope changed");
|
|
103
|
+
}
|
|
104
|
+
checkDeadline();
|
|
105
|
+
};
|
|
106
|
+
return { binary, verifyBinary, resolve(gitRead: ProjectGitRead, assertComplete?: () => void): string {
|
|
107
|
+
checkDeadline();
|
|
108
|
+
if (hit && cached) {
|
|
109
|
+
assertComplete?.(); verify();
|
|
110
|
+
return cached.project;
|
|
111
|
+
}
|
|
112
|
+
const project = memoryProjectKey(cwd, { repositoryIsolation: true, strictGitErrors: true, gitRead });
|
|
113
|
+
// The synchronous resolver can catch an unknown native read internally. Fence that
|
|
114
|
+
// suspended pass before any completion checks, cache publication or returned key.
|
|
115
|
+
assertComplete?.(); verify();
|
|
116
|
+
if (scope !== undefined && validity && cacheableLocal(cwd, project)) {
|
|
117
|
+
checkDeadline();
|
|
118
|
+
publish(dir, file, { schema: SCHEMA, cwd, scope, validity, binary: binary.revision, project }, options.maintainCache === true);
|
|
119
|
+
}
|
|
120
|
+
checkDeadline();
|
|
121
|
+
return project;
|
|
122
|
+
} };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Cache only a successful shared native resolver result, before alias resolution. */
|
|
126
|
+
export function resolveHookProject(options: ResolverOptions): string {
|
|
127
|
+
const prepared = prepareHookProject(options);
|
|
128
|
+
return prepared.resolve(options.gitRead ?? hookGit(options.timeRemaining, prepared.binary.file), options.assertComplete);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A local key's superproject chain depends on outer Git indexes. Cache only ordinary
|
|
132
|
+
* standalone roots; linked/submodule/local nested layouts retain the native resolver. */
|
|
133
|
+
function cacheableLocal(cwd: string, project: string): boolean {
|
|
134
|
+
if (!/.+@[a-f0-9]{8}$/.test(project)) return true;
|
|
135
|
+
if (!project.endsWith(`@${hash(cwd).slice(0, 8)}`)) return false;
|
|
136
|
+
try {
|
|
137
|
+
const git = lstatSync(join(cwd, ".git"));
|
|
138
|
+
if (!git.isDirectory() || git.isSymbolicLink()) return false;
|
|
139
|
+
for (let folder = dirname(cwd), depth = 0; depth < 32; folder = dirname(folder), depth++) {
|
|
140
|
+
if (lstatSync(join(folder, ".git"), { throwIfNoEntry: false })) return false;
|
|
141
|
+
if (folder === dirname(folder)) return true;
|
|
142
|
+
}
|
|
143
|
+
} catch { /* Unprovable ancestry keeps native resolution. */ }
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Replay the shared synchronous resolver only after every requested native read is known.
|
|
148
|
+
* A suspended/partially resolved pass can never publish a cache entry or return a guessed key. */
|
|
149
|
+
export async function resolveHookProjectAsync(options: { cwd: string; base: string; scope?: string; timeRemaining: () => number; maintainCache?: boolean; prefetch?: boolean }, usage: HookNativeUsage): Promise<string> {
|
|
150
|
+
const reads = new Map<string, ReturnType<ProjectGitRead>>();
|
|
151
|
+
const env = { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "", AMEM_HOME: options.base };
|
|
152
|
+
const prepared = prepareHookProject({ ...options, env });
|
|
153
|
+
const suspended = new Error("native identity suspended");
|
|
154
|
+
const pending = new Map<string, { abort: AbortController; result: Promise<ReturnType<ProjectGitRead>>; usage: HookNativeUsage }>();
|
|
155
|
+
const active = new Set<string>(), overlaps: string[][] = [];
|
|
156
|
+
let prefetched = false;
|
|
157
|
+
const query = (args: string[]) => {
|
|
158
|
+
const key = JSON.stringify(args), existing = pending.get(key); if (existing) return existing.result;
|
|
159
|
+
if (pending.size >= 64) throw new Error("native identity query bound");
|
|
160
|
+
prepared.verifyBinary(); // No awaited native read may execute a replaced binary.
|
|
161
|
+
const argv = memoryGitArgv(args); argv[0] = prepared.binary.file;
|
|
162
|
+
const abort = new AbortController(), own = { rssKB: 0, rawRssKB: 0 };
|
|
163
|
+
active.add(key); overlaps.push([...active]);
|
|
164
|
+
const result = hookNative(argv, memoryGitEnv(env, { repositoryIsolation: true }), options.timeRemaining, own, abort.signal)
|
|
165
|
+
.finally(() => { active.delete(key); });
|
|
166
|
+
// Speculative failures have no authority until the shared resolver requests that read.
|
|
167
|
+
void result.catch(() => {});
|
|
168
|
+
pending.set(key, { abort, result, usage: own }); return result;
|
|
169
|
+
};
|
|
170
|
+
try {
|
|
171
|
+
for (let count = 0; count < 64; count++) {
|
|
172
|
+
let needed: { key: string; args: string[] } | undefined;
|
|
173
|
+
let result: string | undefined, failure: unknown;
|
|
174
|
+
try {
|
|
175
|
+
result = prepared.resolve((args) => {
|
|
176
|
+
const key = JSON.stringify(args);
|
|
177
|
+
const value = reads.get(key); if (value) return value;
|
|
178
|
+
needed ??= { key, args }; throw suspended;
|
|
179
|
+
}, () => { if (needed) throw suspended; });
|
|
180
|
+
} catch (error) { failure = error; }
|
|
181
|
+
if (!needed) { if (failure) throw failure; if (result !== undefined) return result; throw suspended; }
|
|
182
|
+
const request = needed as { key: string; args: string[] };
|
|
183
|
+
const demanded = query(request.args);
|
|
184
|
+
// A native read is requested only on a real cache miss. Prefetch the portable
|
|
185
|
+
// resolver's ordinary-repository metadata alongside origin, never an alternate
|
|
186
|
+
// identity algorithm. Only memoryProjectKey decides which replies it consumes.
|
|
187
|
+
if (options.prefetch === true && !prefetched && request.args.length === 5 && request.args[0] === "-C" && request.args.slice(2).join(" ") === "remote get-url origin") {
|
|
188
|
+
prefetched = true;
|
|
189
|
+
void query(["-C", request.args[1]!, "rev-parse", "--path-format=absolute", "--git-common-dir", "--git-dir", "--is-bare-repository", "--show-toplevel", "--show-superproject-working-tree"]);
|
|
190
|
+
}
|
|
191
|
+
reads.set(request.key, await demanded);
|
|
192
|
+
}
|
|
193
|
+
throw new Error("native identity query bound");
|
|
194
|
+
} finally {
|
|
195
|
+
// Never leave an unused prefetch or its descendants behind, even if origin alone
|
|
196
|
+
// resolved the key or a requested read failed. Abort still uses the owned reaper.
|
|
197
|
+
for (const child of pending.values()) child.abort.abort();
|
|
198
|
+
await Promise.allSettled([...pending.values()].map(child => child.result));
|
|
199
|
+
// Per-call peaks arrive at reap. Conservatively sum all calls whose owned
|
|
200
|
+
// lifetimes overlapped, rather than hiding concurrent helpers behind max().
|
|
201
|
+
for (const group of overlaps) {
|
|
202
|
+
usage.rssKB = Math.max(usage.rssKB, group.reduce((sum, key) => sum + pending.get(key)!.usage.rssKB, 0));
|
|
203
|
+
usage.rawRssKB = Math.max(usage.rawRssKB, group.reduce((sum, key) => sum + pending.get(key)!.usage.rawRssKB, 0));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
/** Linux rusage includes the pre-exec launcher's inherited high-water value. VmHWM belongs
|
|
4
|
+
* to this process image, so a large test runner cannot be mistaken for resident hook memory.
|
|
5
|
+
* Unavailable telemetry falls back conservatively to rusage, never to zero. */
|
|
6
|
+
export function hookPeakRssKB(): number {
|
|
7
|
+
if (process.platform === "linux") {
|
|
8
|
+
try {
|
|
9
|
+
const text = readFileSync("/proc/self/status", "utf8");
|
|
10
|
+
const match = /^VmHWM:\s+(\d+)\s+kB$/m.exec(text), peak = match && Number(match[1]);
|
|
11
|
+
if (peak && Number.isSafeInteger(peak) && peak > 0) return peak;
|
|
12
|
+
} catch { /* Non-proc Linux hosts retain the conservative historical measurement. */ }
|
|
13
|
+
}
|
|
14
|
+
return process.resourceUsage().maxRSS;
|
|
15
|
+
}
|