@staix/agent-mem 0.18.0 → 0.20.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.20.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.20.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.
@@ -288,7 +292,7 @@ amem capacity apply --split [--confirm] # each shard's share of the global cap
288
292
  - `capacity` also prints a per-shard split of the global disk-share budget. Each shard's weight halves every 14 days since its last capture, so recently used projects keep more capture and cold ones shrink first; the split never exceeds the budget, gives every shard at least 1 MB, keeps every shard at what it holds whenever the total fits, never proposes a retention shorter than a shard's idle time plus 30 days, and says when the budget forces a shard below what it holds. When the budget holds fewer whole MB than there are shards, no split is proposed and `capacity` says why (`splitRefused`). It proposes capture bounds only, and never evicts curated notes, goals or work lists. `capacity apply --split` previews, then `--confirm` writes exactly the previewed values to each shard's `config.json`, and only if the preview is under an hour old and the set of shards and every shard's config are unchanged since then (a `--disk-share` given only to `--confirm` changes nothing that is written).
289
293
  - amem lists vault candidates and never writes the vault.
290
294
 
291
- Resource budgets are acceptance criteria, measured by `bun scripts/measure-budget.ts` in CI on Ubuntu and macOS: a hook-like append (including the spool-and-exit path under contention) must stay under 150 ms p95 wall time and 100 MB peak RSS (R2), the search path under 100 MB idle and 200 MB peak RSS (R3), and no amem process may remain after exit (R1). The script prints a machine-readable verdict per budget and its CI log is also the FTS5 availability evidence for Linux (R5). The AC4 retrieval re-check of the frozen P0 query set is in [docs/p1-ac4-recheck.md](https://github.com/STAIxBWLB/agent-mem/blob/main/docs/p1-ac4-recheck.md).
295
+ Resource budgets are acceptance criteria, measured by `bun scripts/measure-budget.ts` in CI on Ubuntu and macOS: a hook-like append (including the spool-and-exit path under contention) must stay under 150 ms p95 wall time and 100 MB RSS (R2), the search path under 100 MB idle and 200 MB peak RSS (R3), and no amem process may remain after exit (R1). For the actual-hook scripts (`measure-hooks-budget.ts`, `measure-transcript-budget.ts`, `measure-full-spool-budget.ts`) R2 is three gates: a 150 MB ceiling on every invocation, a regression gate of +10% over a committed per-runner p95 baseline (`docs/bench/r2-baseline.json`), and a 300 ms per-invocation maximum on the handler's own wall, with the outer CLI wall reported only (the transcript harness reports it until #226); see [docs/contracts.md](https://github.com/STAIxBWLB/agent-mem/blob/main/docs/contracts.md) (#228). The script prints a machine-readable verdict per budget and its CI log is also the FTS5 availability evidence for Linux (R5). The AC4 retrieval re-check of the frozen P0 query set is in [docs/p1-ac4-recheck.md](https://github.com/STAIxBWLB/agent-mem/blob/main/docs/p1-ac4-recheck.md).
292
296
 
293
297
  ## Claude Code hooks and SessionStart context
294
298
 
@@ -310,13 +314,13 @@ Before optional work starts, the parent screens the complete hook input and boun
310
314
 
311
315
  Pending capture has an ordinary soft tier of 256 files/2 MiB and a separate private reserve of 32 files/256 KiB, admitted before optional recovery. Replay is globally oldest-first across both tiers, at most 32 files/256 KiB per drain. A record replays only when its original non-null scope positively matches. Null scope is never adopted later, even with a physical cwd receipt: protecting project privacy takes priority over recovering an unattested event. Alias/profile/shard changes invalidate the derived identity cache and reader binding without changing pending scope. Positive fresh native project identity may still supply known-work context when pending scope is unknown; it does not authorize replay of those records.
312
316
 
313
- A valid pending body is eligible for retirement only after at least seven days and current evidence that its scope is unknown/mismatched or its original cwd is positively absent. A transiently removed or unmounted cwd younger than seven days keeps its data. A positively drainable foreign-project record is retained even when older than seven days; another project's admission does not make it undrainable. Retirement removes the body only after persisting a reason/count metric (`captureDrops` and `captureDropReason`, with `cwd_removed`, `undrainable_expired` or `scope_unknown`); failed counter persistence leaves the body pending. No retired body becomes a replay/context source. Malformed publications can be removed through bounded owned-file checks. Explicit current-policy rejection also retains only a numeric private receipt. This bounded loss policy prevents permanently undrainable records from disabling capture, while never rebinding them to a new project.
317
+ Known-format pending records whose cwd is positively absent can retire after one hour (capture and file modification time must both exceed the grace). Other undrainable records keep the seven-day rule; a changed device/root/scope alone never proves cwd removal. Fair-share pressure can retire known-format records with a counted `scope_capacity` loss, while unknown/newer schemas retain seven-day protection. Each retirement persists numeric reason/count metrics before removing the body; a failed receipt leaves it pending. Retired bodies never become replay or context sources, and records are never rebound to the admitting project. Malformed publications still use bounded owned-file cleanup. See [the spool admission contract](docs/contracts.md#pending-spool-identity-and-admission-217) for identity, N-1 and retention details.
314
318
 
315
319
  If both replayable tiers fill, or SessionStart's reserve alone fills, the current event can be dropped. The library receipt reports `dropped: true`, `dropReason: "spool_full"`, `dropCount: 1`, and the metric reports `captureDrops: 1` with `captureDropReason: "spool_full"`. Non-SessionStart native stdout remains empty. Native SessionStart emits only `hookSpecificOutput`, placing a bounded notice with reason counts from the last 200 metric samples after its initial untrusted-data header inside `additionalContext`, not in new top-level fields. These are rolling-window counts, not all-time totals. The header stays first even when the brief is otherwise unavailable, and the combined context stays within its byte bound. Finite-capacity and retirement losses are observable rather than reported as captured. Exact-once replay applies to positively bound retained records that can progress; unlimited lossless intake is not promised. Soft-cap overshoot, unpublished `.writing/` leftovers and power-loss durability retain the [contracts ceiling](docs/contracts.md#p3-hook-and-native-intake-boundaries-13).
316
320
 
317
321
  SessionStart prioritizes known open work and defers unsafe curated refresh with a fixed coverage reason. Native capture reads the newest 128 rows through an existing read-only time index and marks excluded older history partial, instead of rejecting a larger store. It never creates schema/FTS or repairs indexes. Missing/locked/incompatible sources are explicitly unavailable. The standalone `sessionStartBrief()` retains all its original sources. Recovery failure cannot discard a computed brief. Work, reader-lease and capture host ports use zero busy-wait and cooperative deadline checks; ordinary library callers retain their original defaults, including 3,000 ms waits. No watcher, resident worker, model/network call, embedding job or vault access is added. Host policy callbacks remain unsupported by `runHook`, while the standalone brief composes them normally.
318
322
 
319
- SessionStart admits directly to the reserve with at most 32 metadata entries and performs no active-tier scan, retirement or replay. Non-SessionStart hooks maintain and drain both tiers. This is bounded reserve admission, not O(1) accounting; an atomic slot-reservation protocol is the upgrade. Parent brief storage still contains synchronous filesystem/SQLite work between cooperative deadline checks; asynchronous/deadline-aware storage remains the upgrade, not a larger handler limit. The R2 limits remain 150 ms p95 and 100 MB summed main/helper peak RSS, with a 140 ms handler budget. Functional tests construct a programmatic host-only deadline; the actual executable exposes no environment or CLI deadline extension. Its exec-only boot-clock handoff can only shorten the deadline and is cleared before hook import. Dedicated budget scripts retain the actual CLI, original sample counts, cold attempt 0 and timeout/reaper assertions. The [P3 receipt](docs/p3-hook-budget-2026-10-23.md) identifies prior-head evidence separately from current acceptance; the current rework has no newly claimed both-OS green CI result.
323
+ SessionStart admits directly to the reserve with at most 32 metadata entries and performs no active-tier scan or replay. Both tiers use per-cwd fair shares (64 active records, 8 reserve records), grouping together all HOME/scope variants of a cwd. Admission can retire native records from an overrepresented cwd, counting each loss as `scope_capacity` before removal. Accepted summaries survive same-cwd pressure; new summaries backpressure without advancing their transcript cursor. A fully verified foreign all-summary inventory can use counted `summary_capacity` eviction; otherwise it stays pending for replay. All admission maintenance shares a 32-file/256 KiB body-read bound; unknown/newer schemas retain their seven-day protection. Non-SessionStart hooks maintain and drain both tiers. This is bounded reserve admission, not O(1) accounting; an atomic slot-reservation protocol is the upgrade. Parent brief storage still contains synchronous filesystem/SQLite work between cooperative deadline checks; asynchronous/deadline-aware storage remains the upgrade, not a larger handler limit. The R2 limits remain 150 ms p95 and 100 MB summed main/helper peak RSS, with a 140 ms handler budget. Functional tests construct a programmatic host-only deadline; the actual executable exposes no environment or CLI deadline extension. Its exec-only boot-clock handoff can only shorten the deadline and is cleared before hook import. Dedicated budget scripts retain the actual CLI, original sample counts, cold attempt 0 and timeout/reaper assertions. The [P3 receipt](docs/p3-hook-budget-2026-10-23.md) identifies prior-head evidence separately from current acceptance; the current rework has no newly claimed both-OS green CI result.
320
324
 
321
325
  A private admission cursor rotates bounded inspection across pending records (at most 32 files/256 KiB per pass), so a healthy first slice cannot indefinitely hide later removed/unknown entries from retirement checks. Git include/conditional identity dependencies are traversed within depth four and 16 files. Pending scope hashes relevant semantic project-identity values rather than unrelated full-config or HEAD churn; derived cache validity may additionally hash raw configuration/HEAD bytes. Unconditional and `gitdir` includes bind semantic identity values. Dynamic `onbranch`/`hasconfig` or unknown conditional trees without identity keys bind nothing; if they contain origin/rewrite/worktree identity keys, scope stays unknown. Native read-only identity may still supply known work, but it never adopts that pending capture; counted `scope_unknown` retirement needs seven days. This conservative ceiling is upgraded by evaluating `onbranch` against HEAD at drain time, not by adding a condition interpreter in this PR.
322
326
 
@@ -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.20.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,14 +10,14 @@ 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";
17
17
  import type { IngestKind, IngestReceipt } from "./ingest.ts";
18
18
  import { importClaudeMemCapture, importClaudeMemWorkState } from "./memory/import.ts";
19
19
  import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, readSync, rmSync, writeFileSync } from "node:fs";
20
- import { join } from "node:path";
20
+ import { isAbsolute, join } from "node:path";
21
21
  import { MemoryStore, looksSecret, machineId, openShardStore, type StoreOptions } from "./memory/store.ts";
22
22
  import { rawMemoryCommand, rawGoalCommand, claudeMemImportText } from "./memory/commands.ts";
23
23
  import { memoryProjectKey } from "./memory/project.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";
@@ -136,6 +136,39 @@ export function agentMarked(env: Readonly<Record<string, string | undefined>>):
136
136
  return ["AGENTHUB_PEER_ID", "CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID", "PI_SESSION_ID"].some(key => env[key] !== undefined);
137
137
  }
138
138
 
139
+ /** #217 PR2: pure checks only (no home, source or store access), so a bad invocation is refused before anything is read. */
140
+ function assertSpoolReplayArguments(path: readonly string[], options: Record<string, unknown>): void {
141
+ if (path[1] !== "replay") throw new AgentUsageError("usage: amem spool replay [--json] [--source <absolute dir>] | amem spool replay --apply --plan <file>");
142
+ const apply = options["--apply"] === true, planFile = options["--plan"], source = options["--source"];
143
+ if (apply && typeof planFile !== "string") throw new AgentUsageError("spool replay --apply needs --plan <saved preview>; run `amem spool replay --json > plan.json` first");
144
+ if (!apply && planFile !== undefined) throw new AgentUsageError("--plan is only used with --apply; drop it to preview, or add --apply to apply it");
145
+ if (apply && source !== undefined) throw new AgentUsageError("--source is fixed by the saved plan; drop --source when applying");
146
+ if (options["--confirm-unverifiable"] === true && !apply) throw new AgentUsageError("--confirm-unverifiable only applies with --apply --plan; preview first, review the mappings, then apply");
147
+ if (typeof planFile === "string" && (!planFile || planFile.includes("\0"))) throw new AgentUsageError("--plan is empty or contains a NUL byte; pass the saved preview's path");
148
+ if (source !== undefined) {
149
+ if (typeof source !== "string" || !source || source.includes("\0")) throw new AgentUsageError("--source is empty or contains a NUL byte; pass an absolute path to a private directory");
150
+ if (!isAbsolute(source)) throw new AgentUsageError("--source is a relative path; pass the absolute path of the parked directory");
151
+ }
152
+ }
153
+ /** Body-free preview text: counts by class, then each record's cwd, current project and class for explicit confirmation. */
154
+ function replayPreviewText(plan: { records: { name: string; cwd: string; project: string | null; class: string; event: string | null }[]; counts: Record<string, number>; digest: string; needsConfirmation: { cwd: string; project: string; count: number }[] }): string {
155
+ const lines = [`spool replay preview (no record bodies shown; plan ${plan.digest.slice(0, 12)})`,
156
+ ...Object.entries(plan.counts).filter(([, n]) => n > 0).map(([cls, n]) => ` ${cls}: ${n}`)];
157
+ const byCwd = new Map<string, { project: string | null; classes: Map<string, number> }>();
158
+ for (const record of plan.records) {
159
+ const row = byCwd.get(record.cwd) ?? { project: record.project, classes: new Map() };
160
+ row.classes.set(record.class, (row.classes.get(record.class) ?? 0) + 1); byCwd.set(record.cwd, row);
161
+ }
162
+ for (const [cwd, row] of byCwd) lines.push(` ${cwd || "(unreadable record)"} -> ${row.project ?? "(project unresolved)"}: ${[...row.classes].map(([c, n]) => `${c} ${n}`).join(", ")}`);
163
+ if (plan.needsConfirmation.length) {
164
+ lines.push("Needs confirmation (decision A: this boot cannot prove the original identity; each maps by its own cwd):");
165
+ for (const group of plan.needsConfirmation) lines.push(` ${group.cwd} -> ${group.project}: ${group.count}`);
166
+ lines.push("To apply these mappings too, after checking each one: amem spool replay --json > plan.json && amem spool replay --apply --plan plan.json --confirm-unverifiable");
167
+ }
168
+ lines.push("Without --confirm-unverifiable only replayable records are applied: amem spool replay --json > plan.json && amem spool replay --apply --plan plan.json");
169
+ return lines.join("\n");
170
+ }
171
+
139
172
  /** Administrative metadata is checked as a complete value, never by stripping individual fields. */
140
173
  function assertAgentAdministrative(value: unknown, policy: ReturnType<typeof configuredPolicy>): void {
141
174
  if (cliAttribution()?.actor !== "agent") return;
@@ -180,6 +213,16 @@ class CliPartialApplyError extends Error {
180
213
  }
181
214
  }
182
215
 
216
+ /**
217
+ * `amem manifest`: what this install serves, read by installers (dotfiles#320) without a compiled list. A tool is a plain name, or
218
+ * `{ name, readOnly: true }` when its MCP annotation says it changes nothing. No memory data, so it needs no home.
219
+ */
220
+ export function manifest() {
221
+ return { name: "@staix/agent-mem", version: VERSION,
222
+ mcp: { server: "amem", tools: MCP_TOOLS.map(tool => tool.annotations.readOnlyHint ? { name: tool.name, readOnly: true as const } : tool.name) },
223
+ commands: Object.keys(COMMANDS) };
224
+ }
225
+
183
226
  export async function runCli(argv: readonly string[], context: CliContext = {}): Promise<string> {
184
227
  // Native capture and the MCP server retain their existing surfaces and attribution.
185
228
  if (argv[0] === "hook" || argv[0] === "ingest" || argv.length === 1 && argv[0] === "mcp") return runCliCommand(argv, context);
@@ -228,8 +271,17 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
228
271
  throw error;
229
272
  });
230
273
  }
274
+ /**
275
+ * Every lazily imported module outside the CLI-only commands (relative to this file), loaded at `amem mcp` start (#199). A later `import()`
276
+ * then hits the module cache, so an install replaced in place under a running server never mixes two versions in one process.
277
+ * `test/coexistence.test.ts` fails when a new lazy import is neither listed here nor named there as CLI-only.
278
+ */
279
+ export const MCP_PRELOAD = ["./memory/store.ts", "./memory/capture.ts", "./memory/sync.ts", "./memory/shard-sync.ts", "./memory/public-prune-advance.ts"] as const;
280
+ // The boundary check admits only literal specifiers, so the list above is spelled out again here; the test compares the two.
281
+ 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
282
  async function runCliCommand(argv: readonly string[], context: CliContext): Promise<string> {
232
283
  if (argv.length === 1 && argv[0] === "mcp") {
284
+ await preloadMcp();
233
285
  const memory = createMemoryMcp({ cwd: context.cwd ?? process.cwd(), base: memoryHome(context.env ?? process.env) });
234
286
  await serveMcp({ tools: memory.tools, close: memory.close, onEmitted: memory.onEmitted, version: VERSION });
235
287
  return "";
@@ -260,6 +312,7 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
260
312
  try {
261
313
  parsed = parseCommand(argv);
262
314
  if (parsed.path[0] === "version") return `amem ${VERSION}`;
315
+ if (parsed.path[0] === "manifest") return JSON.stringify(manifest(), null, parsed.options["--json"] === true ? undefined : 2);
263
316
  if (parsed.path[0] === "completion") {
264
317
  if (parsed.positionals.length !== 1) throw new Error("usage: amem completion zsh|bash|fish");
265
318
  return completionScript(parsed.positionals[0]!);
@@ -271,15 +324,18 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
271
324
  const { definition, options, rawArgs: args } = parsed;
272
325
  const identity = cliAttribution() ?? { actor: "person" as const, peer: "person" }, actor = identity.actor;
273
326
  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]");
327
+ 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
328
  if (parsed.positionals[0] === "console") {
276
329
  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);
330
+ const mode = options["--apply"] === true ? "apply" : options["--undo"] === true ? "undo" : "preview";
331
+ const root = typeof options["--root"] === "string" ? (await import("./setup.ts")).installRoot(options["--root"]).path : undefined;
332
+ const result = root === undefined ? consoleSetup(mode, context.env ?? process.env) : consoleSetup(mode, context.env ?? process.env, join(root, "src", "ui", "plugin.ts"));
278
333
  return options["--json"] === true ? JSON.stringify(result, null, 2) : consoleSetupText(result, mode);
279
334
  }
280
335
  const { runSetup } = await import("./setup.ts");
281
336
  return runSetup(parsed.positionals[0]!, options["--apply"] === true ? "apply" : options["--undo"] === true ? "undo" : "preview", {
282
337
  env: context.env ?? process.env, stdinTTY: context.stdinTTY ?? process.stdin.isTTY, cwd: context.cwd ?? process.cwd(),
338
+ ...(typeof options["--root"] === "string" ? { root: options["--root"] } : {}),
283
339
  ...(context.setupConfirm ? { confirm: context.setupConfirm } : {}), json: options["--json"] === true, style: outputStyle(context),
284
340
  });
285
341
  }
@@ -317,7 +373,9 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
317
373
  } catch { return false; } finally { try { candidates?.close(); } catch { /* optional telemetry cannot fail emitted output */ } }
318
374
  } });
319
375
  };
320
- if (["migrate", "prune", "capacity", "gc", "project", "alias", "machine", "sync", "import", "dedupe", "consolidate"].includes(sub)) administrative({ command: parsed.path, arguments: args });
376
+ // #217 PR2: pure argument refusals for spool replay run first, before the administrative policy read touches the home.
377
+ if (sub === "spool") assertSpoolReplayArguments(parsed.path, options);
378
+ if (["migrate", "prune", "capacity", "gc", "project", "alias", "machine", "sync", "import", "dedupe", "consolidate", "spool"].includes(sub)) administrative({ command: parsed.path, arguments: args });
321
379
  if (sub === "consolidate") {
322
380
  const requested = typeof options["--project"] === "string" ? options["--project"] as string : boundProject();
323
381
  if (!requested.trim() || requested.length > 400 || requested.includes("\0")) throw Error("invalid consolidation project");
@@ -646,6 +704,24 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
646
704
  }
647
705
  return options["--json"] ? JSON.stringify(report, null, 2) : evalText(report, builtin ? `built-in fixture ${file}` : file ?? "built-in fixture");
648
706
  }
707
+ if (sub === "spool") {
708
+ // #217 PR2. Every argument refusal runs before any home, source or store access; both actors take this guarded
709
+ // preview/apply path (no marker-only ban, no generic TTY gate). The replay module is loaded only here.
710
+ // Argument refusals already ran in assertSpoolReplayArguments, before any home access.
711
+ const apply = options["--apply"] === true, planFile = options["--plan"], source = options["--source"];
712
+ const replay = await import("./hook-spool-replay.ts");
713
+ if (!apply) {
714
+ const plan = administrative(await replay.planHookSpoolReplay({ base: memoryHome(), ...(typeof source === "string" ? { source } : {}) }));
715
+ return options["--json"] === true ? JSON.stringify(plan, null, 2) : replayPreviewText(plan);
716
+ }
717
+ let saved: unknown;
718
+ try { saved = JSON.parse(readFileSync(planFile as string, "utf8")); }
719
+ catch { throw new Error(`cannot read the replay plan ${planFile as string}; run \`amem spool replay --json > plan.json\` and pass that file`); }
720
+ const plan = administrative(replay.parseReplayPlan(saved));
721
+ const receipt = administrative(await replay.applyHookSpoolReplay({ base: memoryHome(), plan, confirmUnverifiable: options["--confirm-unverifiable"] === true }));
722
+ return options["--json"] === true ? JSON.stringify(receipt, null, 2)
723
+ : `replayed ${receipt.applied} (${receipt.spooled} via the capture spool), retired ${receipt.retired}, retained ${receipt.retained}, ${receipt.absent} absent (completed by an earlier run of this plan, or removed or drained by someone else; nothing was appended for it); ${receipt.outcomes.filter(o => o.outcome === "skipped").length} skipped by the plan`;
724
+ }
649
725
  if (sub === "capacity") {
650
726
  const share = options["--disk-share"];
651
727
  if (share !== undefined && (!Number.isFinite(Number(share)) || Number(share) <= 0 || Number(share) > 1)) throw new Error("--disk-share must be a number in (0, 1]");
@@ -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)"),
@@ -41,6 +43,11 @@ export const COMMANDS: Record<string, CommandDefinition> = {
41
43
  usage: { description: "Totals and the largest project shards", options: { ...json, "--limit": text("Largest shards to list (1..100)"), "--all": flag("Include forgotten projects") } },
42
44
  eval: { description: "Replay a local question set against ranked store.search and report recall@k and MRR (no model, no network; the built-in synthetic set without --set, `--set ko` for the built-in Korean one)", options: { ...json,
43
45
  "--set": { description: "`ko` (the built-in Korean set) or 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)") } },
46
+ spool: { description: "Off-hook hook-spool maintenance (#217)", options: json, commands: {
47
+ replay: { description: "Preview (read-only, no record bodies) or apply a replay of orphaned or parked hook records; --apply takes the saved preview", mutates: "apply", options: { ...json,
48
+ "--source": { description: "Absolute path of a private parked directory to read instead of this home's hook spool", value: "path" },
49
+ "--apply": flag("Apply the saved preview given with --plan"), "--confirm-unverifiable": flag("With --apply: also replay the saved plan's 'needs confirmation' cwd -> project mappings (records this boot cannot prove)"), "--plan": { description: "Saved preview from `amem spool replay --json`", value: "path" } } },
50
+ } },
44
51
  gc: { description: "Apply each shard's retention and cap across shards in bounded batches (preview without --confirm)", mutates: "confirm", options: {
45
52
  ...json, "--confirm": flag("Evict now; without it, preview only"), "--batches": text("evict() windows per shard in this run (1..1000, default 1)") } },
46
53
  project: { description: "Per-project lifecycle and portable import/export (#15 D3, F17): one project only", options: json, commands: {
@@ -1,3 +1,15 @@
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
+ /** `unavailable` (#221): the scan threw, so no progress is known; unlike the others it is a fault, never accepted progress. */
5
+ export type HookTranscriptStopReason = "cap" | "deadline" | "spool_full" | "unavailable";
6
+ export const HOOK_TRANSCRIPT_STOP_REASONS: readonly HookTranscriptStopReason[] = ["cap", "deadline", "spool_full", "unavailable"];
7
+ export interface HookTranscriptProgress { offset?: number; stopReason?: HookTranscriptStopReason }
8
+ export function isHookTranscriptProgress(value: unknown): value is HookTranscriptProgress {
9
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
10
+ const row = value as Record<string, unknown>;
11
+ return Object.keys(row).every(key => key === "offset" || key === "stopReason")
12
+ && (row.offset !== undefined || row.stopReason !== undefined)
13
+ && (row.offset === undefined || Number.isSafeInteger(row.offset) && (row.offset as number) >= 0)
14
+ && (row.stopReason === undefined || (HOOK_TRANSCRIPT_STOP_REASONS as readonly string[]).includes(row.stopReason as string));
15
+ }
@@ -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