@staix/agent-mem 0.18.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-mem",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Bounded project memory tools and seven deterministic capture hooks, without a resident worker.",
5
5
  "mcpServers": {
6
6
  "agent-mem": {
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Curated memory with append-only JSONL logs, a derived SQLite index, short- and long-term tiers, a typed graph, goals with leases, and git sync between machines. Extracted from [agent-hub](https://github.com/STAIxBWLB/agent-hub), with compatible storage and entry IDs.
4
4
 
5
- Version: 0.18.0. Requires Bun 1.4.2 or later; no runtime dependencies.
5
+ Version: 0.19.0. Requires Bun 1.4.2 or later; no runtime dependencies.
6
6
 
7
7
  ## Quick start
8
8
 
@@ -32,9 +32,11 @@ From a checkout, install development dependencies with `bun install` and run `bu
32
32
 
33
33
  ## Setup / plugin
34
34
 
35
+ For Orca-managed Claude and Codex homes, setup writes the personal source that Orca copies into new sessions. Claude hooks go to `$HOME/.claude/settings.json`, while its MCP entry stays in the profile's `.claude.json`. Codex MCP and hooks go to `$HOME/.codex/config.toml` and `hooks.json`. Detection requires an exact managed-home shape under `ORCA_USER_DATA_PATH` (default `~/Library/Application Support/orca`); a similar directory elsewhere is unchanged. Preview lists each redirect and its reason. Start a new Orca session after applying. Existing matching managed-copy receipts migrate on apply; undo restores the files and bytes recorded for that installation. Codex hooks still require the person's native `/hooks` review; setup never grants trust or writes `trusted_hash`.
36
+
35
37
  `amem setup agy` registers MCP only in the current git repository's `.agents/mcp_config.json`, with an explicit absolute repository cwd and a private receipt per repository. `setup all` excludes agy; run it separately in each repository. Setup refuses tracked config files and JSONC, and warns when the file is not ignored. Restart Antigravity after applying, use one workspace per session, and ensure no global, plugin or running server is also named `amem`. No global configuration, ingest command or hook is installed. Undo removes `.agents` only when setup created it and it is still empty; pre-existing or nonempty directories remain. Older receipts still restore their configuration without directory cleanup. See the [Antigravity contract and capture limitation](docs/antigravity.md).
36
38
 
37
- `amem setup jcode` registers MCP only, using Jcode v0.99.1's global `JCODE_HOME/mcp.json` (default `~/.jcode/mcp.json`). An explicitly set `JCODE_HOME` must be absolute for amem setup: empty, relative and literal `~` values are refused. Unset uses the default, and absolute values retain their literal spelling without trimming. It owns only `servers.amem`, sets `shared: false` for a workspace-bound session, and preserves unrelated configuration bytes. Jcode has no native MCP add/remove command in this release, so setup edits the owned entry directly. No Jcode ingest command or capture hook is installed: saved messages lack per-message origin while the session cwd can change. Launch Jcode with `JCODE_DISABLE_CLAUDE_MCP=1` and without a same-name project override, which could otherwise replace the private binding. Use a new workspace-bound session when changing projects. See the [Jcode contract and capture limitation](docs/jcode.md).
39
+ `amem setup jcode` registers MCP only, using Jcode v0.99.1's global `JCODE_HOME/mcp.json` (default `~/.jcode/mcp.json`). An explicitly set `JCODE_HOME` must be absolute for amem setup: empty, relative and literal `~` values are refused. Unset uses the default, and absolute values retain their literal spelling without trimming. It owns only `servers.amem`, sets `shared: false` for a workspace-bound session, and preserves unrelated configuration bytes. Jcode has no native MCP add/remove command in this release, so setup edits the owned entry directly. Setup also adds `amem hook jcode` to the `post_tool` and `turn_end` lifecycle hooks in `JCODE_HOME/config.toml`, keeping an existing user command for those events (array form). Each captured row is scoped by that event's `JCODE_HOOK_CWD`; events with no cwd or outside a Git repository are skipped. There is still no `amem ingest jcode`: saved messages lack per-message origin while the session cwd can change. Launch Jcode with `JCODE_DISABLE_CLAUDE_MCP=1` and without a same-name project override, which could otherwise replace the private binding. Use a new workspace-bound session when changing projects. See the [Jcode contract and capture limitation](docs/jcode.md).
38
40
 
39
41
  `amem setup gemini` registers MCP only, at user scope in Gemini CLI 0.61.0's `~/.gemini/settings.json` (`GEMINI_CLI_HOME`, which must be absolute, moves it). It runs the native `gemini mcp add --scope user` (and `remove` for an earlier entry) in a private staging `HOME`, owns only `mcpServers.amem` and keeps every unrelated setting. Antigravity's `~/.gemini/config/mcp_config.json` is a different client's file and is never read or written; `amem setup agy` stays per repository. A workspace `.gemini/settings.json` that defines `mcpServers.amem` overrides the user entry. Setup refuses settings that are not strict JSON (Gemini allows comments, but setup's exact undo needs strict JSON) and runtime paths containing `$`, which Gemini expands. No hook, ingest command or `GEMINI.md` edit: see [the Gemini CLI contract](docs/gemini-cli.md) for why transcripts are not ingested. `setup all` includes Gemini CLI when it is installed.
40
42
 
@@ -55,7 +57,9 @@ amem setup all --undo
55
57
 
56
58
  `--apply` and `--undo` are explicit writes; the default remains a read-only plan. They do not require a TTY prompt, and an agent marker supplies identity rather than a tool refusal. Follow workspace authorization for changes to machine configuration. Native Claude, Codex, Pi and Qwen MCP registration commands run in a private temporary HOME before the listed files are published; current Kimi Code uses its MCP JSON file. No AGENTS.md, CLAUDE.md or rule files are edited. Repeated apply is a no-op even after routine CLI metadata writes. Registrations use the absolute Bun path found on PATH, preserving its stable symlink rather than a versioned Cellar target; temporary package entries (such as bunx caches) are refused; reapply refreshes moved package/runtime locations automatically. Private receipts restore original bytes, permissions and absent-file state when files are untouched. When a CLI updates its own metadata, undo restores only the managed MCP entry and hook rows while preserving those updates; edits to those managed entries refuse rather than overwrite. Config files have an 8 MiB bound and generated TOML is validated before publication.
57
59
 
58
- Claude receives its seven bounded hooks. Codex, Kimi and Qwen receive SessionEnd incremental ingest; Pi receives a session_shutdown extension. [Codex's native `/hooks` review](https://learn.chatgpt.com/docs/hooks) remains a pending person step: setup never grants or bypasses hook trust. Native registration requires a CLI exposing `mcp` administration (Claude, Codex, Pi and Qwen are checked through their help before any registration command); current Kimi Code uses its documented config files. `setup all` skips and reports missing CLIs; an individual missing target refuses. Setup does not download tools or start a resident worker.
60
+ Claude receives its seven bounded hooks; Jcode receives `post_tool` and `turn_end` capture hooks. Codex, Kimi and Qwen receive SessionEnd incremental ingest; Pi receives a session_shutdown extension. [Codex's native `/hooks` review](https://learn.chatgpt.com/docs/hooks) remains a pending person step: setup never grants or bypasses hook trust. Native registration requires a CLI exposing `mcp` administration (Claude, Codex, Pi and Qwen are checked through their help before any registration command); current Kimi Code uses its documented config files. `setup all` skips and reports missing CLIs; an individual missing target refuses. Setup does not download tools or start a resident worker.
61
+
62
+ To keep several releases side by side, install each under its own root (for example `<install>/releases/<version>/`) and register one with `amem setup <target> --root <abs path> --apply`. The root is resolved to its real path, so a `current` link registers the release it names, and replacing another install never changes what the registered hooks and MCP server load. `amem manifest --json` prints the version, the MCP tools (read-only ones as `{ name, readOnly: true }`) and the commands of an install. See [versions on one machine](docs/contracts.md#versions-on-one-machine-199).
59
63
 
60
64
  For a Claude Code plugin installation instead of direct setup registration:
61
65
 
@@ -161,7 +165,7 @@ amem setup console --apply # write $AGENTHUB_HOME/console/plugins/amem.json
161
165
  amem setup console --undo # remove it (only when it points at this install)
162
166
  ```
163
167
 
164
- The manifest holds the absolute path and sha256 of `src/ui/plugin.ts`, in a 0700 directory and a 0600 file, as acon requires. acon refuses a module whose hash changed, so run `amem setup console --apply` again after upgrading amem. `setup all` does not include it.
168
+ The manifest holds the absolute path and sha256 of `src/ui/plugin.ts` (of the `--root` install root when given), in a 0700 directory and a 0600 file, as acon requires. acon refuses a module whose hash changed, so run `amem setup console --apply` again after upgrading amem. `setup all` does not include it.
165
169
 
166
170
  - Reads are screened. Search, Timeline and Work lists call the in-process `amem_search`, `amem_timeline` and `amem_work_state_read` handlers for the chosen project and forward every option the tool declares, so console search returns exactly the `amem_search` set for the same query. Goals and the review queue pass the configured policy; a candidate that fails it, or whose target fails it, is not shown (the panel counts it and points to `amem review walk`). The console never shows unscreened content, also not to the person. It runs in no repository, so a repository's `.amem/config` narrowing does not apply; the machine's `amem.config.json` does.
167
171
  - Recap computes the current SessionStart brief only on its own panel snapshots, using machine policy. Curated and work inputs use bounded private scratch copies; capture is read-only, and no live reader lease is taken. It shows the full context, coverage, partial status, token upper bound and generation time; it is not a stored copy of a prior session's injected brief. The console has no repository binding, so the repository section is absent. No recap or new capture field is stored in the memory home.
@@ -328,6 +332,8 @@ Hook capture rows carry two optional fields: `session`, a 16-hex hash of Claude
328
332
 
329
333
  ## Doctor and the sync cadence
330
334
 
335
+ Doctor checks applied setup receipts against the resolved configuration files. Its additive `setup` section names a target, file and entry when MCP or capture wiring is missing or changed, or when the receipt points at a different setup path. It reports missing Codex hook trust records as review-needed; a present record is not proof that its digest is valid. Configuration contents and stored trust hashes are never included in the report, and these checks write nothing into the memory home.
336
+
331
337
  `amem doctor`, `amem status` and `amem setup` print a readable report: an overall state, one labelled row per check (`OK`, `ATTENTION`, `SKIPPED`, `UNAVAILABLE`) and the next actions. Color appears only on an interactive terminal and is turned off by `NO_COLOR` or `TERM=dumb`. Add `--json` for the machine-readable object (for `status` and `setup` this was the default before #125). Setup also shows each available native client's version against its recorded layout verification version; the advisory probe uses a private staging home, reports unknown or unrecognised versions without blocking apply, and is skipped for Antigravity and undo.
332
338
 
333
339
  **Changed default (#134):** `amem ingest`, `amem migrate`, `amem prune`, `amem capacity apply` (including `--split`) and `amem vault-candidates` now print readable states, labelled facts and next actions. Ingest uses that format only when stdout is a terminal; ingest stays JSON when stdout is not a terminal, because native hooks read it. New Codex, Kimi and Qwen hook registrations explicitly request `--json`. Pass `--json` for automation and saved artifacts; it retains the previous objects and serialization without ANSI, including compact JSON for ingest. Capacity preview and confirmation receipts use the selected format. Exit codes, capture-only ingest and policy screening are unchanged; migrate and prune retain reader-admission and split-marker fences.
@@ -538,3 +544,13 @@ The first explicit v2 `amem sync` primes a private create-once publisher boundar
538
544
  Sync validates all selected history and complete raw Git proof, and checks each realm's aggregate input/archive capacity before atomic publication. It then fsyncs immutable inputs and commits the root map before ordinary/native imports, preventing an unmasked first import. Already-v2 map advances preserve generation/format and monotone ancestry, use a forward-only intent and rebind existing readers without requiring them to close. A reader waits at most 3000 ms for a v2 writer; an unresolved intent, incompatible marker pair or missing committed proof reports unavailable. The same `--resume` command completes an interrupted advance forward. Read and capture never activate, prime a boundary or repair a pending operation.
539
545
 
540
546
  `SyncResult.pruneApplication` reports transport durability, complete-proof count, neutral projection activity, native application, held count and phase separately. Transport durability alone does not establish completed local application. Historical multi-realm translations and older unsupported lineage retain "held, not applied" warnings, so agent-mem#15 AC4 remains open. The [full contract](docs/generation-neutral-prune-witness.md) states the owned-source integrity and resource ceilings; this source behavior does not establish release or live-host verification.
547
+
548
+ ## Claude transcript summaries
549
+
550
+ At Stop and SessionEnd, amem also reads up to 256 KiB of new bytes from Claude Code's `transcript_path`. It captures native session titles (`Title`), away recaps (`Recap`, without the config suffix), and compaction summaries (`Compact`). The existing policy, secret, PII, deny-path and private-span screens and excerpt caps apply. Other transcript lines are skipped before JSON parsing. No network, model call or resident process is added.
551
+
552
+ Private cursors under `hook-transcripts/` store only a session hash, file identity, byte offset, timestamp carry, untimed-content hashes with fallback times, and the last title hash. Repeated titles are skipped; deterministic spool receipts prevent duplicate replay after rotation/truncation. New summary events use `hook-v3-` spool names (format 3), which older readers ignore. Byte, time or spool limits leave remaining lines for a later Stop/SessionEnd invocation. A single line larger than the 256 KiB ceiling is skipped and counted as `transcript_line_bound` rather than buffered. An unreadable or busy transcript leaves ordinary hook capture available.
553
+
554
+ The follow-up bound is a benchmark parameter, not a production retry loop. Backlogs advance on later Stop/SessionEnd calls of the same session. Anything unread after its last hook remains uncaptured, so a cold install on a long session keeps only what its remaining hooks can scan; committed rows are not lost.
555
+
556
+ Search gives these events the existing 1.25 conversation weight and session grouping. The brief selects up to two newest screened main-session Recap/Compact rows first, then fills from the existing chronological rows, under the same record, byte and token caps. With no summaries the output is unchanged. See [verification evidence](docs/bench/transcript-summaries-201.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/agent-mem",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Curated, shared memory for coding agents: append-only logs, a derived index, tiers and a typed graph.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -22,7 +22,8 @@
22
22
  "@staix/agent-mem-v012": "npm:@staix/agent-mem@0.12.0",
23
23
  "@staix/agent-mem-v013": "npm:@staix/agent-mem@0.13.0",
24
24
  "@staix/agent-mem-v015": "npm:@staix/agent-mem@0.15.0",
25
- "@staix/agent-mem-v017": "npm:@staix/agent-mem@0.17.0"
25
+ "@staix/agent-mem-v017": "npm:@staix/agent-mem@0.17.0",
26
+ "@staix/agent-mem-v018": "npm:@staix/agent-mem@0.18.0"
26
27
  },
27
28
  "exports": {
28
29
  ".": "./src/index.ts",
package/src/cli.ts CHANGED
@@ -10,7 +10,7 @@ import { createRecallStalenessProbe } from "./memory/recall-staleness.ts";
10
10
  import { recordMemoryUse } from "./memory/use-telemetry.ts";
11
11
  import { DecayApplyError } from "./memory/decay-review.ts";
12
12
  import { createMemoryMcp } from "./mcp-memory.ts";
13
- import { serveMcp, writeConfirmed } from "./mcp.ts";
13
+ import { MCP_TOOLS, serveMcp, writeConfirmed } from "./mcp.ts";
14
14
  import { createStandalonePolicy } from "./memory/policy.ts";
15
15
  import { configuredPolicy, configuredPolicyOptions, loadAmemConfig, repoConfigRoot } from "./memory/config.ts";
16
16
  import { vaultCandidates, vaultCandidatesText, vaultCandidatesAllText } from "./memory/vault.ts";
@@ -28,7 +28,7 @@ import { aliasedKeys, appendAlias, appendUnalias, listAliases, readShardedSelect
28
28
  import { capacityReport, capacityText, capacityApplyText, capacitySplitText } from "./memory/capacity.ts";
29
29
  import { readCaptureConfigFile, writeCaptureConfigFile, type CaptureConfig } from "./memory/capture.ts";
30
30
 
31
- import { commandHelp, parseCommand } from "./commands-table.ts";
31
+ import { COMMANDS, commandHelp, parseCommand } from "./commands-table.ts";
32
32
  import { completionScript } from "./completion.ts";
33
33
  import { memoryHome, resolveMemoryHome } from "./memory/home.ts";
34
34
  import { realPath } from "./memory/platform.ts";
@@ -180,6 +180,16 @@ class CliPartialApplyError extends Error {
180
180
  }
181
181
  }
182
182
 
183
+ /**
184
+ * `amem manifest`: what this install serves, read by installers (dotfiles#320) without a compiled list. A tool is a plain name, or
185
+ * `{ name, readOnly: true }` when its MCP annotation says it changes nothing. No memory data, so it needs no home.
186
+ */
187
+ export function manifest() {
188
+ return { name: "@staix/agent-mem", version: VERSION,
189
+ mcp: { server: "amem", tools: MCP_TOOLS.map(tool => tool.annotations.readOnlyHint ? { name: tool.name, readOnly: true as const } : tool.name) },
190
+ commands: Object.keys(COMMANDS) };
191
+ }
192
+
183
193
  export async function runCli(argv: readonly string[], context: CliContext = {}): Promise<string> {
184
194
  // Native capture and the MCP server retain their existing surfaces and attribution.
185
195
  if (argv[0] === "hook" || argv[0] === "ingest" || argv.length === 1 && argv[0] === "mcp") return runCliCommand(argv, context);
@@ -228,8 +238,17 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
228
238
  throw error;
229
239
  });
230
240
  }
241
+ /**
242
+ * Every lazily imported module outside the CLI-only commands (relative to this file), loaded at `amem mcp` start (#199). A later `import()`
243
+ * then hits the module cache, so an install replaced in place under a running server never mixes two versions in one process.
244
+ * `test/coexistence.test.ts` fails when a new lazy import is neither listed here nor named there as CLI-only.
245
+ */
246
+ export const MCP_PRELOAD = ["./memory/store.ts", "./memory/capture.ts", "./memory/sync.ts", "./memory/shard-sync.ts", "./memory/public-prune-advance.ts"] as const;
247
+ // The boundary check admits only literal specifiers, so the list above is spelled out again here; the test compares the two.
248
+ const preloadMcp = () => Promise.all([import("./memory/store.ts"), import("./memory/capture.ts"), import("./memory/sync.ts"), import("./memory/shard-sync.ts"), import("./memory/public-prune-advance.ts")]);
231
249
  async function runCliCommand(argv: readonly string[], context: CliContext): Promise<string> {
232
250
  if (argv.length === 1 && argv[0] === "mcp") {
251
+ await preloadMcp();
233
252
  const memory = createMemoryMcp({ cwd: context.cwd ?? process.cwd(), base: memoryHome(context.env ?? process.env) });
234
253
  await serveMcp({ tools: memory.tools, close: memory.close, onEmitted: memory.onEmitted, version: VERSION });
235
254
  return "";
@@ -260,6 +279,7 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
260
279
  try {
261
280
  parsed = parseCommand(argv);
262
281
  if (parsed.path[0] === "version") return `amem ${VERSION}`;
282
+ if (parsed.path[0] === "manifest") return JSON.stringify(manifest(), null, parsed.options["--json"] === true ? undefined : 2);
263
283
  if (parsed.path[0] === "completion") {
264
284
  if (parsed.positionals.length !== 1) throw new Error("usage: amem completion zsh|bash|fish");
265
285
  return completionScript(parsed.positionals[0]!);
@@ -271,15 +291,18 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
271
291
  const { definition, options, rawArgs: args } = parsed;
272
292
  const identity = cliAttribution() ?? { actor: "person" as const, peer: "person" }, actor = identity.actor;
273
293
  if (parsed.path[0] === "setup") {
274
- if (parsed.positionals.length !== 1 || options["--apply"] === true && options["--undo"] === true) throw new Error("usage: amem setup claude|codex|kimi|pi|qwen|jcode|opencode|gemini|agy|all|console [--apply|--undo]");
294
+ if (parsed.positionals.length !== 1 || options["--apply"] === true && options["--undo"] === true) throw new Error("usage: amem setup claude|codex|kimi|pi|qwen|jcode|opencode|gemini|agy|all|console [--root <abs path>] [--apply|--undo]");
275
295
  if (parsed.positionals[0] === "console") {
276
296
  const { consoleSetup, consoleSetupText } = await import("./setup-console.ts");
277
- const mode = options["--apply"] === true ? "apply" : options["--undo"] === true ? "undo" : "preview", result = consoleSetup(mode, context.env ?? process.env);
297
+ const mode = options["--apply"] === true ? "apply" : options["--undo"] === true ? "undo" : "preview";
298
+ const root = typeof options["--root"] === "string" ? (await import("./setup.ts")).installRoot(options["--root"]).path : undefined;
299
+ const result = root === undefined ? consoleSetup(mode, context.env ?? process.env) : consoleSetup(mode, context.env ?? process.env, join(root, "src", "ui", "plugin.ts"));
278
300
  return options["--json"] === true ? JSON.stringify(result, null, 2) : consoleSetupText(result, mode);
279
301
  }
280
302
  const { runSetup } = await import("./setup.ts");
281
303
  return runSetup(parsed.positionals[0]!, options["--apply"] === true ? "apply" : options["--undo"] === true ? "undo" : "preview", {
282
304
  env: context.env ?? process.env, stdinTTY: context.stdinTTY ?? process.stdin.isTTY, cwd: context.cwd ?? process.cwd(),
305
+ ...(typeof options["--root"] === "string" ? { root: options["--root"] } : {}),
283
306
  ...(context.setupConfirm ? { confirm: context.setupConfirm } : {}), json: options["--json"] === true, style: outputStyle(context),
284
307
  });
285
308
  }
@@ -25,8 +25,10 @@ export const GLOBAL_OPTIONS = { "--help": flag("Show help") };
25
25
  export const ROOT_OPTIONS = { "--version": flag("Show version"), ...json };
26
26
  export const COMMANDS: Record<string, CommandDefinition> = {
27
27
  consolidate: { description: "Queue deterministic project-local consolidation candidates; curated notes change only after explicit acceptance", mutates: true, options: { ...json, "--project": text("The current project's key; another project is refused (run from its directory)") } },
28
- setup: { description: "Preview reversible MCP setup and supported capture hooks for native coding CLIs, or the acon console plugin manifest (console); apply and undo use explicit flags", options: { ...json, "--apply": flag("Apply the previewed setup"), "--undo": flag("Restore the original configuration") }, args: [{ name: "target", values: ["claude", "codex", "kimi", "pi", "qwen", "jcode", "opencode", "gemini", "agy", "all", "console"] }] },
28
+ setup: { description: "Preview reversible MCP setup and supported capture hooks for native coding CLIs, or the acon console plugin manifest (console); apply and undo use explicit flags", options: { ...json, "--apply": flag("Apply the previewed setup"), "--undo": flag("Restore the original configuration"),
29
+ "--root": { description: "Register this absolute install root (such as <install>/releases/<version>/, resolved to its real path) instead of the running one", value: "path" } }, args: [{ name: "target", values: ["claude", "codex", "kimi", "pi", "qwen", "jcode", "opencode", "gemini", "agy", "all", "console"] }] },
29
30
  mcp: { description: "Run the project-bound MCP stdio server for this session" },
31
+ manifest: { description: "Describe this install: package name, version, MCP server and tools (read-only ones marked), commands", options: json },
30
32
  hook: { description: "Run a fail-open, context-only Claude Code hook", args: [{ name: "event", values: [...HOOK_EVENTS] }] },
31
33
  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", "qwen"] }] },
32
34
  "vault-candidates": { description: "List screened durable curated vault candidates (per project, or across projects)", options: { ...json, "--limit": text("Maximum candidates per project (1..100)"),
@@ -1,3 +1,12 @@
1
1
  export const HOOK_EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PostToolUseFailure", "Stop", "SessionEnd"] as const;
2
2
  export type HookEvent = typeof HOOK_EVENTS[number];
3
3
  export const HOOK_INPUT_BYTES = 64 * 1024, HOOK_CONTEXT_BYTES = 1200, HOOK_OUTPUT_BYTES = 8192;
4
+ export interface HookTranscriptProgress { offset?: number; stopReason?: "cap" | "deadline" | "spool_full" }
5
+ export function isHookTranscriptProgress(value: unknown): value is HookTranscriptProgress {
6
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
7
+ const row = value as Record<string, unknown>;
8
+ return Object.keys(row).every(key => key === "offset" || key === "stopReason")
9
+ && (row.offset !== undefined || row.stopReason !== undefined)
10
+ && (row.offset === undefined || Number.isSafeInteger(row.offset) && (row.offset as number) >= 0)
11
+ && (row.stopReason === undefined || ["cap", "deadline", "spool_full"].includes(row.stopReason as string));
12
+ }
@@ -0,0 +1,56 @@
1
+ import { dirname, isAbsolute, join } from "node:path";
2
+ import { lstatSync } from "node:fs";
3
+
4
+ // Jcode v0.99.1 lifecycle observers (crates/jcode-base/src/hooks.rs apply_event_env; docs/HOOKS.md). Observers get no
5
+ // stdin and their stdout is discarded; every field arrives in the environment. Only post_tool and turn_end are captured:
6
+ // post_tool carries TOOL_NAME, TOOL_INPUT (16 KiB, may be cut mid-JSON), TOUCHED_PATHS, STATUS and ERROR (1000 chars);
7
+ // turn_end carries STATUS and LAST_ASSISTANT_TEXT (4000 chars). JCODE_HOOK_CWD is the session working directory at event
8
+ // time, the per-event origin that saved sessions lack (docs/jcode.md).
9
+ type Env = Readonly<Record<string, string | undefined>>;
10
+ export const JCODE_HOOK_EVENTS = ["post_tool", "turn_end"] as const;
11
+ const FIELD_BYTES = 16 * 1024 + 64, TOUCHED_PATHS = 32;
12
+
13
+ export interface JcodeHookCapture { event: "PostToolUse" | "PostToolUseFailure" | "Stop"; cwd: string; input: Record<string, unknown> }
14
+
15
+ const bounded = (value: string | undefined): value is string => typeof value === "string" && Buffer.byteLength(value) <= FIELD_BYTES;
16
+ /** A jcode session may sit in $HOME or /tmp. Without an enclosing `.git` (directory or worktree file) there is no project to
17
+ * scope by, so the event is skipped rather than keyed by a directory name. No Git process: the hook resolver decides the key. */
18
+ function insideRepository(cwd: string): boolean {
19
+ for (let dir = cwd, depth = 0; depth < 64; depth++) {
20
+ if (lstatSync(join(dir, ".git"), { throwIfNoEntry: false })) return true;
21
+ const parent = dirname(dir); if (parent === dir) return false; dir = parent;
22
+ }
23
+ return false;
24
+ }
25
+
26
+ /** Maps one jcode observer environment onto the Claude-shaped hook input the spool already screens and normalizes.
27
+ * Anything missing, oversized or inconsistent returns undefined: the event is skipped, never guessed. */
28
+ export function jcodeHookCapture(env: Env): JcodeHookCapture | undefined {
29
+ const event = env.JCODE_HOOK_EVENT, cwd = env.JCODE_HOOK_CWD, session = env.JCODE_HOOK_SESSION_ID, status = env.JCODE_HOOK_STATUS;
30
+ if (event !== "post_tool" && event !== "turn_end") return undefined;
31
+ if (!bounded(cwd) || !cwd || !isAbsolute(cwd) || /[\x00-\x1f]/.test(cwd)) return undefined;
32
+ try { if (!lstatSync(cwd).isDirectory()) return undefined; } catch { return undefined; }
33
+ if (!insideRepository(cwd)) return undefined;
34
+ if (session !== undefined && (!bounded(session) || session.length > 256)) return undefined;
35
+ if (status !== "ok" && status !== "error") return undefined;
36
+ const base: Record<string, unknown> = { cwd, ...(session ? { session_id: session } : {}) };
37
+ if (event === "turn_end") {
38
+ const text = env.JCODE_HOOK_LAST_ASSISTANT_TEXT;
39
+ if (text !== undefined && !bounded(text)) return undefined;
40
+ // A turn without assistant text (tool-only, or an error before any text) has nothing to recall.
41
+ if (!text?.trim()) return undefined;
42
+ return { event: "Stop", cwd, input: { ...base, hook_event_name: "Stop", last_assistant_message: text } };
43
+ }
44
+ const tool = env.JCODE_HOOK_TOOL_NAME, raw = env.JCODE_HOOK_TOOL_INPUT, touched = env.JCODE_HOOK_TOUCHED_PATHS, error = env.JCODE_HOOK_ERROR;
45
+ if (!bounded(tool) || !tool || tool.length > 128 || /[\x00-\x1f]/.test(tool)) return undefined;
46
+ if ([raw, touched, error].some(value => value !== undefined && !bounded(value))) return undefined;
47
+ // TOOL_INPUT is cut at 16 KiB, so a long input is not valid JSON; keep the call with its touched paths only.
48
+ let parsed: Record<string, unknown> = {};
49
+ if (raw) try { const value: unknown = JSON.parse(raw); if (value && typeof value === "object" && !Array.isArray(value)) parsed = value as Record<string, unknown>; } catch { /* truncated */ }
50
+ // The spool reads file_path, path and paths; touched paths already name those inputs, so keep only the ones not present.
51
+ const named = new Set([parsed.file_path, parsed.path, ...(Array.isArray(parsed.paths) ? parsed.paths : [])].filter((p): p is string => typeof p === "string"));
52
+ const paths = [...new Set((touched ?? "").split("\n").filter(path => path && isAbsolute(path) && !named.has(path)))].slice(0, TOUCHED_PATHS);
53
+ const toolInput = { ...parsed, ...(paths.length ? { paths: [...(Array.isArray(parsed.paths) ? parsed.paths : []), ...paths] } : {}) };
54
+ const failed = status === "error", name = failed ? "PostToolUseFailure" : "PostToolUse";
55
+ return { event: name, cwd, input: { ...base, hook_event_name: name, tool_name: tool, tool_input: toolInput, ...(failed ? { error: error ?? "" } : { tool_response: "" }) } };
56
+ }
@@ -1,7 +1,10 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
- import { readFileSync } from "node:fs";
2
+ import { combineRssSources, processImageMemory, rssSource, selectHookRss, type HookRssReading, type HookRssSources } from "./hook-resources.ts";
3
3
 
4
- export interface HookNativeUsage { rssKB: number; rawRssKB: number }
4
+ export interface HookNativeUsage {
5
+ rssKB: number; rawRssKB: number; rssSources?: HookRssSources;
6
+ observe?: () => { started(pid: number): void; finished(reading: HookRssReading): void };
7
+ }
5
8
  // One native supervisor, with a native sleep watchdog rather than another Bun boot.
6
9
  // The leader waits for its watchdog even after stdin EOF, so losing the calling CLI
7
10
  // cannot let the owned command outlive the same remaining per-call deadline.
@@ -10,12 +13,13 @@ const SUPERVISOR = [
10
13
  // Fixed ps plus the unguessable invocation marker guards an externally killed/reused
11
14
  // leader too. Normal completion never exits the leader before its group is reaped.
12
15
  '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; }',
16
+ "rss() { [ -r \"/proc/$leader/status\" ] || return 0; trap '' PIPE; hwm=0; virtual=0; while read -r key value unit rest; do case \"$key\" in VmPeak:) virtual=$value ;; VmHWM:) hwm=$value; break ;; esac; done < \"/proc/$leader/status\"; printf \"%s:RSS:%s:%s\\n\" \"$marker\" \"$hwm\" \"$virtual\" >&2; trap - PIPE; }",
17
+ 'reap() { if owned; then rss; kill -KILL "-$leader"; fi; }',
14
18
  '[ -x /bin/sleep ] && [ -x /bin/ps ] && owned || exit 125',
15
19
  'trap reap EXIT HUP INT TERM',
16
20
  '( /bin/sleep "$seconds" & sleeper=$!; kill -0 "$sleeper" 2>/dev/null || { reap; exit 125; }; printf "\\n%s:ARMED\\n" "$marker"; wait "$sleeper"; reap ) & watchdog=$!',
17
21
  'kill -0 "$watchdog" 2>/dev/null || { reap; exit 125; }',
18
- '"$@" </dev/null; code=$?; printf "\\n%s:%s\\n" "$marker" "$code"',
22
+ '"$@" </dev/null 2>/dev/null; code=$?; rss; printf "\\n%s:%s\\n" "$marker" "$code"',
19
23
  'read -r ignored || :',
20
24
  'wait "$watchdog"',
21
25
  ].join("; ");
@@ -25,16 +29,66 @@ export async function hookNative(argv: readonly string[], env: NodeJS.ProcessEnv
25
29
  const remaining = timeRemaining();
26
30
  if (!Number.isFinite(remaining) || remaining <= 1) throw new Error("native metadata deadline");
27
31
  const marker = `AMEM_META_${randomBytes(16).toString("hex")}`, prefix = `\n${marker}:`, armedFrame = `${prefix}ARMED\n`;
32
+ let observer: ReturnType<NonNullable<HookNativeUsage["observe"]>> | undefined, observationFailed = false;
33
+ try { observer = usage.observe?.(); } catch { observationFailed = true; }
28
34
  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" });
35
+ { env, detached: true, stdin: "pipe", stdout: "pipe", stderr: "pipe" });
36
+ // A real owned image cannot disappear from provenance if collection/cleanup throws.
37
+ usage.rssSources = combineRssSources(usage.rssSources);
38
+ usage.rssSources.unavailable++;
39
+ try { observer?.started(child.pid); } catch { observationFailed = true; }
30
40
  let exited = false, ownPeak = 0;
31
- const exit = child.exited.then(code => { exited = true; return code; });
41
+ let reportedPeak = 0, reportedVirtual = 0, ownVirtual = 0, receiptValid = true;
42
+ const rssReader = child.stderr.getReader();
43
+ // A separate numeric channel survives a late parent read after the watchdog exits.
44
+ // Command stderr remains discarded, as before; only the fixed supervisor writes here.
45
+ const rssReceipt = (async () => {
46
+ let pending = "", bytes = 0;
47
+ try {
48
+ for (;;) {
49
+ const next = await rssReader.read(); if (next.done) break;
50
+ bytes += next.value.byteLength;
51
+ if (bytes > 4096) { receiptValid = false; break; }
52
+ pending += new TextDecoder("utf-8", { fatal: true }).decode(next.value);
53
+ for (;;) {
54
+ const newline = pending.indexOf("\n"); if (newline < 0) break;
55
+ const line = pending.slice(0, newline); pending = pending.slice(newline + 1);
56
+ const prefix = `${marker}:RSS:`, value = line.startsWith(prefix) ? line.slice(prefix.length) : "";
57
+ const fields = /^(\d+):(\d+)$/.exec(value);
58
+ const peak = fields ? Number(fields[1]) : -1, virtual = fields ? Number(fields[2]) : -1;
59
+ if (Number.isSafeInteger(peak) && peak >= 0 && Number.isSafeInteger(virtual) && virtual >= 0) {
60
+ reportedPeak = Math.max(reportedPeak, peak); reportedVirtual = Math.max(reportedVirtual, virtual);
61
+ } else receiptValid = false;
62
+ }
63
+ }
64
+ if (pending) receiptValid = false;
65
+ } catch { receiptValid = false; }
66
+ finally { await rssReader.cancel().catch(() => {}); rssReader.releaseLock(); }
67
+ })();
68
+ let usageRecorded = false;
69
+ const recordUsage = () => {
70
+ if (usageRecorded) return;
71
+ usageRecorded = true;
72
+ const raw = (child.resourceUsage()?.maxRSS ?? 0) / 1024;
73
+ const selected = selectHookRss(process.platform, Math.max(ownPeak, receiptValid ? reportedPeak : 0), raw, Math.max(ownVirtual, receiptValid ? reportedVirtual : 0));
74
+ usage.rawRssKB = Math.max(usage.rawRssKB, raw);
75
+ usage.rssKB = Math.max(usage.rssKB, selected.kb);
76
+ usage.rssSources!.unavailable--;
77
+ usage.rssSources = combineRssSources(usage.rssSources, rssSource(selected));
78
+ try { observer?.finished(selected); } catch { observationFailed = true; }
79
+ if (observationFailed) usage.rssSources.unavailable++;
80
+ };
81
+ const exit = child.exited.then(code => {
82
+ exited = true;
83
+ // Reaped platform rusage is final before pipe cleanup; publish it while the worker is alive.
84
+ // Linux still needs the authenticated terminal receipt consumed below.
85
+ if (process.platform !== "linux") recordUsage();
86
+ return code;
87
+ });
32
88
  const stop = () => {
33
89
  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. */ }
90
+ const image = processImageMemory(child.pid);
91
+ ownPeak = Math.max(ownPeak, image.hwmKB ?? 0); ownVirtual = Math.max(ownVirtual, image.virtualKB ?? 0);
38
92
  try { process.kill(-child.pid, "SIGKILL"); } catch { try { child.kill("SIGKILL"); } catch { /* already exited */ } }
39
93
  };
40
94
  const reader = child.stdout.getReader();
@@ -75,11 +129,10 @@ export async function hookNative(argv: readonly string[], env: NodeJS.ProcessEnv
75
129
  // Init can briefly retain zombie grandchildren; they cannot execute, and only
76
130
  // their adopting reaper can remove those PIDs (we cannot waitpid a non-child).
77
131
  await exit;
132
+ await rssReceipt;
133
+ recordUsage();
78
134
  try { child.stdin.end(); } catch { /* pipe closed with the owned leader */ }
79
135
  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
136
  }
84
137
  }
85
138
 
@@ -1,3 +1,4 @@
1
+ import { combineRssSources } from "./hook-resources.ts";
1
2
  import { createHash, randomBytes } from "node:crypto";
2
3
  import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, opendirSync, readSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
4
  import { dirname, join } from "node:path";
@@ -12,6 +13,8 @@ import { hookNative, type HookNativeUsage } from "./hook-native.ts";
12
13
  const SCHEMA = "agent-mem.hook-project/v1", CACHE_BYTES = 4096, CACHE_CWDS = 128;
13
14
  const hash = (text: string) => createHash("sha256").update(text).digest("hex");
14
15
  interface Binding { schema: typeof SCHEMA; cwd: string; scope: string; validity: string; binary: string; project: string }
16
+ /** One-invocation parent/child transport, not an authentication token or a reusable cache. */
17
+ export interface HookProjectReceipt { cwd: string; base: string; scope: string; validity?: string; binary: string; project: string }
15
18
  function read(file: string): Binding | undefined {
16
19
  let fd: number | undefined;
17
20
  try {
@@ -103,7 +106,21 @@ function prepareHookProject(options: ResolverOptions) {
103
106
  }
104
107
  checkDeadline();
105
108
  };
106
- return { binary, verifyBinary, resolve(gitRead: ProjectGitRead, assertComplete?: () => void): string {
109
+ return { binary, verifyBinary,
110
+ receipt(project: string): HookProjectReceipt | undefined {
111
+ verify();
112
+ return scope === undefined ? undefined : { cwd, base, scope, validity, binary: binary.revision, project };
113
+ },
114
+ accept(value: unknown): string {
115
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("project receipt unavailable");
116
+ const receipt = value as HookProjectReceipt;
117
+ if (Object.keys(receipt).some(key => !["cwd", "base", "scope", "validity", "binary", "project"].includes(key))
118
+ || receipt.cwd !== cwd || receipt.base !== base || receipt.scope !== scope || scope === undefined
119
+ || receipt.validity !== validity || receipt.binary !== binary.revision || typeof receipt.project !== "string"
120
+ || !receipt.project || Buffer.byteLength(receipt.project) > 64 * 1024) throw new Error("project receipt changed");
121
+ verify(); return receipt.project;
122
+ },
123
+ resolve(gitRead: ProjectGitRead, assertComplete?: () => void): string {
107
124
  checkDeadline();
108
125
  if (hit && cached) {
109
126
  assertComplete?.(); verify();
@@ -128,6 +145,11 @@ export function resolveHookProject(options: ResolverOptions): string {
128
145
  return prepared.resolve(options.gitRead ?? hookGit(options.timeRemaining, prepared.binary.file), options.assertComplete);
129
146
  }
130
147
 
148
+ /** Revalidate a fresh result delivered by our parent, without launching a native child. */
149
+ export function acceptHookProjectReceipt(value: unknown, options: ResolverOptions): string {
150
+ return prepareHookProject(options).accept(value);
151
+ }
152
+
131
153
  /** A local key's superproject chain depends on outer Git indexes. Cache only ordinary
132
154
  * standalone roots; linked/submodule/local nested layouts retain the native resolver. */
133
155
  function cacheableLocal(cwd: string, project: string): boolean {
@@ -146,7 +168,7 @@ function cacheableLocal(cwd: string, project: string): boolean {
146
168
 
147
169
  /** Replay the shared synchronous resolver only after every requested native read is known.
148
170
  * 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> {
171
+ export async function resolveHookProjectAsync(options: { cwd: string; base: string; scope?: string; timeRemaining: () => number; maintainCache?: boolean; prefetch?: boolean; onResolved?: (receipt: HookProjectReceipt | undefined) => void }, usage: HookNativeUsage): Promise<string> {
150
172
  const reads = new Map<string, ReturnType<ProjectGitRead>>();
151
173
  const env = { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "", AMEM_HOME: options.base };
152
174
  const prepared = prepareHookProject({ ...options, env });
@@ -159,7 +181,7 @@ export async function resolveHookProjectAsync(options: { cwd: string; base: stri
159
181
  if (pending.size >= 64) throw new Error("native identity query bound");
160
182
  prepared.verifyBinary(); // No awaited native read may execute a replaced binary.
161
183
  const argv = memoryGitArgv(args); argv[0] = prepared.binary.file;
162
- const abort = new AbortController(), own = { rssKB: 0, rawRssKB: 0 };
184
+ const abort = new AbortController(), own: HookNativeUsage = { rssKB: 0, rawRssKB: 0, observe: usage.observe };
163
185
  active.add(key); overlaps.push([...active]);
164
186
  const result = hookNative(argv, memoryGitEnv(env, { repositoryIsolation: true }), options.timeRemaining, own, abort.signal)
165
187
  .finally(() => { active.delete(key); });
@@ -178,7 +200,7 @@ export async function resolveHookProjectAsync(options: { cwd: string; base: stri
178
200
  needed ??= { key, args }; throw suspended;
179
201
  }, () => { if (needed) throw suspended; });
180
202
  } catch (error) { failure = error; }
181
- if (!needed) { if (failure) throw failure; if (result !== undefined) return result; throw suspended; }
203
+ if (!needed) { if (failure) throw failure; if (result !== undefined) { options.onResolved?.(prepared.receipt(result)); return result; } throw suspended; }
182
204
  const request = needed as { key: string; args: string[] };
183
205
  const demanded = query(request.args);
184
206
  // A native read is requested only on a real cache miss. Prefetch the portable
@@ -202,5 +224,6 @@ export async function resolveHookProjectAsync(options: { cwd: string; base: stri
202
224
  usage.rssKB = Math.max(usage.rssKB, group.reduce((sum, key) => sum + pending.get(key)!.usage.rssKB, 0));
203
225
  usage.rawRssKB = Math.max(usage.rawRssKB, group.reduce((sum, key) => sum + pending.get(key)!.usage.rawRssKB, 0));
204
226
  }
227
+ usage.rssSources = combineRssSources(...[...pending.values()].map(child => child.usage.rssSources));
205
228
  }
206
229
  }
@@ -1,15 +1,57 @@
1
1
  import { readFileSync } from "node:fs";
2
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;
3
+ export const HOOK_RSS_COVERAGE = "images: bun+supervisor; descendants: not measured" as const;
4
+
5
+ export interface HookRssReading { kb: number; rawKB: number; source: "VmHWM" | "vmpeak-bound" | "platform-rusage" | "raw-fallback" | "unavailable"; known: boolean }
6
+ export interface HookRssSources { vmhwm: number; vmpeakBound: number; platformRusage: number; rawFallback: number; unavailable: number }
7
+ const positive = (value: number | undefined): value is number => value !== undefined && Number.isSafeInteger(value) && value > 0;
8
+
9
+ /** A raw fallback stays conservative, but is explicitly insufficient to certify a Linux image peak. */
10
+ export function selectHookRss(platform: string, imageKB: number | undefined, rawKB: number, virtualKB?: number): HookRssReading {
11
+ const raw = positive(rawKB) ? rawKB : 0;
12
+ if (platform === "linux" && positive(imageKB)) return { kb: imageKB, rawKB: raw, source: "VmHWM", known: true };
13
+ if (platform === "linux" && positive(virtualKB)) return { kb: virtualKB, rawKB: raw, source: "vmpeak-bound", known: true };
14
+ if (platform !== "linux" && raw) return { kb: Math.max(raw, positive(imageKB) ? imageKB : 0), rawKB: raw, source: "platform-rusage", known: true };
15
+ return { kb: raw, rawKB: raw, source: raw ? "raw-fallback" : "unavailable", known: false };
16
+ }
17
+
18
+ export function processImageMemory(pid: number | "self"): { hwmKB?: number; virtualKB?: number } {
19
+ if (process.platform !== "linux" || pid !== "self" && (!Number.isSafeInteger(pid) || pid <= 0)) return {};
20
+ try {
21
+ const text = readFileSync(`/proc/${pid}/status`, "utf8");
22
+ const field = (match: RegExpExecArray | null) => { const value = Number(match?.[1]); return positive(value) ? value : undefined; };
23
+ return { hwmKB: field(/^VmHWM:\s+(\d+)\s+kB$/m.exec(text)), virtualKB: field(/^VmPeak:\s+(\d+)\s+kB$/m.exec(text)) };
24
+ } catch { /* Missing or exited processes are unknown, never a fabricated zero peak. */ }
25
+ return {};
26
+ }
27
+ export const processImageRssKB = (pid: number | "self"): number | undefined => processImageMemory(pid).hwmKB;
28
+
29
+ export function hookRssReading(): HookRssReading {
30
+ const image = processImageMemory("self");
31
+ return selectHookRss(process.platform, image.hwmKB, process.resourceUsage().maxRSS, image.virtualKB);
32
+ }
33
+ export const hookPeakRssKB = (): number => hookRssReading().kb;
34
+ export const emptyRssSources = (): HookRssSources => ({ vmhwm: 0, vmpeakBound: 0, platformRusage: 0, rawFallback: 0, unavailable: 0 });
35
+ export function rssSource(reading: HookRssReading): HookRssSources {
36
+ const value = emptyRssSources();
37
+ value[reading.source === "VmHWM" ? "vmhwm" : reading.source === "vmpeak-bound" ? "vmpeakBound" : reading.source === "platform-rusage" ? "platformRusage" : reading.source === "raw-fallback" ? "rawFallback" : "unavailable"] = 1;
38
+ return value;
39
+ }
40
+ export function combineRssSources(...values: (HookRssSources | undefined)[]): HookRssSources {
41
+ const result = emptyRssSources();
42
+ for (const value of values) if (value) for (const key of Object.keys(result) as (keyof HookRssSources)[]) result[key] += value[key];
43
+ return result;
44
+ }
45
+ export function countedRssSources(kb: number, value?: HookRssSources): HookRssSources {
46
+ const result = combineRssSources(value);
47
+ if (kb > 0 && Object.values(result).every(count => count === 0)) result.unavailable++;
48
+ return result;
49
+ }
50
+ export function validRssSources(value: unknown): value is HookRssSources {
51
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
52
+ const row = value as Record<string, unknown>, keys = Object.keys(emptyRssSources());
53
+ return Object.keys(row).length === keys.length && keys.every(key => Number.isSafeInteger(row[key]) && (row[key] as number) >= 0 && (row[key] as number) <= 1024);
54
+ }
55
+ export function rssSourcesKnown(value: HookRssSources): boolean {
56
+ return value.rawFallback === 0 && value.unavailable === 0 && value.vmhwm + value.vmpeakBound + value.platformRusage > 0;
15
57
  }