@staix/agent-mem 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "agent-mem",
3
+ "version": "0.6.0",
4
+ "description": "Bounded project memory context and deterministic capture, without a resident worker."
5
+ }
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.4.0. Requires Bun 1.4.2 or later; no runtime dependencies.
5
+ Version: 0.6.0. Requires Bun 1.4.2 or later; no runtime dependencies.
6
6
 
7
7
  ## Install
8
8
 
@@ -34,7 +34,7 @@ amem goal new "Ship a release" --milestone "Review changes"
34
34
 
35
35
  Memory commands: `list`, `search`, `show`, `graph`, `suggestions`, `confirm`, `reject`, `promote`, `demote`, `forget`, `supersede`, `rebuild`, `dedupe`, `sync`, `machine`, and `import`. Use `--json` where supported by the corresponding `ahub memory` command. `dedupe` previews candidates; `dedupe --apply` appends reconciliation operations. `amem alias add|list` manages person-reviewed project key aliases; `amem capacity` reports and proposes capture bounds (see below).
36
36
 
37
- Ordinary `amem` commands are for the person and refuse agent-session markers, including `AGENTHUB_PEER_ID`, `CLAUDECODE`, and `CODEX_THREAD_ID`. `amem mcp` is the sole intentional session agent entrypoint; it supplies scoped reads and bounded candidate/work-list writes, never direct curated-note writes. Hosted agents can also use the hub's `hub_memory_*` read tools. Environment-marker detection is an honest-session guard, not authentication. Ordinary CLI mutations remain person-only and require a TTY on stdin.
37
+ Ordinary `amem` commands are for the person and refuse agent-session markers, including `AGENTHUB_PEER_ID`, `CLAUDECODE`, and `CODEX_THREAD_ID`. `amem mcp`, `amem hook` and `amem ingest` are intentional session entrypoints. MCP supplies scoped reads and bounded candidate/work-list writes; hooks and ingest write capture only. None writes curated notes directly. Hosted agents can also use the hub's `hub_memory_*` read tools. Environment-marker detection is an honest-session guard, not authentication. Ordinary CLI mutations remain person-only and require a TTY on stdin.
38
38
 
39
39
  This package does not ship the host's privacy policy. Ordinary person reads show stored data without screening; the vault-candidate listing uses its current policy. Import and promotion retain the built-in secret screen. Commands that cross the privacy boundary require the person's explicit `--policy-free` choice:
40
40
 
@@ -133,16 +133,64 @@ The JSONL segments are authoritative. `index.db` is derived and can be rebuilt.
133
133
 
134
134
  ## Capture tier and full-text search
135
135
 
136
- From issue #13 (phase P1), the package carries a second, machine-local tier beside the curated store, born sharded (#15 T1): one capture shard per project key at `projects/<key-hash>/machine/<machine-id>/` under the memory home, with its own locks so two projects never contend. The capture tier records deterministic tool-event rows (project, agent, event, tool, paths, exit status and a bounded, secret-screened excerpt) in the shard's `capture.db`. It is never synced, never read as curated evidence, and never writes a curated log, so its maintenance can never evict notes, goals or work lists. Capture is bounded by a soft size cap and a retention window (defaults 512 MB per shard, 90 days), resolved at every invocation from the library options, then the shard's `config.json`, then the home default `capture-config.json`; eviction removes the oldest capture rows first in bounded batches. The 2026-10-17 owner decision keeps the P1 cap per shard; the machine-wide total is #15 T5. On lock contention a writer appends to an append-only spool and exits fast; the next uncontended writer replays the spool idempotently. The capture path makes no network or model call. The pre-T1 machine-wide `capture/` (an unreleased layout of this branch) is migrated per project on open and removed once empty.
136
+ From issue #13 (phase P1), the package carries a second, machine-local tier beside the curated store, born sharded (#15 T1): one capture shard per project key at `projects/<key-hash>/machine/<machine-id>/` under the memory home, with its own locks so two projects never contend. The capture tier records deterministic tool-event rows (project, agent, event, tool, paths, exit status and a bounded, secret-screened excerpt) in the shard's `capture.db`. It is never synced, never read as curated evidence, and never writes a curated log, so its maintenance can never evict notes, goals or work lists. Capture is bounded by a soft size cap and a retention window (defaults 512 MB per shard, 90 days), resolved at every invocation from the library options, then the shard's `config.json`, then the home default `capture-config.json`; eviction removes the oldest capture rows first in bounded batches. The 2026-10-17 owner decision keeps the P1 cap per shard; the machine-wide total is #15 T5. On lock contention a writer publishes a complete immutable per-writer spool segment and exits fast; the next uncontended writer replays the spool idempotently. The capture path makes no network or model call. The pre-T1 machine-wide `capture/` (an unreleased layout of this branch) is migrated per project on open and removed once empty.
137
137
 
138
- Project identity (#15 D2) keeps `memoryProjectKey`: a worktree, a submodule and the main checkout of one repository share its key; two unrelated same-named repositories without a remote get distinct path-hash keys, flagged `local` in the shard's `project.json` and the catalog. A no-remote key hashes the main working root (using the actual top-level for a separate git dir), so keys written from the repository root keep their pre-#15 value. Known limit: a linked worktree of a no-remote separate-git-dir repository gets its own key, because git does not record that repository's main working root (probe: `alpha` and `alpha/sub` share `alpha@<hash>`, `alpha-wt` gets `alpha-wt@<hash>`); the key stays as is for root-key compatibility, and a reviewed alias reconciles its capture. Keys written by older builds from a subdirectory or worktree still used that invocation's cwd hash; a person-reviewed alias maps such a key to its canonical one for capture, work lists and the CLI and MCP project binding. Curated rows written under the old key keep it and are not remapped, so the CLI and MCP stop showing that key's project-scoped notes, goals and candidates (the library still reads them by the raw key; shared rows stay visible). An alias can be retargeted but not removed, so add one only after checking the old key's private notes; curated row reconciliation is a later spec change. `amem alias add <from-key> <to-key>` appends an op to `aliases.jsonl` (latest wins, never chained, never automatic), `amem alias list` shows the ops. `catalog.db` in the memory home (#15 D3's storage part) is derived only from the shards and the alias log and rebuilds to the same rows; the D3 management commands are #15 T5 and not built.
138
+ Project identity (#15 D2) keeps `memoryProjectKey`: a worktree, a submodule and the main checkout of one repository share its key; two unrelated same-named repositories without a remote get distinct path-hash keys, flagged `local` in the shard's `project.json` and the catalog. A no-remote key hashes the main working root (using the actual top-level for a separate git dir), so keys written from the repository root keep their pre-#15 value. Known limit: a linked worktree of a no-remote separate-git-dir repository gets its own key, because git does not record that repository's main working root (probe: `alpha` and `alpha/sub` share `alpha@<hash>`, `alpha-wt` gets `alpha-wt@<hash>`); the key stays as is for root-key compatibility, and a reviewed alias reconciles its capture. Keys written by older builds from a subdirectory or worktree still used that invocation's cwd hash; a person-reviewed alias maps such a key to its canonical one for capture, work lists and the CLI and MCP project binding. Curated rows written under the old key keep their key in the log (nothing is rewritten), and the CLI and MCP read them under the canonical project: the project filter of notes, goals, candidates, vault candidates and work lists covers the canonical key and every key aliased to it, and presents those rows under the canonical key (#27). The library `MemoryStore` still reads by the raw key unless a host passes `reconcile`, and shared rows stay visible as before. Writes still go to the key's own log and shard, and a settle or forget of an aliased row lands in the shard that holds it. `amem alias add <from-key> <to-key>` appends an op to `aliases.jsonl` (latest wins, never chained, never automatic), `amem alias remove <from-key>` appends a removal (the old key reads under itself again, the history stays), `amem alias list` shows the ops including removals. Both are person-only and need a terminal. An amem or hub older than this change ignores a removal and keeps the old binding until it is upgraded, so upgrade every host that shares the home first. A correction accepted on an aliased row lives under the canonical key, so after a removal the old key shows the superseded note again (see `docs/contracts.md`). `catalog.db` in the memory home (#15 D3's storage part) is derived only from the shards and the alias log and rebuilds to the same rows; the D3 management commands are #15 T5 and not built.
139
139
 
140
140
  `store.search(query, filter)` searches the curated tier and the current project's capture shard with SQLite FTS5 and BM25 plus tier/recency weighting: natural multi-word queries match partial term sets (the substring filter required every word), and a curated row outranks the same text in the capture tier. Prefix terms match inflections and Korean particles in either direction by retaining the original query term and adding a common-particle-stripped alternative. The FTS copy of the curated rows lives in its own derived `search.db` (`index.db`'s table set is a frozen contract and gains nothing); it is written from the same apply path as the index, and a prune, crash or rebuild is detected and resynced, so the incremental FTS copy equals a full resync. Where FTS5 is unavailable the engine falls back to the substring filter and reports `engine: "substring"`; capture recall needs FTS5 and is skipped in the fallback. The library entry exports `CaptureStore`, `captureSearch`, `readCaptureConfig`, `writeCaptureConfig`, `SearchIndex`, `fts5Available`, the shard helpers (`resolveAlias`, `appendAlias`, `listAliases`, `shardKeyHash`), the catalog (`rebuildCatalog`, `readCatalog`, `scanCatalog`) and the capacity advisor (`capacityReport`).
141
141
 
142
- The R7 capacity advisor reports this machine's load and proposes capture bounds: `amem capacity [--json]` shows capture size and growth, disk, memory, the rolling `metrics.jsonl` readings and the proposal with its binding constraint; `amem capacity apply [--cap-mb N] [--retention-days N]` saves a preview, and only a matching later `--confirm` writes the home default config. Applying is person-only (agent markers refused, a terminal required); an agent may read the proposal through the library, never apply it. The 2026-10-17 owner decision keeps P1 shrink-only; growth proposals are a follow-up after the agent-hub release, while explicit person-directed growth remains available through `apply --cap-mb N`. Benchmark samples stay in the measuring script's synthetic home; only real hook and search invocations feed the advisor's rolling metrics.
142
+ The R7 capacity advisor reports this machine's load and proposes capture bounds: `amem capacity [--json]` shows capture size and growth, disk, memory, the rolling `metrics.jsonl` readings and the proposal with its binding constraint; `amem capacity apply [--cap-mb N] [--retention-days N]` saves a preview, and only a matching later `--confirm` writes the home default config. Applying is person-only (agent markers refused, a terminal required); an agent may read the proposal through the library, never apply it. Under the 2026-10-17 owner decision (item 2) the proposal is the largest cap and retention that fit, and it may be above the current values: the cap is the disk-share bound itself (default 2% of free space, `--disk-share`), and retention is the share divided by the measured daily growth, up to a 365-day ceiling (a `ponytail:` constant, a person setting is the upgrade path). A hook p95 over 150 ms or hook RSS over 100 MB halves the proposed cap, and search RSS over 200 MB halves the retention. The reason names every binding constraint: `disk-share retention` or `retention ceiling`, `disk-share cap`, `R3 search RSS`, `R2 hook cost`. A proposal is never written by the advisor, and `apply` still takes `--cap-mb` and `--retention-days` to override it. Benchmark samples stay in the measuring script's synthetic home; only real hook and search invocations feed the advisor's rolling metrics.
143
143
 
144
144
  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).
145
145
 
146
+ ## Claude Code hooks and SessionStart context
147
+
148
+ The package includes a local Claude Code plugin: `.claude-plugin/plugin.json` and `hooks/hooks.json`. Install the package as above, then explicitly load its package root in a person's Claude Code session. A checkout can be loaded directly:
149
+
150
+ ```sh
151
+ claude --plugin-dir /absolute/path/to/agent-mem
152
+ ```
153
+
154
+ Use the directory containing `.claude-plugin/`, `hooks/` and `bin/`, not the global executable directory. These are setup instructions, not an installation performed by this repository. Package installation alone does not edit Claude settings, register the plugin or disable any existing memory integration. Check for duplicate capture hooks before opting in; the separate P5 cutover owns changes to existing integrations.
155
+
156
+ The packaged executable uses `#!/usr/bin/env -S bun --smol`, and plugin commands explicitly pass `bun --smol`. An explicit `bun bin/amem.js hook ...` invocation without that flag replaces its bootstrap with `execve` before loading hook stores/policy. That fallback keeps the same PID, stdin, preload arguments and original wall deadline; it does not add a third Bun process. Process helpers load on demand, and machine identity lives in a small shared leaf, avoiding eager curated-store loading in the hook parent/work-list path.
157
+
158
+ The plugin observes seven events: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `Stop` and `SessionEnd`. They are deterministic capture/context hooks, never permission hooks: they cannot deny a tool call, replace its arguments or turn a capture failure into a native failure. Input is capped at 64 KiB. Malformed, private, secret-looking, denied-path or unavailable input is skipped, and hook errors exit successfully without a native decision. All seven events may append screened rows to the current project's machine-local capture shard; only SessionStart adds context.
159
+
160
+ The standalone brief prioritizes open work lists, recent curated notes and then recent capture; native SessionStart uses the narrower deadline-safe sources described below. The context starts with an untrusted-data warning and per-section `complete`, `partial` or `unavailable` coverage, followed by JSON records. Capture records carry `evidence: false`. The default context bound is 1,200 UTF-8 bytes, counted conservatively as a token upper bound, including the header. Unicode-safe display limits flatten control/line separators; memory text cannot introduce a new header or instruction frame. Full rows and composed work views pass standalone and any stricter host policy before selection; displayed projections are screened again. Canonical project scope includes reviewed aliases for curated notes and work lists, excludes foreign shared-project rows, and is rechecked with profile/selector bindings after asynchronous policy calls.
161
+
162
+ Before optional work starts, the parent screens the complete hook input and bounded normalized capture fields, then closes and atomically publishes a private immutable record in `<memory-home>/hook-spool/`. It retains normalized capture data and a physical-cwd/scope stamp, never the raw native envelope or native argument vector. Non-SessionStart events use the fixed Bun `--smol` worker in a short-lived owned process group. The worker resolves the current project, rechecks scope, drains by a stable receipt, and acknowledges only after append or capture-spool publication. The parent reaps it, and it has its own deadline. SessionStart computes context in the already-running CLI, avoiding a second Bun boot. Its native metadata commands have owned shell supervisors with the same remaining deadline, an armed self-watchdog and group cleanup even if the CLI dies. A timeout leaves accepted capture pending for later replay; unresolved pending records never enter context.
163
+
164
+ 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.
165
+
166
+ 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.
167
+
168
+ 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).
169
+
170
+ 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.
171
+
172
+ 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.
173
+
174
+ 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.
175
+
176
+ Successful native project identities have a private, bounded derived cache (128 cwd entries, 4 KiB each). A hit must match the current project scope, stricter Git-validity metadata and executable revision; a miss still uses the shared hardened Git resolver. It stores no capture or context, and does not guess an identity when Git fails. The hook uses the selected developer Git directly only when PATH selects Apple's root-owned shim; custom PATH binaries remain selected. Cache ceilings and invalidation are in [the P3 contracts](docs/contracts.md#hook-project-identity-cache).
177
+
178
+ ## Native transcript intake
179
+
180
+ `amem ingest` reads existing Codex, Kimi or Pi transcripts and writes screened deterministic capture rows, never curated notes. Run it from the project to bind intake to that working directory; the CLI exposes no project/home override or policy bypass:
181
+
182
+ ```sh
183
+ amem ingest codex --since 2026-10-01T00:00:00Z --json
184
+ amem ingest kimi --json
185
+ amem ingest pi --since 2026-10-01T00:00:00Z --json
186
+ ```
187
+
188
+ Discovery uses `CODEX_HOME` or `~/.codex` (`sessions/` and `archived_sessions/`), `KIMI_CODE_HOME` or `~/.kimi-code` (`sessions/**/wire.jsonl`), and `PI_CODING_AGENT_DIR` or `~/.pi/agent` (`sessions/`). It does not follow directory symlinks. A native record must carry a recognized schema/time and an observed absolute working-directory binding that resolves to this canonical project, including reviewed aliases. Unknown working directories, unsupported Kimi wire versions, missing timestamps and unavailable source roots are reported rather than guessed as an empty successful history. Foreign/private/secret rows are not imported. Full native envelopes and extracted capture inputs both pass current policy.
189
+
190
+ Per-source cursor databases and a fair rotation cursor live in the capture shard's private `ingest/` directory. Filenames and source identities are hashes; cursor contents retain offsets, anchor hashes, scope/protocol/cutoff and ownership metadata, not raw transcript paths or text. Capture receipts make replay idempotent across a crash between append and cursor acknowledgement. Repeating the command resumes its bounded tour instead of repeatedly favoring the first files. Broadening `--since` rechecks earlier history while existing capture receipts prevent duplicates. The JSON receipt reports `files`, `read`, `imported`, `skipped`, `spooled`, `remaining`, `errors` and an optional bounded `reason`; unavailable coverage is not silently represented by zero imported rows.
191
+
192
+ The default invocation reads at most 4 MiB and attempts 32 sources; trusted library options can raise those limits to 16 MiB and 128 sources. It examines at most 4,096 records, bounds a record at 256 KiB, and caps discovery at 4,096 directory entries/depth 8. Re-run a receipt with `remaining: true` after inspecting its reason; ingest creates no polling timer or watcher. Oversized records, including an unbroken record longer than the 4 MiB default window, are counted once and discarded through their next newline using a durable byte offset/skip flag. Later valid records remain reachable without retaining oversized content; an unfinished oversized tail stays pending until its newline arrives. The cursor assumes append-only native files and verifies file identity plus the last 1,024 acknowledged bytes. Historical rewrites outside that anchor are a declared ceiling, not full-file verification. These readers do not establish account-backed native interoperability, P5 cutover or P6 live-week acceptance.
193
+
146
194
  ## Library
147
195
 
148
196
  ```ts
@@ -238,6 +286,8 @@ A native shard crash tail is left to its original writer's recovery. Migration c
238
286
 
239
287
  Run `amem mcp` as a stdio child of the agent CLI. It binds the working directory, canonical project key, exact memory home and selected profile once, accepts no project/home overrides in tool arguments, and closes all stores on stdin EOF. It has no listener, daemon, watcher or model call. Ordinary person commands retain their agent-marker refusal and mutation TTY checks; `mcp` is the sole agent entry point.
240
288
 
289
+ MCP search, timeline and show remain available above 5,000 rows. Reads page exact-project candidates and screen them before result selection. Search ranks the newest 5,000 permitted matching candidates per tier; `since` narrows that window. Timeline can anchor an older row directly. Capture MCP indexing is lazy on queried reads, never hook construction or append. Index contention uses a call-pinned substring fallback; later searches retry. See [MCP candidate reads](docs/contracts.md#mcp-candidate-reads-13-task-209) for the bounded-window and fallback contract.
290
+
241
291
  The frozen nine-tool contract for dotfiles#255 and maru#458:
242
292
 
243
293
  | Tool | Purpose |
@@ -252,7 +302,7 @@ The frozen nine-tool contract for dotfiles#255 and maru#458:
252
302
  | `amem_vault_candidates` | Read-only screened durable curated notes |
253
303
  | `amem_capacity` | Numeric machine capacity proposal; no apply capability |
254
304
 
255
- Search/show/timeline label capture as recall with `evidence: false`; capture never enters the curated graph. Policy screens complete rows before selection, limits or graph construction. The standalone secret screen, private flags, PII patterns and deny paths compose with a host's stricter policy through `createMemoryMcp({cwd, base, policy})`. Screening errors fail closed. `base` is the exact memory root; historical library `home` options still name its parent. Scope and profile changes refuse further access instead of silently reopening another store. Reads refuse above 5,000 rows per curated/capture tier; work-list reads use the same 5,000-entry scan ceiling. Count-first admission refuses an oversized work-list corpus before loading or parsing its fields, rather than returning an incomplete screened result. These are operational ceilings, not exposed counts of unscreened data. Graphs cover curated-note relationships, not goal/suggestion tables.
305
+ Search/show/timeline label capture as recall with `evidence: false`; capture never enters the curated graph. Policy screens complete rows before selection, limits or graph construction. The standalone secret screen, private flags, PII patterns and deny paths compose with a host's stricter policy through `createMemoryMcp({cwd, base, policy})`. Screening errors fail closed. `base` is the exact memory root; historical library `home` options still name its parent. Scope and profile changes refuse further access instead of silently reopening another store. Graph reads retain the 5,000-row curated ceiling; work-list reads stream the complete ordered history without an operation-count ceiling, applying policy before latest-value reduction. The working state scales with the current result, not historical payloads. The graph ceiling is an operational bound, not an exposed count of unscreened data. Graphs cover curated-note relationships, not goal/suggestion tables.
256
306
 
257
307
  `WorkListStore` writes `projects/<key-hash>/work/` with its own append log, derived index and lock. Scalar fields reduce by latest value, null clears, list status `done` closes, and item status is `todo|doing|done|dropped`. Work lists are outside generation selection and capture eviction. `amem import claude-mem` additionally imports `work_state_entries` only through persisted person-reviewed D2 aliases, preserving source DB bytes and stable replay receipts; JSON keeps observation fields and adds `work` counts/cursor.
258
308
 
@@ -262,7 +312,7 @@ Do not record vault links on a home shared with a host using published agent-mem
262
312
 
263
313
  General memory candidates are separate from curated evidence. `amem review accept <id> --kind memory --policy-free` remains person-only and rechecks the current standalone privacy policy before the accepted note append. Machine capacity reports numeric aggregate use across shards, as R7 requires; it does not return foreign project names or text.
264
314
 
265
- `bun scripts/measure-mcp-budget.ts` exercises an actual stdio session with 200 curated notes, 2,000 capture rows and 2,000 work-list operations reduced into 100 items. It checks the R3 idle RSS limit of 100 MB, peak RSS limit of 200 MB across searches and work-list reads, and child reaping after EOF. CI runs the harness on macOS and Linux. All harness data is synthetic; live P6 measurements remain separate.
315
+ `bun scripts/measure-mcp-budget.ts` exercises an actual stdio session with 5,001 curated notes, 5,001 capture rows and 10,001 work-list operations reduced into 100 items. It checks the R3 idle RSS limit of 100 MB, peak RSS limit of 200 MB across searches, latest/anchored timelines and work-list reads, and child reaping after EOF. CI runs the harness on macOS and Linux. All harness data is synthetic; live P6 measurements remain separate.
266
316
 
267
317
  ## Development
268
318
 
package/bin/amem.js CHANGED
@@ -1,9 +1,32 @@
1
- #!/usr/bin/env bun
1
+ #!/usr/bin/env -S bun --smol
2
2
  // Plain JavaScript: refuse Node before it can load TypeScript or bun:sqlite.
3
3
  const version = /^(\d+)\.(\d+)\.(\d+)/.exec(process.versions.bun ?? "");
4
4
  if (!version || Number(version[1]) < 1 || (Number(version[1]) === 1 && (Number(version[2]) < 4 || (Number(version[2]) === 4 && Number(version[3]) < 2)))) {
5
5
  console.error("amem: Bun >=1.4.2 is required. Install Bun: https://bun.sh");
6
6
  process.exit(1);
7
7
  }
8
- const { main } = await import("../src/cli.ts");
9
- await main();
8
+ const args = process.argv.slice(2);
9
+ if (args[0] === "hook") {
10
+ // This narrow intake must stay fail-open even if loading the optional hook implementation fails.
11
+ try {
12
+ const absoluteStart = performance.timeOrigin + performance.now() - process.uptime() * 1000;
13
+ // Replace this bootstrap before importing stores/policy. One PID, stdin and process group survive;
14
+ // there is no third resident Bun process and the original invocation still owns the wall budget.
15
+ if (!process.execArgv.includes("--smol")) {
16
+ process.execve(process.execPath, [process.execPath, "--smol", ...process.execArgv, import.meta.path, ...args],
17
+ { ...process.env, AMEM_HOOK_BOOT_AT: String(absoluteStart) });
18
+ }
19
+ // Exec-only clock handoff: untrusted inherited values can only shorten via Math.min below.
20
+ // Clear it before any hook/store code or child launch; it cannot select a longer deadline.
21
+ const inheritedStart = Number(process.env.AMEM_HOOK_BOOT_AT);
22
+ delete process.env.AMEM_HOOK_BOOT_AT;
23
+ const now = performance.timeOrigin + performance.now();
24
+ const started = (Number.isFinite(inheritedStart) && inheritedStart > 0 && inheritedStart <= now ? Math.min(absoluteStart, inheritedStart) : absoluteStart) - performance.timeOrigin;
25
+ const { hookMain } = await import("../src/hooks.ts");
26
+ const output = await hookMain(args.slice(1), started);
27
+ if (output) console.log(output);
28
+ } catch { /* Context-only hooks never deny a native action. */ }
29
+ } else {
30
+ const { main } = await import("../src/cli.ts");
31
+ await main(args);
32
+ }
@@ -0,0 +1,81 @@
1
+ {
2
+ "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "command": "bun --smol \"${CLAUDE_PLUGIN_ROOT}/bin/amem.js\" hook SessionStart",
9
+ "timeout": 1
10
+ }
11
+ ]
12
+ }
13
+ ],
14
+ "UserPromptSubmit": [
15
+ {
16
+ "hooks": [
17
+ {
18
+ "type": "command",
19
+ "command": "bun --smol \"${CLAUDE_PLUGIN_ROOT}/bin/amem.js\" hook UserPromptSubmit",
20
+ "timeout": 1
21
+ }
22
+ ]
23
+ }
24
+ ],
25
+ "PreToolUse": [
26
+ {
27
+ "hooks": [
28
+ {
29
+ "type": "command",
30
+ "command": "bun --smol \"${CLAUDE_PLUGIN_ROOT}/bin/amem.js\" hook PreToolUse",
31
+ "timeout": 1
32
+ }
33
+ ]
34
+ }
35
+ ],
36
+ "PostToolUse": [
37
+ {
38
+ "hooks": [
39
+ {
40
+ "type": "command",
41
+ "command": "bun --smol \"${CLAUDE_PLUGIN_ROOT}/bin/amem.js\" hook PostToolUse",
42
+ "timeout": 1
43
+ }
44
+ ]
45
+ }
46
+ ],
47
+ "PostToolUseFailure": [
48
+ {
49
+ "hooks": [
50
+ {
51
+ "type": "command",
52
+ "command": "bun --smol \"${CLAUDE_PLUGIN_ROOT}/bin/amem.js\" hook PostToolUseFailure",
53
+ "timeout": 1
54
+ }
55
+ ]
56
+ }
57
+ ],
58
+ "Stop": [
59
+ {
60
+ "hooks": [
61
+ {
62
+ "type": "command",
63
+ "command": "bun --smol \"${CLAUDE_PLUGIN_ROOT}/bin/amem.js\" hook Stop",
64
+ "timeout": 1
65
+ }
66
+ ]
67
+ }
68
+ ],
69
+ "SessionEnd": [
70
+ {
71
+ "hooks": [
72
+ {
73
+ "type": "command",
74
+ "command": "bun --smol \"${CLAUDE_PLUGIN_ROOT}/bin/amem.js\" hook SessionEnd",
75
+ "timeout": 1
76
+ }
77
+ ]
78
+ }
79
+ ]
80
+ }
81
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/agent-mem",
3
- "version": "0.4.0",
3
+ "version": "0.6.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",
@@ -28,6 +28,8 @@
28
28
  "files": [
29
29
  "src/",
30
30
  "bin/",
31
+ ".claude-plugin/",
32
+ "hooks/",
31
33
  "README.md",
32
34
  "LICENSE"
33
35
  ],
package/src/cli.ts CHANGED
@@ -9,7 +9,7 @@ import { MemoryStore, looksSecret, machineId, openShardStore, type StoreOptions
9
9
  import { rawMemoryCommand, rawGoalCommand } from "./memory/commands.ts";
10
10
  import { memoryProjectKey } from "./memory/project.ts";
11
11
  import { CandidateStore, type CandidateFilter } from "./memory/candidates.ts";
12
- import { appendAlias, listAliases, readShardedSelection, resolveAlias } from "./memory/shard.ts";
12
+ import { aliasedKeys, appendAlias, appendUnalias, listAliases, readShardedSelection, resolveAlias } from "./memory/shard.ts";
13
13
  import { capacityReport, capacityText } from "./memory/capacity.ts";
14
14
  import { readCaptureConfigFile, writeCaptureConfigFile, type CaptureConfig } from "./memory/capture.ts";
15
15
 
@@ -70,12 +70,15 @@ function shardStatePresent(): boolean {
70
70
  catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; }
71
71
  }
72
72
 
73
- function curatedStore(project: string, opts: StoreOptions): MemoryStore {
73
+ /** `reconciled` (#27): the project filter also covers every key aliased to `project`; prune and other planners keep the exact key. */
74
+ function curatedStore(project: string, opts: StoreOptions, reconciled = true): MemoryStore {
75
+ const aliases = reconciled ? aliasedKeys(memoryHome(), project) : [];
76
+ const withAliases: StoreOptions = aliases.length ? { ...opts, reconcile: { canonical: project, aliases } } : opts;
74
77
  if (shardStatePresent()) {
75
78
  readShardedSelection(memoryHome());
76
- return openShardStore({ ...opts, project });
79
+ return openShardStore({ ...withAliases, project });
77
80
  }
78
- return new MemoryStore(opts);
81
+ return new MemoryStore(withAliases);
79
82
  }
80
83
 
81
84
  export async function runCli(argv: readonly string[], context: CliContext = {}): Promise<string> {
@@ -84,6 +87,22 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
84
87
  await serveMcp({ tools: memory.tools, close: memory.close, version: VERSION });
85
88
  return "";
86
89
  }
90
+ if (argv[0] === "hook") {
91
+ const { hookMain } = await import("./hooks.ts");
92
+ return hookMain(argv.slice(1));
93
+ }
94
+ if (argv[0] === "ingest") {
95
+ // Capture-only native intake: no project/home override, policy bypass or curated write capability.
96
+ const parsed = parseCommand(argv);
97
+ if (parsed.options["--help"] === true) return commandHelp();
98
+ const kind = parsed.positionals[0];
99
+ if (parsed.positionals.length !== 1 || !["codex", "kimi", "pi"].includes(kind ?? "")) throw new Error("usage: amem ingest codex|kimi|pi [--since ISO] [--json]");
100
+ const { ingestTranscript } = await import("./ingest.ts");
101
+ return JSON.stringify(await ingestTranscript(kind as "codex" | "kimi" | "pi", {
102
+ cwd: context.cwd ?? process.cwd(), base: memoryHome(context.env ?? process.env),
103
+ ...(typeof parsed.options["--since"] === "string" ? { since: parsed.options["--since"] as string } : {}),
104
+ }));
105
+ }
87
106
  if (agentMarked(context.env ?? process.env)) throw new Error(PERSON_ONLY);
88
107
  if (argv[0] === "--version" || argv[0] === "version") return `amem ${VERSION}`;
89
108
  if (argv[0] === "--help" || argv[0] === "help" || argv.includes("--help")) return commandHelp();
@@ -136,7 +155,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
136
155
  }
137
156
  const store = options["--user"] === true
138
157
  ? openShardStore({ memoryDir: memoryHome(), user: true, deferPromotions: true })
139
- : curatedStore(project, { deferPromotions: true });
158
+ : curatedStore(project, { deferPromotions: true }, false);
140
159
  try {
141
160
  const auth = { project, actor: "person" as const, authorize: () => true };
142
161
  return JSON.stringify(applying ? applyPrune(store.prunePort(), input, auth) : planPrune(store.prunePort(), { cutoff: String(options["--cutoff"]) }, auth), null, 2);
@@ -148,7 +167,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
148
167
  if (sub === "list" && options["--kind"] !== "memory" && resolveProfile(memoryHome()).kind === "generation") throw new Error("candidate review is not yet integrated with this generation; select --kind memory for the standalone memory candidate surface");
149
168
  const id = parsed.positionals[0];
150
169
  if (sub !== "list" && (!id || !/^[a-f0-9]{64}$/.test(id))) throw new Error("review accept/reject requires a full 64-character candidate id");
151
- const project = boundProject(), candidates = new CandidateStore();
170
+ const project = boundProject(), aliases = aliasedKeys(memoryHome(), project), candidates = new CandidateStore(aliases.length ? { reconcile: { canonical: project, aliases } } : {});
152
171
  try {
153
172
  if (sub === "list") {
154
173
  const rows = candidates.list({ project, ...(options["--status"] ? { status: options["--status"] as CandidateFilter["status"] } : {}),
@@ -219,8 +238,15 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
219
238
  const op = appendAlias(memoryHome(), { alias: from, key: to, actor: "person", machine: machineId(memoryHome()).id });
220
239
  return options["--json"] ? JSON.stringify(op, null, 2) : `alias ${op.alias} -> ${op.key}`;
221
240
  }
241
+ if (parsed.path[1] === "remove") {
242
+ // #27 option 2: person only (ordinary CLI refusal of agent markers above, a TTY through `mutates`), append only, the latest op wins.
243
+ const [from] = parsed.positionals;
244
+ if (!from || parsed.positionals.length !== 1) throw new Error("usage: amem alias remove <alias-key>");
245
+ const op = appendUnalias(memoryHome(), { alias: from, actor: "person", machine: machineId(memoryHome()).id });
246
+ return options["--json"] ? JSON.stringify(op, null, 2) : `alias ${op.alias} removed`;
247
+ }
222
248
  const ops = listAliases(memoryHome());
223
- return options["--json"] ? JSON.stringify(ops, null, 2) : ops.length ? ops.map((op) => `${op.at} ${op.alias} -> ${op.key} (${op.actor})`).join("\n") : "no aliases recorded";
249
+ return options["--json"] ? JSON.stringify(ops, null, 2) : ops.length ? ops.map((op) => op.op === "alias" ? `${op.at} ${op.alias} -> ${op.key} (${op.actor})` : `${op.at} ${op.alias} removed (${op.actor})`).join("\n") : "no aliases recorded";
224
250
  }
225
251
  // ponytail: board task links need the host's authenticated task boundary; add a host bridge before supporting them here.
226
252
  if (goal && sub === "link") throw new Error("goal link needs host integration; use ahub goal link");
@@ -1,3 +1,4 @@
1
+ import { HOOK_EVENTS } from "./hook-contract.ts";
1
2
  import { GOAL_STATES, KINDS } from "./memory/store.ts";
2
3
 
3
4
  export interface OptionDefinition { description: string; value?: "text" | "path" | readonly string[] }
@@ -22,6 +23,8 @@ export const GLOBAL_OPTIONS = { "--help": flag("Show help") };
22
23
  export const ROOT_OPTIONS = { "--version": flag("Show version"), ...json };
23
24
  export const COMMANDS: Record<string, CommandDefinition> = {
24
25
  mcp: { description: "Run the project-bound MCP stdio server for this session" },
26
+ hook: { description: "Run a fail-open, context-only Claude Code hook", args: [{ name: "event", values: [...HOOK_EVENTS] }] },
27
+ 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"] }] },
25
28
  "vault-candidates": { description: "List screened durable curated vault candidates", options: { ...json, "--limit": text("Maximum candidates (1..100)") } },
26
29
  list: { description: "List memory", options: filters },
27
30
  search: { description: "Search memory", options: filters, args: [{ name: "words", variadic: true }] },
@@ -75,6 +78,7 @@ export const COMMANDS: Record<string, CommandDefinition> = {
75
78
  alias: { description: "Person-reviewed project key aliases (#15 D2)", options: json, defaultCommand: "list", commands: {
76
79
  list: { description: "List alias ops" },
77
80
  add: { description: "Map one key onto its canonical key (append-only op)", args: [{ name: "from-key" }, { name: "to-key" }], mutates: true },
81
+ remove: { description: "Remove an alias so the key resolves to itself again (append-only op, the latest wins)", args: [{ name: "alias-key" }], mutates: true },
78
82
  } },
79
83
  completion: { description: "Print static shell completion", args: [{ name: "shell", values: ["zsh", "bash", "fish"] }] },
80
84
  help: { description: "Show help" }, version: { description: "Show version" },
@@ -0,0 +1,3 @@
1
+ export const HOOK_EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PostToolUseFailure", "Stop", "SessionEnd"] as const;
2
+ export type HookEvent = typeof HOOK_EVENTS[number];
3
+ export const HOOK_INPUT_BYTES = 64 * 1024, HOOK_CONTEXT_BYTES = 1200, HOOK_OUTPUT_BYTES = 8192;
@@ -0,0 +1,42 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstatSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { realPath } from "./memory/platform.ts";
5
+ import { memoryGitArgv, memoryGitEnv } from "./memory/platform.ts";
6
+ import type { ProjectGitRead } from "./memory/project.ts";
7
+
8
+ /** Synchronous compatibility port for library callers, with shared Git hardening and
9
+ * bounded output. Its deadline check precedes each read but cannot interrupt a blocked
10
+ * spawnSync. Native hook workers use resolveHookProjectAsync and hookNative instead,
11
+ * keeping the JS deadline responsive and the native self-watchdog independent of Bun. */
12
+ export function hookGit(timeRemaining: () => number, binary?: string): ProjectGitRead {
13
+ return (args, env) => {
14
+ const remaining = timeRemaining();
15
+ if (!Number.isFinite(remaining) || remaining <= 0) throw new Error("Git project identity unavailable");
16
+ const argv = memoryGitArgv(args); argv[0] = binary ?? hookGitBinary(env).file;
17
+ const result = Bun.spawnSync(argv, {
18
+ env: memoryGitEnv(env), stdin: "ignore", stdout: "pipe", stderr: "ignore", maxBuffer: 64 * 1024,
19
+ });
20
+ if (result.stdout.byteLength >= 64 * 1024) throw new Error("Git project identity unavailable");
21
+ return { status: result.exitCode, signal: result.signalCode ? String(result.signalCode) : undefined,
22
+ stdout: new TextDecoder("utf-8", { fatal: true }).decode(result.stdout) };
23
+ };
24
+ }
25
+
26
+ /** Only bypass Apple's selected /usr/bin/git shim, never a caller's custom PATH binary.
27
+ * Resolve the root-owned developer selection on disk, without launching xcrun on the hook deadline. */
28
+ export function hookGitBinary(env: NodeJS.ProcessEnv = process.env): { file: string; revision: string } {
29
+ let file = realPath(Bun.which("git", { PATH: env.PATH ?? process.env.PATH ?? "" }) ?? (() => { throw new Error("Git unavailable"); })());
30
+ if (process.platform === "darwin" && file === "/usr/bin/git" && !env.DEVELOPER_DIR) {
31
+ try {
32
+ const selected = "/var/db/xcode_select_link", link = lstatSync(selected);
33
+ if (link.isSymbolicLink() && link.uid === 0) {
34
+ const candidate = realPath(join(realPath(selected), "usr", "bin", "git")), stat = lstatSync(candidate);
35
+ if (stat.isFile() && stat.uid === 0 && (stat.mode & 0o111)) file = candidate;
36
+ }
37
+ } catch { /* A missing/unproven selection keeps the PATH-selected Git and its normal refusal. */ }
38
+ }
39
+ const stat = lstatSync(file);
40
+ if (!stat.isFile() || !(stat.mode & 0o111)) throw new Error("Git unavailable");
41
+ return { file, revision: createHash("sha256").update(`${file}:${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`).digest("hex") };
42
+ }
@@ -0,0 +1,92 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+
4
+ export interface HookNativeUsage { rssKB: number; rawRssKB: number }
5
+ // One native supervisor, with a native sleep watchdog rather than another Bun boot.
6
+ // The leader waits for its watchdog even after stdin EOF, so losing the calling CLI
7
+ // cannot let the owned command outlive the same remaining per-call deadline.
8
+ const SUPERVISOR = [
9
+ 'marker=$1; seconds=$2; shift 2; leader=$$',
10
+ // Fixed ps plus the unguessable invocation marker guards an externally killed/reused
11
+ // leader too. Normal completion never exits the leader before its group is reaped.
12
+ 'owned() { identity=$(/bin/ps -ww -p "$leader" -o pgid=,command=) || return 1; case "$identity" in *"$marker"*) ;; *) return 1 ;; esac; set -f; set -- $identity; [ "$1" = "$leader" ]; }',
13
+ 'reap() { if owned; then kill -KILL "-$leader"; fi; }',
14
+ '[ -x /bin/sleep ] && [ -x /bin/ps ] && owned || exit 125',
15
+ 'trap reap EXIT HUP INT TERM',
16
+ '( /bin/sleep "$seconds" & sleeper=$!; kill -0 "$sleeper" 2>/dev/null || { reap; exit 125; }; printf "\\n%s:ARMED\\n" "$marker"; wait "$sleeper"; reap ) & watchdog=$!',
17
+ 'kill -0 "$watchdog" 2>/dev/null || { reap; exit 125; }',
18
+ '"$@" </dev/null; code=$?; printf "\\n%s:%s\\n" "$marker" "$code"',
19
+ 'read -r ignored || :',
20
+ 'wait "$watchdog"',
21
+ ].join("; ");
22
+
23
+ export async function hookNative(argv: readonly string[], env: NodeJS.ProcessEnv, timeRemaining: () => number, usage: HookNativeUsage, signal?: AbortSignal): Promise<{ status: number; stdout: string }> {
24
+ if (signal?.aborted) throw new Error("native metadata cancelled");
25
+ const remaining = timeRemaining();
26
+ if (!Number.isFinite(remaining) || remaining <= 1) throw new Error("native metadata deadline");
27
+ const marker = `AMEM_META_${randomBytes(16).toString("hex")}`, prefix = `\n${marker}:`, armedFrame = `${prefix}ARMED\n`;
28
+ const child = Bun.spawn(["/bin/sh", "-c", SUPERVISOR, "amem-hook-native", marker, String(remaining / 1000), ...argv],
29
+ { env, detached: true, stdin: "pipe", stdout: "pipe", stderr: "ignore" });
30
+ let exited = false, ownPeak = 0;
31
+ const exit = child.exited.then(code => { exited = true; return code; });
32
+ const stop = () => {
33
+ if (exited) return;
34
+ if (process.platform === "linux") try {
35
+ const match = /^VmHWM:\s+(\d+)\s+kB$/m.exec(readFileSync(`/proc/${child.pid}/status`, "utf8"));
36
+ if (match && Number.isSafeInteger(Number(match[1]))) ownPeak = Number(match[1]);
37
+ } catch { /* Fall back conservatively to reaped rusage. */ }
38
+ try { process.kill(-child.pid, "SIGKILL"); } catch { try { child.kill("SIGKILL"); } catch { /* already exited */ } }
39
+ };
40
+ const reader = child.stdout.getReader();
41
+ let timer: ReturnType<typeof setTimeout> | undefined;
42
+ let aborted: (() => void) | undefined;
43
+ try {
44
+ const read = async () => {
45
+ let text = "", bytes = 0, armed = false;
46
+ const decoder = new TextDecoder("utf-8", { fatal: true });
47
+ for (;;) {
48
+ const next = await reader.read();
49
+ if (next.done) throw new Error("native metadata incomplete");
50
+ bytes += next.value.byteLength;
51
+ if (bytes > 64 * 1024 + 128) throw new Error("native metadata bound");
52
+ text += decoder.decode(next.value, { stream: true });
53
+ if (text.includes(armedFrame)) { text = text.replace(armedFrame, ""); armed = true; }
54
+ const at = text.indexOf(prefix);
55
+ if (at < 0) continue;
56
+ const code = /^(\d{1,3})\n/.exec(text.slice(at + prefix.length));
57
+ if (!code || !armed) continue;
58
+ const status = Number(code[1]);
59
+ if (status > 255 || Buffer.byteLength(text.slice(0, at)) >= 64 * 1024) throw new Error("native metadata invalid");
60
+ return { status, stdout: text.slice(0, at) };
61
+ }
62
+ };
63
+ return await Promise.race([read(), new Promise<never>((_, reject) => {
64
+ timer = setTimeout(() => { stop(); reject(new Error("native metadata deadline")); }, remaining);
65
+ }), new Promise<never>((_, reject) => {
66
+ aborted = () => { stop(); reject(new Error("native metadata cancelled")); };
67
+ signal?.addEventListener("abort", aborted, { once: true });
68
+ if (signal?.aborted) aborted();
69
+ })]);
70
+ } finally {
71
+ if (aborted) signal?.removeEventListener("abort", aborted);
72
+ if (timer) clearTimeout(timer);
73
+ stop();
74
+ // SIGKILL was delivered to every member before waiting for our owned leader.
75
+ // Init can briefly retain zombie grandchildren; they cannot execute, and only
76
+ // their adopting reaper can remove those PIDs (we cannot waitpid a non-child).
77
+ await exit;
78
+ try { child.stdin.end(); } catch { /* pipe closed with the owned leader */ }
79
+ await reader.cancel().catch(() => {}); reader.releaseLock();
80
+ const raw = (child.resourceUsage()?.maxRSS ?? 0) / 1024;
81
+ usage.rawRssKB = Math.max(usage.rawRssKB, raw);
82
+ usage.rssKB = Math.max(usage.rssKB, process.platform === "linux" ? ownPeak || raw : Math.max(ownPeak, raw));
83
+ }
84
+ }
85
+
86
+ /** Same pinned ps format/hash as the shared process identity helper, with bounded owned native work. */
87
+ export async function hookProcessSignature(timeRemaining: () => number, usage: HookNativeUsage): Promise<string> {
88
+ const result = await hookNative(["/bin/ps", "-p", String(process.pid), "-o", "lstart=,comm="],
89
+ { PATH: process.env.PATH ?? "", LC_ALL: "C", TZ: "UTC" }, timeRemaining, usage);
90
+ if (result.status !== 0 || !result.stdout.trim()) throw new Error("native process identity unavailable");
91
+ return createHash("sha256").update(result.stdout.trim()).digest("hex");
92
+ }