@staix/agent-mem 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +59 -35
- package/package.json +7 -3
- package/src/cli-agent-reads.ts +3 -1
- package/src/cli.ts +12 -1
- package/src/hook-jcode-journal.ts +153 -0
- package/src/hook-jcode.ts +13 -0
- package/src/hook-record.ts +91 -0
- package/src/hook-spool-replay.ts +10 -1
- package/src/hook-spool.ts +43 -10
- package/src/hook-transcript.ts +68 -4
- package/src/hooks.ts +36 -11
- package/src/index.ts +4 -0
- package/src/memory/capture-input.ts +10 -1
- package/src/memory/capture.ts +42 -2
- package/src/memory/config.ts +9 -2
- package/src/memory/generation.ts +6 -3
- package/src/memory/metrics.ts +4 -3
- package/src/memory/private-spans.ts +6 -3
- package/src/memory/profile-lock-diagnostic.ts +69 -0
- package/src/memory/reader-lease.ts +3 -2
- package/src/memory/repo-scope.ts +7 -1
- package/src/memory/search-score.ts +4 -2
- package/src/memory/shard.ts +4 -0
- package/src/memory/sync-placement-guard.ts +6 -2
- package/src/memory/sync-placement-prerequisites.ts +8 -1
- package/src/session-brief-worker.ts +1 -1
- package/src/session-brief.ts +21 -0
- package/src/setup-toml.ts +3 -2
- package/src/sync-placement-writer-context.ts +31 -7
package/README.md
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
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
|
-
|
|
5
|
+
<!-- facts:version -->
|
|
6
|
+
Version: 0.23.0. Requires Bun 1.4.2 or later; no runtime dependencies.
|
|
7
|
+
<!-- /facts:version -->
|
|
6
8
|
|
|
7
9
|
## Quick start
|
|
8
10
|
|
|
@@ -36,7 +38,7 @@ For Orca-managed Claude and Codex homes, setup writes the personal source that O
|
|
|
36
38
|
|
|
37
39
|
`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).
|
|
38
40
|
|
|
39
|
-
`amem setup jcode` registers MCP
|
|
41
|
+
`amem setup jcode` registers MCP and capture hooks (below), 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. On `turn_end`, 0.23.0 also captures bounded journal metadata as `Title` and `Compact` rows, independently of the ordinary assistant-text capture. It does not ingest persisted message bodies or snapshots. 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).
|
|
40
42
|
|
|
41
43
|
`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.
|
|
42
44
|
|
|
@@ -55,9 +57,9 @@ amem setup pi --apply
|
|
|
55
57
|
amem setup all --undo
|
|
56
58
|
```
|
|
57
59
|
|
|
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
|
|
60
|
+
`--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, Qwen and Gemini 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.
|
|
59
61
|
|
|
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
|
|
62
|
+
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, Qwen and Gemini 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
63
|
|
|
62
64
|
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).
|
|
63
65
|
|
|
@@ -110,13 +112,14 @@ The machine-local file `amem.config.json` in the memory home (see `amem status`
|
|
|
110
112
|
```
|
|
111
113
|
|
|
112
114
|
- `denyPaths`: substrings of a path that are never captured and that decline any note, candidate or MCP answer naming them. `piiPatterns`: case-insensitive literal substrings (never regular expressions) that decline a value and redact a capture excerpt. Each list holds up to 64 entries of up to 256 bytes.
|
|
115
|
+
- `records`: optional boolean, enabled by default. `false` disables the static native SessionStart instruction and new Claude `Record` extraction; a repository may disable it but cannot re-enable a machine opt-out. See [assistant records](#assistant-records-313).
|
|
113
116
|
- `briefBytes`, `excerptBytes`, `capturePaths` can only lower the built-in ceilings (the 1200-byte SessionStart context, 2000-character capture excerpt, 32 captured paths). `capture.capMb` and `capture.retentionDays` are the lowest layer of the capture size and retention bounds: a shard's own `config.json` and the home's `capture-config.json` still override them.
|
|
114
117
|
- A file with an unknown key, a bad value, a link, or more than 64 KiB is an error that names the file. An unreadable policy is never treated as no policy.
|
|
115
118
|
- `<private>...</private>` spans are dropped before anything else reads the text, at every write boundary (capture, MCP candidates and work lists, notes, goals, promotion and import), so they are never screened, counted or stored. Tags match like HTML (case-insensitive, with attributes or spaces, and `<private/>` opens a span); an unclosed span drops the rest of the text.
|
|
116
119
|
|
|
117
|
-
A repository may add `.amem/config` (same shape, only `denyPaths`, `piiPatterns`, `briefBytes`, `excerptBytes` and `
|
|
120
|
+
A repository may add `.amem/config` (same shape, only `denyPaths`, `piiPatterns`, `briefBytes`, `excerptBytes`, `capturePaths` and `records`). It can only narrow: its lists are added to yours and its budgets count only when lower. It cannot remove an entry, raise a budget, move the home or change the capture bounds, and any other key is an error (a malformed repository file stops amem in that repository until fixed; it never falls back to no policy). A stricter policy a host passes through the library always wins over both files.
|
|
118
121
|
|
|
119
|
-
`amem sync`
|
|
122
|
+
On non-placement profiles, `amem sync` screens this machine's owned outbound segments before staging. One declined row refuses the whole sync and names its id (never its text); the log remains append-only. On active placement v3, permitted originals can publish while declined rows receive sticky holds, subject to the policy and dependency checks described under [placement activation](#initial-sync-placement-activation). Malformed or unavailable source metadata still fails closed. Other machines' segments are not the source of the non-placement outbound preflight.
|
|
120
123
|
|
|
121
124
|
`--policy-free` is still accepted by `promote`, `import`, `sync` and `review accept` so existing scripts keep working, and it means nothing more: the configured policy always applies. It does not bypass the secret screen or the sync remote's privacy checks. Forgetting a shared note appends an operation; its previous content remains in the remote's git history.
|
|
122
125
|
|
|
@@ -145,19 +148,19 @@ Pending candidates include up to three overlapping current notes from the same p
|
|
|
145
148
|
|
|
146
149
|
JSON listing screens and tokenizes each current project note once for the whole candidate batch, retaining only three matches per candidate. The score divides shared terms by the smaller term set: a one- or two-term candidate can therefore score 1 against a longer note containing those terms. A score is an overlap hint, not a verdict that the notes are equivalent.
|
|
147
150
|
|
|
148
|
-
Candidates live in `candidates.db` beside the memory logs. This sidecar is machine-local: it is never synced, imported, or read by recall, briefs, ask or graph evidence. Hosts must supply an explicit public privacy verdict before putting candidates or counters; private, PII and unknown verdicts are refused.
|
|
151
|
+
Candidates live in `candidates.db` beside the memory logs. This sidecar is machine-local: it is never synced, imported, or read by recall, briefs, ask or graph evidence. Hosts must supply an explicit public privacy verdict before putting candidates or counters; private, PII and unknown verdicts are refused. The package supplies proposal-only MCP tools, on-demand consolidation and goal-promotion offers, and callback-confirmed MCP use counters. Candidate retention is 500 per project and 30 days; counters retain 10,000 per project and 90 days. Candidate payloads are capped at 16 KiB.
|
|
149
152
|
|
|
150
153
|
Acceptance creates ordinary v1 memory data. A correction retains its target's scope, project and storage reach, writes the corrected note and applies the existing `supersede` operation; its exact current target revision must match. A local target stays short-term; a synced target stays synced, including a demoted target whose effective tier is short. A fail becomes a long-term project note. The accepted text binds the candidate ID, target revision and target ID; a fail proposal without a target records `none` for both target fields. Both paths require the actual MemoryStore promotion screen to return `promote` at acceptance time; `keep`, `skip` or an absent screen refuses acceptance without falling back to short-term storage. Accepted v1 notes and operations follow the existing storage and sync rules; the candidate/counter sidecar remains local and outside evidence. No new v1 operation is introduced. Decay candidates are listing-only and cannot be accepted; a CLI caller may separately choose `amem demote` or `amem forget`. The sidecar receipt, note append and correction's supersede append are separate durable writes. An interrupted acceptance can leave a note without its supersede; it is held for explicit inspection and never replayed automatically.
|
|
151
154
|
|
|
152
155
|
The library entry exports `CandidateStore({ home?, now? })`, `put(input, verdict)`, `recordCounter(key, values, verdict)`, and `accept(id, { project, actor, verdict, store, policy })`. `home` retains the parent-home convention (`<home>/memory`); the default uses `memoryHome()`. The host must derive actor and peer from its session, bind the current project and privacy verdict, and supply a current acceptance policy plus the ordinary store's actual promotion screen. An `actor` string or caller-supplied public verdict alone is not authentication. Counter writes are idempotent snapshots for the same contextual key, not increments: observed `0` stays zero, while `null` means unknown (a later unknown value does not erase an observed value).
|
|
153
156
|
|
|
154
|
-
Open a review-only `MemoryStore` with `deferPromotions: true` to prevent unrelated automatic promotions during construction and reads. Explicit candidate acceptance still runs its promotion screen.
|
|
157
|
+
Open a review-only `MemoryStore` with `deferPromotions: true` to prevent unrelated automatic promotions during construction and reads. Explicit candidate acceptance still runs its promotion screen. The standalone MCP registers `amem_remember` and `amem_correct` as candidate tools. Additional host tools or capture observers must establish capture exclusion and authenticated producers before those integrations are enabled.
|
|
155
158
|
|
|
156
159
|
Acceptance normalizes its note once, including timestamp, content ID and short-term expiry, then screens and appends that exact body. The final target-revision check and correction appends hold the same machine-local `mutation-lock.db` writer transaction used by this version's ordinary memory, import and sync writers. This lock is independent of the derived index, so an ordinary write still persists when SQLite is busy. External or older writers that edit segments without this lock do not participate in serialization. The logs remain authoritative after an interrupted acceptance; the next read refreshes the derived index.
|
|
157
160
|
|
|
158
161
|
## Console (acon)
|
|
159
162
|
|
|
160
|
-
`@staix/agent-mem/ui-plugin` is a plugin for the local agent console `acon` (
|
|
163
|
+
`@staix/agent-mem/ui-plugin` is a plugin for the local agent console `acon` (<!-- facts:console-pin -->`@staix/agent-console` 0.1.3<!-- /facts:console-pin -->, contract api 1). It adds a Memory area with the panels Review, Recap, Search, Timeline, Work lists, Goals and Capacity, puts pending candidates (group `candidate`) and a capture capacity proposal (group `alert`) in the console inbox, and answers the console palette with memory search hits.
|
|
161
164
|
|
|
162
165
|
```sh
|
|
163
166
|
amem setup console # preview
|
|
@@ -171,7 +174,7 @@ The manifest holds the absolute path and sha256 of `src/ui/plugin.ts` (of the `-
|
|
|
171
174
|
- 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.
|
|
172
175
|
- Review shows the candidate kind, proposal, correction diff, stored evidence references, target-note quotes with dates and similar memories. Source sessions, proposing agents and times seen are not recorded by the candidate schema; the panel labels them unavailable rather than inferring them from capture data. An explicitly unavailable project is refused instead of falling back to another project.
|
|
173
176
|
- Review actions are the CLI's. Accept runs the same acceptance as `amem review accept` and writes the same note, edit-then-accept is `review walk`'s edit (the person's own note, then the candidate closed), reject is `review reject`. The console stamps the person as the actor (peer `person`). A privacy-screen hit refuses with a named reason and writes nothing. agent-mem keeps no reject reason, so a reason typed in the console is not recorded, and the reply says so.
|
|
174
|
-
- Undo
|
|
177
|
+
- Acon displays the Undo offer for 10 seconds; the plugin accepts a matching undo record for 60 seconds within its current provider instance. Undo stays bound to the candidate action: undoing an accept forgets the accepted note (an append-only forget op; a note a correction replaced shows again), undoing a reject makes the candidate pending again. A promotion has no undo. `merge_into` is not offered: the library has no merge operation.
|
|
175
178
|
- Destructive forms (`gc`, `capacity_apply`, `project_forget`, `decay_apply`, `prune`) answer `needsConfirm` with the preview and `changes[]` and never write on the first call. The console's single-use confirm token carries the fingerprint back; the plugin recomputes the preview and writes only when it still matches. `prune` needs a generation home, as `amem prune` does.
|
|
176
179
|
|
|
177
180
|
## Shell completion
|
|
@@ -238,13 +241,13 @@ The JSONL segments are authoritative. `index.db` is derived and can be rebuilt.
|
|
|
238
241
|
|
|
239
242
|
## Capture tier and full-text search
|
|
240
243
|
|
|
241
|
-
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
|
|
244
|
+
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`, then the `capture` section of `amem.config.json`; eviction removes the oldest capture rows first in bounded batches. The 2026-10-17 owner decision keeps the P1 cap per shard; `amem capacity apply --split` divides the machine-wide budget among the shards (#15 T5, see [Managing the project shards](#managing-the-project-shards-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/` (a historical unpublished layout) is migrated per project on open and removed once empty.
|
|
242
245
|
|
|
243
246
|
Claude's `PreToolUse`, `PostToolUse` and `PostToolUseFailure` excerpts start with a deterministic tool-input line before the existing content. String values use this key order: `command`, `pattern`, `query`, `url`, `description`, `prompt`, `skill`, `subject`, then other string fields in sorted key order. Bulk `content`, `old_string`, `new_string` and `edits` are omitted. Private spans are removed before clipping; the line is at most 512 UTF-8 bytes and the combined tool excerpt is at most 2,000 bytes or the lower configured `excerptBytes` limit. Existing envelope, secret/PII and path screens remain in force; a refused tool excerpt drops the row. Legacy rows and non-tool/other-client shaping are unchanged.
|
|
244
247
|
|
|
245
|
-
Selected-project `store.search` and `amem_search` first reserve the existing unweighted result's non-capture cohort and capture slots. Only the capture slots are refilled: `Stop` and `
|
|
248
|
+
Selected-project `store.search` and `amem_search` first reserve the existing unweighted result's non-capture cohort and capture slots. Only the capture slots are refilled: `Stop`, `UserPromptSubmit` and the transcript summary events (`Title`, `Recap`, `Compact`) receive a fixed 1.25x ordering weight, and only the best matching row per existing valid session hash is kept. A row without a hash is its own group. If distinct sessions run out, unused capture slots are omitted, yielding a shorter dense result while retaining the curated cohort and its relative order. Returned scores and explanations retain their unweighted priority values; the capture-slot ordering is separate. Collapsed rows remain accessible through timeline/show. The standalone `captureSearch` uses weighted session representatives too; `--all-projects` retains its existing separate merge. The SessionStart brief continues to use chronological capture pages, not this ranking or session collapse.
|
|
246
249
|
|
|
247
|
-
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; an explicitly written 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 append an alias operation without a general TTY gate. 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
|
|
250
|
+
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; an explicitly written 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 append an alias operation without a general TTY gate. 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 (`amem projects`, `usage`, `gc` and `project`) are #15 T5, described in [Managing the project shards](#managing-the-project-shards-15-t5).
|
|
248
251
|
|
|
249
252
|
`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`).
|
|
250
253
|
|
|
@@ -293,7 +296,7 @@ amem capacity apply --split [--confirm] # each shard's share of the global cap
|
|
|
293
296
|
- `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).
|
|
294
297
|
- amem lists vault candidates and never writes the vault.
|
|
295
298
|
|
|
296
|
-
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
|
|
299
|
+
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 enforces it too since #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).
|
|
297
300
|
|
|
298
301
|
## Claude Code hooks and SessionStart context
|
|
299
302
|
|
|
@@ -309,7 +312,7 @@ The packaged executable uses `#!/usr/bin/env -S bun --smol`, and plugin commands
|
|
|
309
312
|
|
|
310
313
|
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.
|
|
311
314
|
|
|
312
|
-
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.
|
|
315
|
+
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 memory context starts with an untrusted-data warning and per-section `complete`, `partial` or `unavailable` coverage, followed by JSON records. Native SessionStart may prepend the package-owned [Record instruction](#assistant-records-313) before that warning. 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.
|
|
313
316
|
|
|
314
317
|
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.
|
|
315
318
|
|
|
@@ -317,11 +320,11 @@ Pending capture has an ordinary soft tier of 256 files/2 MiB and a separate priv
|
|
|
317
320
|
|
|
318
321
|
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.
|
|
319
322
|
|
|
320
|
-
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
|
|
323
|
+
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 untrusted-data header inside `additionalContext`, not in new top-level fields. These are rolling-window counts, not all-time totals. The header stays before all memory data even when the brief is otherwise unavailable; the optional trusted Record instruction precedes it. The combined instruction and context stay within the same 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).
|
|
321
324
|
|
|
322
325
|
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.
|
|
323
326
|
|
|
324
|
-
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
|
|
327
|
+
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, with a 140 ms handler budget; the absolute 100 MB peak RSS was retired by #228 for a 150 MB per-invocation ceiling plus a per-runner baseline regression gate. 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.
|
|
325
328
|
|
|
326
329
|
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.
|
|
327
330
|
|
|
@@ -465,19 +468,26 @@ Run `amem mcp` as a stdio child of the agent CLI. It binds the working directory
|
|
|
465
468
|
|
|
466
469
|
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.
|
|
467
470
|
|
|
468
|
-
The
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
|
474
|
-
|
|
|
475
|
-
| `
|
|
476
|
-
| `
|
|
477
|
-
| `
|
|
478
|
-
| `
|
|
479
|
-
| `
|
|
480
|
-
| `
|
|
471
|
+
The MCP server exposes these tools. The nine-tool contract frozen for dotfiles#255 and maru#458 is the subset without `amem_evidence`, `amem_brief` and `amem_correct`.
|
|
472
|
+
|
|
473
|
+
<!-- facts:mcp-tools -->
|
|
474
|
+
12 tools, generated from `MCP_TOOLS` in `src/mcp.ts`:
|
|
475
|
+
|
|
476
|
+
| Tool | Read-only | Purpose |
|
|
477
|
+
| --- | --- | --- |
|
|
478
|
+
| `amem_search` | yes | Search screened memory in this project. |
|
|
479
|
+
| `amem_evidence` | yes | Return a bounded bundle of cited, live curated entries for a question. |
|
|
480
|
+
| `amem_show` | yes | Show a screened memory entry in this project by its exact id or unique prefix. |
|
|
481
|
+
| `amem_timeline` | yes | Read screened memory near an entry or time in this project. |
|
|
482
|
+
| `amem_brief` | yes | Read an intent/path-scoped brief of live public memory: fails first, then decisions/contracts and one-hop neighbours. |
|
|
483
|
+
| `amem_graph` | yes | Read the screened curated-memory neighbourhood. |
|
|
484
|
+
| `amem_work_state_read` | yes | Read project work lists; closed lists/items can be included explicitly. |
|
|
485
|
+
| `amem_work_state_write` | no | Append scalar project work-list fields; null clears, task selects an item, list status done closes. |
|
|
486
|
+
| `amem_remember` | no | Propose a memory candidate (text, optional kind/title), or kind=fail with approach, symptom, cause, evidenceRefs and retryWhen. |
|
|
487
|
+
| `amem_correct` | no | Propose a correction to an exact curated entry id visible in this project. |
|
|
488
|
+
| `amem_vault_candidates` | yes | List screened durable curated notes for review before moving them to a vault. |
|
|
489
|
+
| `amem_capacity` | yes | Read this machine's bounded capture capacity proposal; applying it requires the matching CLI preview and `--confirm`. |
|
|
490
|
+
<!-- /facts:mcp-tools -->
|
|
481
491
|
|
|
482
492
|
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.
|
|
483
493
|
|
|
@@ -497,17 +507,19 @@ General memory candidates are separate from curated evidence. `amem review accep
|
|
|
497
507
|
bun install --frozen-lockfile
|
|
498
508
|
bun x tsc --noEmit
|
|
499
509
|
bun scripts/check-boundary.ts
|
|
510
|
+
bun scripts/readme-facts.ts # README blocks between <!-- facts:<name> --> markers; --write regenerates them
|
|
511
|
+
bun scripts/check-docs.ts # review stamps in docs/verified.json; stale documents are warnings
|
|
500
512
|
bun test
|
|
501
513
|
npm pack --dry-run
|
|
502
514
|
```
|
|
503
515
|
|
|
504
|
-
CI runs the typecheck, boundary check and tests on Ubuntu and macOS with Bun 1.4.2. Tests use synthetic fixtures and isolated homes, not personal memory stores.
|
|
516
|
+
CI runs the typecheck, boundary check, README facts and documentation stamp checks and tests on Ubuntu and macOS with Bun 1.4.2. Tests use synthetic fixtures and isolated homes, not personal memory stores.
|
|
505
517
|
|
|
506
518
|
## Publishing
|
|
507
519
|
|
|
508
520
|
0.1.0 was published once by hand, because npm trusted publishing requires an existing package; the trusted publisher for repository `STAIxBWLB/agent-mem` and workflow `release.yml` was configured after it. Every later release goes through the tag workflow.
|
|
509
521
|
|
|
510
|
-
After setup, a `v*` tag
|
|
522
|
+
After setup, a `v*` tag first proves that the identical whole Git tree already has a successful same-repository `check` run with both OS gates; it does not rerun the test shards. Matching active CI is awaited within 25 minutes. The release then runs its two-OS version, main-ancestry, CHANGELOG, manifest, typecheck, boundary, README-facts, documentation-freshness and pack gates. The tag must equal `v` plus `package.json`'s version. Only after both gates pass does the release job install npm 11.20.0 (trusted publishing needs 11.5.1 or later) and run `npm publish --access public`, authenticated through GitHub OIDC with `id-token: write`; npm adds provenance itself. The workflow uses no `NPM_TOKEN`. Release checkouts pin the proven commit, and the tag object is rechecked before publication and Release edits. Manual release dispatch is proof-only and cannot publish; the [release reuse contract](docs/contracts.md#release-gate-reuse-302) states the last-read ceiling. The gates also refuse a stale documentation stamp (`bun scripts/check-docs.ts --require-fresh`). After the GitHub Release, a separate job sends a `repository_dispatch` to STAIxBWLB/agent-hub-site so its portal re-syncs; it is skipped without the `SITE_DISPATCH_TOKEN` secret and its failure does not fail the release.
|
|
511
523
|
|
|
512
524
|
License: MIT.
|
|
513
525
|
|
|
@@ -548,14 +560,26 @@ Sync validates all selected history and complete raw Git proof, and checks each
|
|
|
548
560
|
|
|
549
561
|
## Claude transcript summaries
|
|
550
562
|
|
|
551
|
-
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.
|
|
563
|
+
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. Lines without a native summary marker or `amem:` are skipped before JSON parsing. No network, model call or resident process is added.
|
|
552
564
|
|
|
553
|
-
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
|
|
565
|
+
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 0.18.0 and earlier hooks do not match. 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.
|
|
554
566
|
|
|
555
567
|
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.
|
|
556
568
|
|
|
557
|
-
Search gives these events the existing 1.25 conversation weight and session grouping.
|
|
569
|
+
Search gives these events the existing 1.25 conversation weight and session grouping. After eligible Records, the brief selects up to two newest screened main-session Recap/Compact rows, then fills from the existing chronological rows under the same record, byte and token caps. With no Records or summaries the output is unchanged. See [verification evidence](docs/bench/transcript-summaries-201.md).
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
## Assistant records (#313)
|
|
573
|
+
|
|
574
|
+
Since 0.23.0, native Claude SessionStart can prepend the package-owned `RECORD_INSTRUCTION` before the untrusted memory header, including on resume. Its three lines occupy 331 UTF-8 bytes, or 332 with the separator, reserved from the existing 1,200-byte context budget. The metric reports the actual emitted `recordInstructionBytes` and `recordInstructionEstimatedTokens` (83 when emitted, an approximation). The full block is omitted when it cannot fit; it never turns a failed or timed-out hook into valid context. Set `records: false` in the home's `amem.config.json` or the repository's `.amem/config` to disable the instruction and new extraction; false wins across both files.
|
|
575
|
+
|
|
576
|
+
At Stop/SessionEnd, complete native assistant text can yield `Record` capture events through the existing transcript scan. `parseRecordLines` and `RECORD_INSTRUCTION` are public root exports; the canonical grammar is maintained in `src/hook-record.ts`. The parser checks format and explicit text exclusions; hosts still apply their capture privacy policy before storage. It accepts the five kinds decision/fix/fail/contract/finding, repository-relative anchors and at most 300 text characters, excludes fenced/quoted examples, and requires the fix/fail relation. Missing native row time or stable `message.id` refuses extraction. At most two syntactically valid record lines are considered per stable message per scan call, with no persisted count. Whole records are declined on a denied anchor, policy refusal, truncation or redaction; no partial record is saved.
|
|
577
|
+
|
|
578
|
+
Records stay machine-local capture, not curated evidence or review candidates. They reuse Title/Recap/Compact staging, deterministic receipts and replay, with canonical line content and the native row timestamp; no Record-only receipt or capture-row schema is added. Source-row privacy flags are screened before capture, and replay refuses to clip or remove anchors from a pending Record when caps change. A slow replay opportunity cannot strand the second Record in the same transcript row. A crash after staging but before cursor save retains the existing duplicate window. A message-ID/ordinal receipt is a future upgrade if the AC6 duplicate experiment requires it. Record-to-candidate conversion and other ingest/Jcode paths remain outside this change; AC6 and real-home rollout are person-run checks.
|
|
579
|
+
|
|
580
|
+
The brief puts up to five recent, screened main-session Records bound to this project before summaries and raw capture lines, using the same remaining byte budget. Repository-relative anchors must match the stored paths; `-` is valid only for project-bound decision/contract Records. Subagent or malformed Records stay out. Large legacy stores read only the newest 128 rows until an off-hook search prepares the separate Record partial index; hooks do not build it above 128 rows. Search weights Record at 1.5 versus 1.25 for conversational capture without changing curated slots or session grouping. MCP preserves the canonical line and paths, and the existing all-project CLI capture display keeps the complete Record line and anchors.
|
|
558
581
|
|
|
582
|
+
Before relying on Record capture, upgrade every amem sharing the home or have the person append the existing `home` reader floor at 0.23.0. An old 0.22.1 hook drain silently discards a Record format-3 spool record without a counted loss, and its config parser refuses `records` as an unknown key, even when false. Set `records: false` only after every participating install is upgraded. Core never writes `formats.json`; a floor prevents old participation rather than teaching old readers the event.
|
|
559
583
|
|
|
560
584
|
## Initial sync placement activation
|
|
561
585
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@staix/agent-mem",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.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",
|
|
@@ -23,7 +23,11 @@
|
|
|
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
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
|
+
"@staix/agent-mem-v018": "npm:@staix/agent-mem@0.18.0",
|
|
27
|
+
"bun-types-host-1-4-2": "npm:bun-types@1.4.2",
|
|
28
|
+
"@staix/agent-mem-v0220": "npm:@staix/agent-mem@0.22.0",
|
|
29
|
+
"@types/bun-host-1-4-2": "npm:@types/bun@1.4.2",
|
|
30
|
+
"@staix/agent-mem-v0221": "npm:@staix/agent-mem@0.22.1"
|
|
27
31
|
},
|
|
28
32
|
"exports": {
|
|
29
33
|
".": "./src/index.ts",
|
|
@@ -42,6 +46,6 @@
|
|
|
42
46
|
"LICENSE"
|
|
43
47
|
],
|
|
44
48
|
"scripts": {
|
|
45
|
-
"check": "bun x tsc --noEmit && bun scripts/check-boundary.ts && bun test --timeout 20000"
|
|
49
|
+
"check": "bun x tsc --noEmit && bun scripts/check-boundary.ts && bun scripts/readme-facts.ts && bun scripts/check-docs.ts && bun test --timeout 20000"
|
|
46
50
|
}
|
|
47
51
|
}
|
package/src/cli-agent-reads.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { noteDisplayActor } from "./memory/cli-attribution.ts";
|
|
2
2
|
import { singleLine } from "./memory/style.ts";
|
|
3
3
|
import { createMemoryMcp } from "./mcp-memory.ts";
|
|
4
|
+
import { safeProfileLockMessage } from "./memory/profile-lock-diagnostic.ts";
|
|
4
5
|
import { memoryHome } from "./memory/home.ts";
|
|
5
6
|
import type { ParsedCommand } from "./commands-table.ts";
|
|
6
7
|
|
|
@@ -111,7 +112,8 @@ export async function agentCliRead(
|
|
|
111
112
|
return shown;
|
|
112
113
|
}
|
|
113
114
|
return output;
|
|
114
|
-
} catch {
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if(safeProfileLockMessage(error))throw error;
|
|
115
117
|
throw new Error(UNAVAILABLE);
|
|
116
118
|
} finally {
|
|
117
119
|
try { memory?.close(); } catch { /* close failures do not expose partial reads */ }
|
package/src/cli.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseRecordLines } from "./hook-record.ts";
|
|
1
2
|
import { assertFormatFloor } from "./memory/formats.ts";
|
|
2
3
|
import { placementRuntimeRefusal, readPlacementRuntimeMarkers } from "./memory/sync-placement-guard.ts";
|
|
3
4
|
import { activatePublicPruneView } from "./memory/public-prune-activation.ts";
|
|
@@ -12,6 +13,7 @@ import { createRecallStalenessProbe } from "./memory/recall-staleness.ts";
|
|
|
12
13
|
import { recordMemoryUse } from "./memory/use-telemetry.ts";
|
|
13
14
|
import { DecayApplyError } from "./memory/decay-review.ts";
|
|
14
15
|
import { createMemoryMcp } from "./mcp-memory.ts";
|
|
16
|
+
import { safeProfileLockMessage } from "./memory/profile-lock-diagnostic.ts";
|
|
15
17
|
import { MCP_TOOLS, serveMcp, writeConfirmed } from "./mcp.ts";
|
|
16
18
|
import { createStandalonePolicy } from "./memory/policy.ts";
|
|
17
19
|
import { configuredPolicy, configuredPolicyOptions, loadAmemConfig, repoConfigRoot } from "./memory/config.ts";
|
|
@@ -314,6 +316,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
314
316
|
}
|
|
315
317
|
// Data-free errors (decided before any store or file is opened) keep their own message.
|
|
316
318
|
if (marked && error instanceof AgentUsageError) throw error;
|
|
319
|
+
const recovery=marked?safeProfileLockMessage(error):undefined;if(recovery)throw new Error(recovery);
|
|
317
320
|
if (marked) {
|
|
318
321
|
// parseCommand is pure: when it rejects argv, the error it raised is a usage error, not memory data.
|
|
319
322
|
let usage: Error | undefined, parsed: ReturnType<typeof parseCommand> | undefined;
|
|
@@ -898,6 +901,14 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
|
|
|
898
901
|
const written = appendLifecycle(memoryHome(), { op: op as "archive" | "unarchive" | "forget", key, actor, machine: machineId(memoryHome()).id });
|
|
899
902
|
return options["--json"] ? JSON.stringify(written, null, 2) : `${op === "forget" ? "forgot" : op === "archive" ? "archived" : "unarchived"} ${written.key}${requested !== written.key ? ` (alias ${requested})` : ""}`;
|
|
900
903
|
}
|
|
904
|
+
// Only the existing cross-project capture renderer uses this; project search and agent MCP routing stay unchanged.
|
|
905
|
+
const captureSearchText = (capture: { event: string; excerpt: string; paths: string[] }): string => {
|
|
906
|
+
if (capture.event !== "Record") return capture.excerpt.split("\n")[0]!.slice(0, 100);
|
|
907
|
+
const parsed = parseRecordLines(capture.excerpt), record = parsed.records[0];
|
|
908
|
+
// Historical capture can contain arbitrary event text. Never render it as a canonical Record or inject another display line.
|
|
909
|
+
return record && parsed.records.length === 1 && Object.keys(parsed.drops).length === 0 && record.line === capture.excerpt
|
|
910
|
+
&& JSON.stringify(record.anchors) === JSON.stringify(capture.paths) ? record.line : "[invalid Record format or anchors; inspect --json output]";
|
|
911
|
+
};
|
|
901
912
|
if (sub === "search" && options["--all-projects"] === true) {
|
|
902
913
|
if (options["--explain"] === true) throw new Error("--explain applies to current-project ranked search, not --all-projects");
|
|
903
914
|
if (options["--filter"] === true) throw new Error("--filter applies to project-local search, not --all-projects");
|
|
@@ -905,7 +916,7 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
|
|
|
905
916
|
if (!words.trim()) throw new Error("usage: amem search --all-projects <words> [--limit N] [--include-archived] [--json]");
|
|
906
917
|
const result = searchAllProjects(memoryHome(), words, { ...(options["--limit"] !== undefined ? { limit: Number(options["--limit"]) } : {}), includeArchived: options["--include-archived"] === true });
|
|
907
918
|
if (options["--json"]) return JSON.stringify(result, null, 2);
|
|
908
|
-
const one = (hit: (typeof result.hits)[number]) => hit.view ? `${hit.project} ${hit.view.id.slice(0, 8)} ${hit.view.kind} ${(hit.view.title ?? hit.view.text).split("\n")[0]!.slice(0, 100)}` : `${hit.project} capture ${hit.capture!.id} ${hit.capture!.event}${hit.capture!.subagent ? " subagent" : ""} ${hit.capture
|
|
919
|
+
const one = (hit: (typeof result.hits)[number]) => hit.view ? `${hit.project} ${hit.view.id.slice(0, 8)} ${hit.view.kind} ${(hit.view.title ?? hit.view.text).split("\n")[0]!.slice(0, 100)}` : `${hit.project} capture ${hit.capture!.id} ${hit.capture!.event}${hit.capture!.subagent ? " subagent" : ""} ${captureSearchText(hit.capture!)}`;
|
|
909
920
|
return [`crossed projects: searched ${result.projects.length} (${result.projects.join(", ")})`, ...(result.hits.length ? result.hits.map(one) : ["(nothing)"])].join("\n");
|
|
910
921
|
}
|
|
911
922
|
if (sub === "search" && options["--include-archived"] === true) throw new Error("--include-archived only applies with --all-projects");
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { Database } from "bun:sqlite";
|
|
5
|
+
import { assertFormatFloor } from "./memory/formats.ts";
|
|
6
|
+
import { appendMetrics } from "./memory/metrics.ts";
|
|
7
|
+
import { configuredPolicyOptions } from "./memory/config.ts";
|
|
8
|
+
import { canonicalPath, realPath } from "./memory/platform.ts";
|
|
9
|
+
import { createStandalonePolicy, type StandalonePolicyOptions } from "./memory/policy.ts";
|
|
10
|
+
import { HookSpoolFull, stageTranscriptCapture } from "./hook-spool.ts";
|
|
11
|
+
import { cursorDatabase, HOOK_TRANSCRIPT_BYTES, privateDirectory } from "./hook-transcript.ts";
|
|
12
|
+
import type { HookTranscriptProgress } from "./hook-contract.ts";
|
|
13
|
+
|
|
14
|
+
// #216: jcode writes `<sessions>/<stem>.journal.jsonl`, one `{"meta":{...},"append_messages":[...]}` line per save. Only `meta.title` and
|
|
15
|
+
// `meta.compaction.summary_text` are read. Message bodies, reasoning and the snapshot are never parsed: the meta object is sliced out
|
|
16
|
+
// before `,"append_messages"` (an unescaped quote cannot occur inside a JSON string, so that marker cannot hide in text).
|
|
17
|
+
const hash = (value: string) => createHash("sha256").update(value).digest("hex");
|
|
18
|
+
const META = '{"meta":', BODIES = ',"append_messages"', TITLE_BYTES = 512;
|
|
19
|
+
const object = (value: unknown): Record<string, unknown> | undefined => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
20
|
+
const timestamp = (value: unknown) => typeof value === "string" && Number.isFinite(Date.parse(value)) ? new Date(value).toISOString() : undefined;
|
|
21
|
+
interface Cursor { offset: number; dev: string; ino: string; size: number; title: string; at: string; readStart: number; readAt: string; skipping: number }
|
|
22
|
+
/** `clock` is a test seam (default `performance.now`): the deadline and the work budget are both read from it. */
|
|
23
|
+
export interface JcodeJournalOptions { base: string; deadline: number; workBudgetMs?: number; policy?: StandalonePolicyOptions; clock?: () => number }
|
|
24
|
+
|
|
25
|
+
/** The meta object of one journal line, parsed without touching the message bodies. */
|
|
26
|
+
export function journalMeta(line: string): Record<string, unknown> | undefined {
|
|
27
|
+
if (!line.startsWith(META)) return undefined;
|
|
28
|
+
const end = line.indexOf(BODIES, META.length);
|
|
29
|
+
const text = end >= 0 ? line.slice(META.length, end) : line.endsWith("}") ? line.slice(META.length, -1) : "";
|
|
30
|
+
if (!text) return undefined;
|
|
31
|
+
try { return object(JSON.parse(text)); } catch { return undefined; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Stage a `Title` row when the journal's title changed and a `Compact` row for each compaction not yet seen, from at most 256 KiB of new
|
|
36
|
+
* journal bytes inside the caller's deadline. A private per-session cursor (device, inode, offset, last title hash, seen compactions)
|
|
37
|
+
* makes repeated turn_end calls and a checkpoint that replaces the journal (new inode) idempotent. Fails open: any fault is the caller's.
|
|
38
|
+
*/
|
|
39
|
+
export async function scanJcodeJournal(target: { cwd: string; session: string; journal: string }, options: JcodeJournalOptions): Promise<HookTranscriptProgress | undefined> {
|
|
40
|
+
if (options.policy?.host) return;
|
|
41
|
+
const clock = options.clock ?? (() => performance.now());
|
|
42
|
+
if (clock() >= options.deadline) return { stopReason: "deadline" };
|
|
43
|
+
const cwd = realPath(target.cwd), base = canonicalPath(options.base);
|
|
44
|
+
assertFormatFloor(base, "hook-spool");
|
|
45
|
+
const policy = createStandalonePolicy(configuredPolicyOptions(base, cwd, options.policy));
|
|
46
|
+
const session = hash(`amem-session\0${target.session}`).slice(0, 16);
|
|
47
|
+
if (!lstatSync(target.journal, { throwIfNoEntry: false })) return;
|
|
48
|
+
if (lstatSync(target.journal).isSymbolicLink()) throw new Error("jcode journal unavailable");
|
|
49
|
+
const journal = realPath(target.journal);
|
|
50
|
+
if (!await policy.screen({ transcript_path: journal })) return;
|
|
51
|
+
const fd = openSync(journal, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
52
|
+
let db: Database | undefined;
|
|
53
|
+
try {
|
|
54
|
+
const stat = fstatSync(fd), named = lstatSync(target.journal);
|
|
55
|
+
if (!stat.isFile() || stat.nlink !== 1 || named.isSymbolicLink() || stat.dev !== named.dev || stat.ino !== named.ino) return;
|
|
56
|
+
const dir = join(base, "hook-transcripts"); privateDirectory(dir);
|
|
57
|
+
db = cursorDatabase(join(dir, `jcode-${session}.db`), String(stat.dev), String(stat.ino), stat.size); db.run("BEGIN IMMEDIATE");
|
|
58
|
+
const cursor = db.query("SELECT * FROM cursor WHERE id=1").get() as Cursor;
|
|
59
|
+
if (!Number.isSafeInteger(cursor.offset) || cursor.offset < 0 || !Number.isSafeInteger(cursor.size) || cursor.size < 0 || !timestamp(cursor.readAt)
|
|
60
|
+
|| ![0, 1].includes(cursor.skipping) || !/^(?:[a-f0-9]{64})?$/.test(cursor.title)) throw new Error("jcode journal cursor unavailable");
|
|
61
|
+
// A new inode, or a file shorter than what was read, is a checkpoint replacement: start over; seen title/compactions suppress repeats.
|
|
62
|
+
if (cursor.dev !== String(stat.dev) || cursor.ino !== String(stat.ino) || stat.size < cursor.size || stat.size < cursor.offset) { cursor.offset = 0; cursor.skipping = 0; }
|
|
63
|
+
// #216 review (codex P2): read a bounded window FORWARD from the cursor. Jumping to the tail would discard earlier compactions that a
|
|
64
|
+
// later summary superseded, so a backlog is worked off over successive turns (256 KiB each) and no complete record is dropped.
|
|
65
|
+
const from = cursor.offset, partial = cursor.skipping === 1;
|
|
66
|
+
let oversized = 0;
|
|
67
|
+
const bytes = Buffer.alloc(Math.min(HOOK_TRANSCRIPT_BYTES, Math.max(0, stat.size - from)));
|
|
68
|
+
const length = readSync(fd, bytes, 0, bytes.length, from);
|
|
69
|
+
const after = fstatSync(fd), live = lstatSync(target.journal);
|
|
70
|
+
if (length !== bytes.length || after.size < stat.size || live.dev !== stat.dev || live.ino !== stat.ino || realPath(target.journal) !== journal) return;
|
|
71
|
+
const workStart = clock();
|
|
72
|
+
const due = () => clock() >= options.deadline || options.workBudgetMs !== undefined && clock() - workStart >= options.workBudgetMs;
|
|
73
|
+
let unpublished = false, start = 0, skipping = partial, stopReason: HookTranscriptProgress["stopReason"], newestLine: string | undefined;
|
|
74
|
+
const compactions: { covers: unknown; text: string; at: string | undefined; subagent: boolean; dir: unknown }[] = [];
|
|
75
|
+
// #216 review (codex P2): a row belongs to the project of its own line. A journal of a session that moved between projects carries other
|
|
76
|
+
// `meta.working_dir` values; those rows are not staged under this hook's cwd. A missing working_dir keeps the hook's cwd (unchanged).
|
|
77
|
+
const owned = new Map<string, boolean>();
|
|
78
|
+
const ownsDir = (dir: unknown): boolean => {
|
|
79
|
+
if (dir === undefined || dir === null) return true;
|
|
80
|
+
if (typeof dir !== "string" || !dir) return false;
|
|
81
|
+
let known = owned.get(dir);
|
|
82
|
+
if (known === undefined) { try { known = realPath(dir) === cwd; } catch { known = false; } owned.set(dir, known); }
|
|
83
|
+
return known;
|
|
84
|
+
};
|
|
85
|
+
const seen = (digest: string) => !!db!.query("SELECT 1 FROM fallback WHERE digest=?").get(digest);
|
|
86
|
+
while (start < length && !due()) {
|
|
87
|
+
const newline = bytes.indexOf(10, start);
|
|
88
|
+
if (skipping) { start = newline < 0 ? length : newline + 1; if (newline >= 0) skipping = false; continue; }
|
|
89
|
+
if (newline < 0) {
|
|
90
|
+
// A line with no end inside a full window is bounded-skipped and counted; a short unfinished tail waits for the next turn.
|
|
91
|
+
if (start === 0 && length >= HOOK_TRANSCRIPT_BYTES) { skipping = true; start = length; oversized++; }
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
let line: string;
|
|
95
|
+
try { line = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(start, newline)); } catch { start = newline + 1; continue; }
|
|
96
|
+
start = newline + 1;
|
|
97
|
+
if (!line.startsWith(META)) continue;
|
|
98
|
+
const metaEnd = line.indexOf(BODIES, META.length), head = metaEnd >= 0 ? line.slice(0, metaEnd) : line;
|
|
99
|
+
if (head.includes('"compaction":{')) {
|
|
100
|
+
const meta = journalMeta(line), compaction = object(meta?.compaction);
|
|
101
|
+
if (meta && compaction && typeof compaction.summary_text === "string" && compaction.summary_text.trim()) {
|
|
102
|
+
compactions.push({ covers: compaction.covers_up_to_turn, text: compaction.summary_text, at: timestamp(meta.updated_at), subagent: typeof meta.parent_id === "string", dir: meta.working_dir });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
newestLine = line; // every line repeats the current meta: only the newest one decides the title
|
|
106
|
+
}
|
|
107
|
+
if (!stopReason && start < length && due()) stopReason = "deadline";
|
|
108
|
+
// The newest line's meta carries the current title.
|
|
109
|
+
const newest = newestLine === undefined ? undefined : journalMeta(newestLine);
|
|
110
|
+
const stage = async (event: "Title" | "Compact", text: string, at: string | undefined, subagent: boolean, identity: string) => {
|
|
111
|
+
const publish = () => stageTranscriptCapture({ agent: "jcode", event, excerpt: text, at: at ?? cursor.readAt, session, ...(subagent ? { subagent: true } : {}) },
|
|
112
|
+
{ cwd, base, policy: options.policy, deferAtCapacity: true }, identity);
|
|
113
|
+
await publish();
|
|
114
|
+
};
|
|
115
|
+
try {
|
|
116
|
+
for (const compaction of compactions) {
|
|
117
|
+
// #216 review (codex P1): the work budget covers publication too. Stop before the next publish; the cursor does not pass the
|
|
118
|
+
// unpublished lines (see the offset below) and the seen digests make the next call publish only the rest, exactly once.
|
|
119
|
+
if (!ownsDir(compaction.dir)) continue;
|
|
120
|
+
const digest = hash(JSON.stringify(["jcode-compact", compaction.covers ?? null, compaction.text]));
|
|
121
|
+
if (seen(digest)) continue; // already published: costs a lookup, never budget, so a long journal cannot starve its own tail
|
|
122
|
+
if (due()) { stopReason = "deadline"; unpublished = true; break; }
|
|
123
|
+
await stage("Compact", compaction.text, compaction.at, compaction.subagent, `compact:${digest}`);
|
|
124
|
+
// A refused row (secret, deny, policy) is final; a full spool throws before this point and is retried next turn.
|
|
125
|
+
db.query("INSERT OR IGNORE INTO fallback VALUES (?,?)").run(digest, cursor.readAt);
|
|
126
|
+
}
|
|
127
|
+
const title = typeof newest?.title === "string" ? newest.title.trim() : "";
|
|
128
|
+
// The newest line decides the title only once the scan has reached the end of the file; mid-backlog it would stage stale titles.
|
|
129
|
+
const atEnd = from + start >= stat.size && !skipping;
|
|
130
|
+
const titleDue = atEnd && !!title && ownsDir(newest?.working_dir) && Buffer.byteLength(title) <= TITLE_BYTES * 4 && hash(title) !== cursor.title;
|
|
131
|
+
if (unpublished) { /* the title waits for the next call with the compactions */ }
|
|
132
|
+
else if (titleDue && due()) { stopReason = "deadline"; unpublished = true; } // keep the cursor behind the title line too
|
|
133
|
+
else if (titleDue) {
|
|
134
|
+
await stage("Title", title, timestamp(newest?.updated_at), typeof newest?.parent_id === "string", `title:${hash(title)}`);
|
|
135
|
+
cursor.title = hash(title);
|
|
136
|
+
}
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (!(error instanceof HookSpoolFull)) throw error;
|
|
139
|
+
stopReason = "spool_full"; start = 0; // nothing is acknowledged; the same window is read again next turn
|
|
140
|
+
}
|
|
141
|
+
if (oversized) appendMetrics(base, [{ at: new Date().toISOString(), kind: "hook", captureDrops: oversized, captureDropReason: "transcript_line_bound" }]);
|
|
142
|
+
assertFormatFloor(base, "hook-spool");
|
|
143
|
+
const offset = stopReason === "spool_full" || unpublished ? cursor.offset : from + start;
|
|
144
|
+
db.query("UPDATE cursor SET offset=?,dev=?,ino=?,size=?,title=?,at=?,skipping=? WHERE id=1")
|
|
145
|
+
.run(offset, String(stat.dev), String(stat.ino), stat.size, cursor.title, cursor.at, skipping ? 1 : 0);
|
|
146
|
+
db.run("COMMIT");
|
|
147
|
+
return { offset, ...(stopReason ? { stopReason } : {}) };
|
|
148
|
+
} finally {
|
|
149
|
+
if (db) try { db.run("ROLLBACK"); } catch { /* committed */ }
|
|
150
|
+
db?.close();
|
|
151
|
+
closeSync(fd);
|
|
152
|
+
}
|
|
153
|
+
}
|
package/src/hook-jcode.ts
CHANGED
|
@@ -23,6 +23,19 @@ function insideRepository(cwd: string): boolean {
|
|
|
23
23
|
return false;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
const SESSION_STEM = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,254}$/;
|
|
27
|
+
/** #216: where this turn's session journal is and which project directory owns its rows. No scan of the sessions directory:
|
|
28
|
+
* the journal is `<JCODE_HOME or ~/.jcode>/sessions/<JCODE_HOOK_SESSION_ID>.journal.jsonl`. Undefined skips the scan. */
|
|
29
|
+
export function jcodeJournalTarget(env: Env, home: string): { cwd: string; session: string; journal: string } | undefined {
|
|
30
|
+
const cwd = env.JCODE_HOOK_CWD, session = env.JCODE_HOOK_SESSION_ID;
|
|
31
|
+
if (env.JCODE_HOOK_EVENT !== "turn_end" || !bounded(cwd) || !cwd || !isAbsolute(cwd) || /[\x00-\x1f]/.test(cwd) || !session || !SESSION_STEM.test(session) || session.includes("..")) return undefined;
|
|
32
|
+
try { if (!lstatSync(cwd).isDirectory()) return undefined; } catch { return undefined; }
|
|
33
|
+
if (!insideRepository(cwd)) return undefined;
|
|
34
|
+
const root = env.JCODE_HOME !== undefined ? env.JCODE_HOME : join(home, ".jcode");
|
|
35
|
+
if (!isAbsolute(root) || /[\x00-\x1f]/.test(root)) return undefined;
|
|
36
|
+
return { cwd, session, journal: join(root, "sessions", `${session}.journal.jsonl`) };
|
|
37
|
+
}
|
|
38
|
+
|
|
26
39
|
/** Maps one jcode observer environment onto the Claude-shaped hook input the spool already screens and normalizes.
|
|
27
40
|
* Anything missing, oversized or inconsistent returns undefined: the event is skipped, never guessed. */
|
|
28
41
|
export function jcodeHookCapture(env: Env): JcodeHookCapture | undefined {
|