[DRAFT] chore: synchronize canonical upstream v0.19.0 #2

Closed
clawlter wants to merge 228 commits from audit/upstream-sync into main
Owner

Summary

  • Synchronizes the preparation fork from b8d934b (v0.9.2) to canonical stephenschoettler/hermes-lcm 2d8385614bbc384769036d7fd11c5f70aa8ccca2 (v0.19.0): 224 upstream commits, zero fork-only commits.
  • Uses a signed, history-preserving merge commit; the merge tree is byte-for-byte equivalent to canonical upstream. No shared history was force-pushed or rewritten.
  • Corrects two importer regression assertions so that a protected legacy data: payload is required to round-trip with its existing legacy prefix. This is a test-only correction; it preserves the upstream runtime behavior and should be offered upstream separately.
  • Adds CI remediation commit 7daa69c: the static validation job now provisions Go before installing actionlint, and the slow pytest matrix runs two deterministic, complete shards.

Compatibility and risk review

The sync carries upstream changes to the plugin contract (plugin.yaml now v0.19.0), configuration/engine behavior, operational scripts, and CI/release workflows. The full suite exercises the declared tool schema and engine contract. No live Hermes plugin, configuration, state database, gateway, or user session was changed by this audit.

The pre-existing PR #1 (fix/preserve-active-user-objective) is superseded in substance by upstream commit c626d24 (fix: preserve latest user objective during compaction (#132)). It should be closed rather than merged after this synchronization PR lands, unless review identifies a material downstream-only difference.

Validation

  • python3 -m pytest tests/ -q1605 passed, 12 xfailed (43 existing Python 3.13 import-layout deprecation warnings)
  • CI shard rehearsal: python3 scripts/run_test_shard.py --index 0 --total 2786 passed, 6 xfailed; shard 2 — 819 passed, 6 xfailed. The runner preserves pytest collection order and ignores runner-global Git configuration so Git-LFS hooks cannot contaminate fixture repositories.
  • /tmp/hermes-lcm-review-bin/actionlint .github/workflows/*.yml (actionlint v1.7.7) — passed
  • python3 -m compileall -q .
  • python3 -m py_compile scripts/import_lossless_claw.py scripts/run_test_shard.py
  • bash -n scripts/install.sh scripts/update.sh
  • git diff --check
  • Both commits have verified GPG signatures.

The checkout does not contain a runnable hermes executable, so a live-host integration probe was intentionally not fabricated; the upstream CI-compatible agent.context_engine.ContextEngine contract stub was used for the local test suite.

CI remediation and post-fix run

  • static-validation provisions actions/setup-go at immutable commit 40f1582b2485089dde7abd97c1529aa768e1baff (v5.6.0) before go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.7; this directly fixes the prior go: command not found failure.
  • Each Python version has two test jobs, retaining the existing ulimit -n 1024 protection. The partition is SHA-256 based, non-empty, and its union is the collected test set.
  • Post-fix Forgejo CI: run #7 is queued in waiting state as of this update. It has not been represented as a passing run; the branch is ready for review once a runner dispatches it.

Migration, rollback, and deployment notes

  1. Before deploying the merged plugin, take the normal lcm_backup/SQLite snapshot and retain externalized payload storage.
  2. Upgrade through the normal plugin installation path; do not copy or delete state files manually. The focused tests cover backup-first import/doctor operations, rollback after deletion failure, refusal of newer schemas before writable DDL, and failed rotation backup preservation.
  3. A gateway restart or /reset is required only when deploying the merged plugin to load new code; neither was performed here.
  4. Roll back code by redeploying the current pre-sync main commit b8d934b58f43b54622d56a164488849cd05e4780; restore the pre-deploy SQLite snapshot if state recovery is required. Do not downgrade a database whose schema guard reports it is newer than the running plugin.
  5. To roll back only the CI remediation, revert 7daa69cfb26890d64f90006d9d6dd83e098da0b0; no runtime release artifact or state migration is involved.

Fork policy recommendation

Treat this repository as a preparation fork, not an independent distribution: keep main content-equivalent to canonical upstream and import upstream via signed PRs. Keep only narrowly justified, documented downstream changes on review branches, upstream them promptly, and remove them once accepted. Require a fresh compatibility/backup audit for any upstream release with configuration, schema, plugin-contract, or operational-script changes.

Merge sequence: wait for Forgejo CI to conclude successfully, review this draft, rebase/fast-forward through the protected PR workflow, then close PR #1 as superseded. Do not direct-push or force-push main.

## Summary - Synchronizes the preparation fork from `b8d934b` (v0.9.2) to canonical [`stephenschoettler/hermes-lcm`](https://github.com/stephenschoettler/hermes-lcm) `2d8385614bbc384769036d7fd11c5f70aa8ccca2` (v0.19.0): 224 upstream commits, zero fork-only commits. - Uses a signed, history-preserving merge commit; the merge tree is byte-for-byte equivalent to canonical upstream. No shared history was force-pushed or rewritten. - Corrects two importer regression assertions so that a protected legacy `data:` payload is required to round-trip with its existing `legacy ` prefix. This is a test-only correction; it preserves the upstream runtime behavior and should be offered upstream separately. - Adds CI remediation commit [`7daa69c`](https://code.mehalter.com/clawlter/hermes-lcm/commit/7daa69cfb26890d64f90006d9d6dd83e098da0b0): the static validation job now provisions Go before installing actionlint, and the slow pytest matrix runs two deterministic, complete shards. ## Compatibility and risk review The sync carries upstream changes to the plugin contract (`plugin.yaml` now v0.19.0), configuration/engine behavior, operational scripts, and CI/release workflows. The full suite exercises the declared tool schema and engine contract. No live Hermes plugin, configuration, state database, gateway, or user session was changed by this audit. The pre-existing PR #1 (`fix/preserve-active-user-objective`) is superseded in substance by upstream commit `c626d24` (`fix: preserve latest user objective during compaction (#132)`). It should be closed rather than merged after this synchronization PR lands, unless review identifies a material downstream-only difference. ## Validation - `python3 -m pytest tests/ -q` — **1605 passed, 12 xfailed** (43 existing Python 3.13 import-layout deprecation warnings) - CI shard rehearsal: `python3 scripts/run_test_shard.py --index 0 --total 2` — **786 passed, 6 xfailed**; shard 2 — **819 passed, 6 xfailed**. The runner preserves pytest collection order and ignores runner-global Git configuration so Git-LFS hooks cannot contaminate fixture repositories. - `/tmp/hermes-lcm-review-bin/actionlint .github/workflows/*.yml` (actionlint v1.7.7) — passed - `python3 -m compileall -q .` - `python3 -m py_compile scripts/import_lossless_claw.py scripts/run_test_shard.py` - `bash -n scripts/install.sh scripts/update.sh` - `git diff --check` - Both commits have verified GPG signatures. The checkout does not contain a runnable `hermes` executable, so a live-host integration probe was intentionally not fabricated; the upstream CI-compatible `agent.context_engine.ContextEngine` contract stub was used for the local test suite. ## CI remediation and post-fix run - `static-validation` provisions `actions/setup-go` at immutable commit `40f1582b2485089dde7abd97c1529aa768e1baff` (v5.6.0) before `go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.7`; this directly fixes the prior `go: command not found` failure. - Each Python version has two test jobs, retaining the existing `ulimit -n 1024` protection. The partition is SHA-256 based, non-empty, and its union is the collected test set. - Post-fix Forgejo CI: [run #7](https://code.mehalter.com/clawlter/hermes-lcm/actions/runs/7) is queued in `waiting` state as of this update. It has not been represented as a passing run; the branch is ready for review once a runner dispatches it. ## Migration, rollback, and deployment notes 1. Before deploying the merged plugin, take the normal `lcm_backup`/SQLite snapshot and retain externalized payload storage. 2. Upgrade through the normal plugin installation path; do not copy or delete state files manually. The focused tests cover backup-first import/doctor operations, rollback after deletion failure, refusal of newer schemas before writable DDL, and failed rotation backup preservation. 3. A gateway restart or `/reset` is required only when deploying the merged plugin to load new code; neither was performed here. 4. Roll back code by redeploying the current pre-sync `main` commit `b8d934b58f43b54622d56a164488849cd05e4780`; restore the pre-deploy SQLite snapshot if state recovery is required. Do not downgrade a database whose schema guard reports it is newer than the running plugin. 5. To roll back only the CI remediation, revert `7daa69cfb26890d64f90006d9d6dd83e098da0b0`; no runtime release artifact or state migration is involved. ## Fork policy recommendation Treat this repository as a preparation fork, not an independent distribution: keep `main` content-equivalent to canonical upstream and import upstream via signed PRs. Keep only narrowly justified, documented downstream changes on review branches, upstream them promptly, and remove them once accepted. Require a fresh compatibility/backup audit for any upstream release with configuration, schema, plugin-contract, or operational-script changes. **Merge sequence:** wait for Forgejo CI to conclude successfully, review this draft, rebase/fast-forward through the protected PR workflow, then close PR #1 as superseded. Do not direct-push or force-push `main`.
Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
Treat KeyboardInterrupt during the session-end raw-message ingest/finalization path as best-effort cleanup failure instead of surfacing a shutdown traceback. Add regression tests for ingest and lifecycle finalization interrupts.
* chore: release v0.9.3

* docs: refresh release references

* fix: restore filtered grep and carried source expansion

---------

Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
* fix: add period to _FTS5_SPECIAL_CHARS to prevent FTS5 syntax errors on version-style queries

* test: cover period-style FTS queries

---------

Co-authored-by: socials-adnw <socials-adnw@adnw.uk>
Co-authored-by: Voscko <Tosko4@users.noreply.github.com>
Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
* chore: release v0.9.4

* docs: refresh release references
Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
Co-authored-by: Stephen Schoettler <stephenschoettler@gmail.com>
Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
Co-authored-by: Stephen Schoettler <stephenschoettler@gmail.com>
* chore: release v0.10.0

* docs: refresh release references
* ci: update actions to Node 24 runtimes

* fix: close sqlite handles deterministically

* ci: quote actionlint path
Adds README guidance for tuning LCM on large context windows and clarifies default leaf compaction behavior versus dynamic chunk-sized passes.
* fix: clean unsafe assistant content during assembly

* fix: reconcile sanitized active replay after rebind

* fix: avoid no-compaction tool stubs

* fix: strip mixed internal assistant content

* fix: reconcile stripped assistant replay

* fix: strip assistant tool-call replay content

* fix: map stripped replay to original store rows

* fix: preserve repeated suffix deltas after cleanup

* fix: guard cleanup-sensitive replay reconciliation

---------

Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
* chore: release v0.10.1

* docs: refresh release references
Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
* chore: release v0.10.2

* docs: refresh release references
Co-authored-by: batumilove <batumilove@users.noreply.github.com>
* Harden LCM ingest against inline media/base64 payloads

* fix: protect payload-bearing JSON keys

* fix: sanitize externalized payload placeholder metadata

* fix: detect base64url payload runs during ingest

* fix: detect escaped data URI payloads in raw argument scans

* fix: reserialize restored tool-call replay payloads

* fix: keep ingest placeholder refs visible in expansion

* test: cover long embedded ingest markers in expansion

* fix: reserialize structured content replay identity

* fix: handle unicode escaped data URI slashes

* fix: preserve JSON argument scaffolds

---------

Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
When LCM_HERMES_BASE_DIR is set, all paths (hermes_home and configured
externalization paths) are validated against that base directory to
prevent directory escape via ../ sequences.

Changes:
- Path resolution with .resolve() for symlink and ../ normalization
- Optional containment check guarded by LCM_HERMES_BASE_DIR env var
- Backward compatible: no restriction when env var is unset
- Validation of configured externalization paths against allowed base
- 9 new tests: path_containment, path_resolution, path_security

Fixes pre-existing issues:
- Missing threading imports in engine.py and store.py
- Future annotations for forward references in externalize.py
- Agent/ directory in .gitignore

Co-authored-by: batumilove <189313529+batumilove@users.noreply.github.com>
Co-authored-by: Stephen Schoettler <stephenschoettler@gmail.com>
* fix: refresh plugin metadata from manifest on status reads

* test: cover hot-reload plugin metadata fallback

---------

Co-authored-by: Stephen Schoettler <stephenschoettler@gmail.com>
* chore: release v0.10.3

* docs: refresh release references
* chore: release v0.10.4

* docs: refresh release references
* fix: keep model-switch context length authoritative

* fix: preserve model identity on same-window switches

* fix: clear missing session context windows

* fix: preserve resolved window for blank telemetry

* fix: expire model update window after binding

* fix: keep zero model windows authoritative

---------

Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
* feat: add /lcm rotate for in-place tail-preserving session compaction

Adds /lcm rotate and /lcm rotate apply on the existing slash-command
surface. Rotate advances the lifecycle frontier marker past pre-tail raw
messages so they are no longer replayed into active context, without
changing session_id or conversation_id. Raw messages remain in the
SQLite store and stay recoverable through lcm_load_session and
lcm_expand, preserving the lossless raw recovery contract.

Operator-facing surface:

- /lcm rotate: read-only preview of the planned frontier advance
- /lcm rotate apply: backup-first apply; rolling rotate-latest snapshot
  overwritten atomically via tmp + replace; no-op rerun preserves the
  previous known-good snapshot
- lcm_status / /lcm status: surfaces last_rotate_at (UTC ISO-8601),
  rotate_backup_path, and rotate_backup_size when a rotate has run
- LCM_STATUS tool schema description updated to mention the new fields

Refusal/no-op reason codes are documented on rotate_active_session and
include no_active_session, session_ignored, session_stateless,
no_pre_tail_content, empty_tail, frontier_already_ahead, and
stale_lifecycle_state. stale_lifecycle_state surfaces when
advance_frontier returns the unchanged state because the lifecycle
row's current_session_id no longer matches the engine; rotate detects
that case rather than reporting a fabricated frontier advance.

Internals:

- engine.backup_dir() centralizes the backup directory derivation so
  the timestamped /lcm backup slot and the rolling rotate slot cannot
  drift on hermes_home or db_path changes
- command._flush_engine_connections() extracted to share the
  three-connection commit between /lcm backup and /lcm rotate apply
- get_status() uses a single stat() on the rolling backup path,
  avoiding a TOCTOU window between separate mtime and size reads

Refs lossless-claw upstream design for /lcm rotate (martian-engineering/
lossless-claw README). Independent of the cache-aware compaction
discussion in #142.

* fix: keep rotate's persisted frontier separate from in-process source-mapping marker

Tosko4 surfaced on PR #176 review: the apply path advanced the live
engine's `_last_compacted_store_id` even though the host's active
context is not rewritten by the command. That created a same-process
window where the next normal `compress()` could summarize pre-rotate
messages while `_get_store_ids_for_messages()` filtered out the rows
at or below the rotated frontier, producing a DAG node whose summary
text covered `msg-0..msg-7` but whose `source_ids` only referenced
post-frontier rows.

Two frontier markers are now intentionally kept separate:

- The persisted lifecycle frontier
  (`lifecycle_state.current_frontier_store_id`) is the bootstrap
  signal. Rotate advances this directly via `advance_frontier`. On
  next session start, raw rows at or below it are not replayed into
  the active context.
- The in-process source-mapping marker
  (`self._last_compacted_store_id`) tracks raw rows that the current
  process has already moved into summary DAG nodes.
  `_get_store_ids_for_messages` uses it as a candidate filter. Rotate
  deliberately does NOT touch this marker: pre-tail raw messages
  remain in the in-memory active context until the host rebuilds it,
  so a later in-process `compress()` can still summarize them with
  correct `source_ids` lineage. On next process start,
  `_bind_lifecycle_state` reads the persisted frontier into the
  marker against a freshly-built active context, so the contract
  holds.

Regression coverage: a new test ingests system + msg-0..msg-9, runs
rotate apply, appends one assistant message in the still-live
process, stubs the summarizer for determinism, runs compress(), and
asserts that the resulting summary node has source_ids containing at
least one row at or below the rotate frontier. Reverting the fix in
isolation makes this test fail with a severed-lineage assertion;
applying it makes the test pass.

Updates the existing assertion in
test_rotate_apply_advances_frontier_and_writes_rolling_backup so
that it now pins `engine._last_compacted_store_id == 0` after rotate
(the previous assertion `>= 7` was inadvertently encoding the bug
as desired behavior).

Validation:

```
pytest tests/test_lcm_rotate.py -q                        -> 20 passed
pytest tests/test_lcm_core.py tests/test_lcm_engine.py \
       tests/test_packaging_install.py -q                 -> 564 passed
pytest -q                                                 -> 696 passed
bash -lc 'ulimit -n 1024 && pytest -q'                    -> 696 passed
python -m compileall -q .                                 -> OK
python -m py_compile scripts/import_lossless_claw.py      -> OK
bash -n scripts/install.sh scripts/update.sh              -> OK
git diff --check                                          -> clean
```

Refs #175
Co-authored-by: Diet Lam <duclamvan@users.noreply.github.com>
Bump hermes-lcm release references to v0.11.0.
Preserve no-op preflight suppression while keeping deferred maintenance eligible under critical pressure.
Bump hermes-lcm version identity for v0.11.1.
Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
* feat: add deterministic LCM benchmark harness

* fix: resolve benchmark policy paths from repo root
Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
* fix: rebind storage when Hermes home changes

* fix: clear profile-bound runtime state on rebind

---------

Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
* feat: add benchmark policy comparison metrics

* fix: isolate benchmark runs by policy version
* Fix quarantined assistant replay safety

* fix: tighten ignore-pattern replay handling

* fix: reuse durable quarantine refs after preflight

* fix: constrain quarantine ignore reconciliation

* fix: preserve repeated tail after raw ignored replay

---------

Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
* feat: add inspectable preset surface

Add benchmark provenance, symptom tuning docs, scrubbed community exports, and dry-run /lcm preset show/suggest/apply commands for issue #189.

Validation: focused preset/export tests, full pytest, constrained-fd pytest, compile checks, shell syntax, diff check, pressure smoke, external-CWD smoke, and independent review passed.

* fix: validate preset env overrides

Treat unparsable preset-managed LCM_* env values as invalid instead of active explicit overrides in preset suggest/apply dry-runs.

Validation: preset command tests, focused preset/export tests, core/engine/packaging slice, full pytest, compile checks, shell syntax, and diff check passed.

* fix: keep benchmark exports under output dir
* feat: add opt-in sensitive pattern redaction

* fix: cover sensitive redaction review gaps
Bump hermes-lcm release identity to v0.12.0.
* [verified] add summary failure benchmark profiles

* ci: retrigger checkout after GitHub fetch failure

* fix: normalize summary failure modes

---------

Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
* [verified] add summary fallback circuit breaker

* ci: retrigger checkout after GitHub fetch failure

* fix: try summary fallbacks for non-compressing output

---------

Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
Co-authored-by: Voscko <Tosko4@users.noreply.github.com>
* fix: surface LCM schema health in doctor

* fix: harden schema health diagnostics
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
* Improve budget-aware context assembly

* fix: preserve prompt replay reconciliation

* fix: make preserved prompt replay idempotent

* fix: scaffold non-contiguous raw user tails

---------

Co-authored-by: Voscko <Tosko4@users.noreply.github.com>
* feat: register LCM diagnostic tools with Hermes host

Register all 7 LCM diagnostic tools (lcm_grep, lcm_load_session,
lcm_describe, lcm_expand, lcm_expand_query, lcm_status, lcm_doctor)
as Hermes agent tools in the plugin entrypoint (__init__.py).

- Imports tool schemas from schemas.py and registers each tool under
  the context_engine toolset via ctx.register_tool()
- Routes each handler through engine.handle_tool_call() to preserve
  current-turn message-ingest behavior
- Guards ctx.register_tool via getattr() so plugin loads on hosts
  without register_tool (same pattern as /lcm slash command guard)
- Wraps each register_tool call in try/except (TypeError, ValueError)
  so a partially compatible host hook does not abort plugin init
- Adds regression test: ctx with register_tool that raises TypeError
  still loads plugin successfully
- Adds regression test: ctx without register_tool loads plugin gracefully
- Adds regression test: registered handlers forward messages=... to
  engine.handle_tool_call()
- Restores original git-runtime-identity assertions (plugin_git_dirty
  is None when git probe fails)
- Updates test _Ctx stubs to support the register_tool() interface

Refs stephenschoettler/hermes-lcm#200

* feat: add _host_supports_message_forwarding() capability check

Add host capability detection to inspect ctx.register_tool signature
before registering lcm_* tools. This prevents shadowing the native
context-engine dispatch path on Hermes Agent hosts.

- Returns True if register_tool accepts **kwargs or messages param
- Returns False for rigid signatures (Hermes Agent-shaped hosts)
- Uses inspect.signature() for deterministic detection
- Adds 4 unit tests for capability detection

* feat: gate lcm_* registration behind host message-forwarding capability

Gate ctx.register_tool() calls behind _host_supports_message_forwarding()
to prevent shadowing the native context-engine dispatch path on Hermes
Agent. When host lacks message-forwarding support, plugin skips tool
registration and relies on native context-engine schema injection.

- On Hermes Agent: tools not registered via ctx.register_tool()
- On other hosts: tools registered normally (backward compatible)
- Adds 3 regression tests for registration gating
- Updates existing test mocks to use **kwargs

* test: add Hermes Agent-shaped regression for lcm_* tool routing

Add regression tests verifying the Hermes Agent-shaped path works
correctly after gating lcm_* registration behind capability check.

- test_hermes_agent_shaped_host_uses_context_engine_path: verifies
  no tools registered via ctx.register_tool on rigid-signature hosts
- test_messages_forwarded_through_context_engine_path: verifies
  engine.handle_tool_call receives messages via context-engine path

---------

Co-authored-by: Voscko <Tosko4@users.noreply.github.com>
* fix: register LCM tools via ctx.register_tool()

Previously __init__.py:register(ctx) only called ctx.register_context_engine(engine)
and conditionally registered a /lcm slash command. The plugin.yaml declared 7 tools
(lcm_grep, lcm_load_session, lcm_describe, lcm_expand, lcm_expand_query,
lcm_status, lcm_doctor) but nothing bound them into the global tool registry.

This meant the agent only saw them via the ContextCompressor fallback path,
which does not run for all agent configurations. With this change the tools
are registered as first-class plugin-provided tools.

Original implementation by Stephen Bowman in PR #201. Picked up after the
original author closed that PR and explicitly invited others to continue it.

Refs #200

(cherry picked from commit 92f0e699289455cce76be94da80b2b0680c51dc2)

* fix: preserve context-engine dispatch for registered LCM tools

Keep plugin-side tool registration guarded for hosts without ctx.register_tool, and route registered handlers through LCMEngine.handle_tool_call so live messages=... ingestion remains on the same path as native context-engine dispatch.\n\nAdd regressions for the no-register_tool host shape, context_engine toolset registration, and handler kwargs forwarding.

* fix: tolerate plugin tool registration hook failures

Do not let a partially compatible ctx.register_tool implementation abort plugin initialization. Log the failed tool registration and continue with context-engine schemas so the LCM engine remains available.\n\nAdds a regression for a host hook that raises TypeError during tool registration.

* fix: gate registered LCM tools on message forwarding

---------

Co-authored-by: Stephen Bowman <bowman.stephen92@gmail.com>
Co-authored-by: Voscko <Tosko4@users.noreply.github.com>
Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
Background
==========

`MessageStore` opens its SQLite connection with `check_same_thread=False`
and shares it across all threads in the host process. SQLite's own C-level
mutex protects engine-internal state, but Python's `sqlite3` module releases
the GIL while the C call runs. Under heavy thread contention with concurrent
HTTPS API clients in the same process, downstream operators have observed
on-disk corruption that is consistent with external bytes landing inside
SQLite's write path.

Observed signature
==================

On two independent LCM databases corrupted in the same Hermes deployment,
the first 28 bytes of the database file were replaced with a TLS 1.2
Application Data record header + ciphertext while bytes 0-4 ("SQLit")
remained intact:

  Healthy: 53 51 4c 69 74 65 20 66 6f 72 6d 61 74 20 33 00    "SQLite format 3\\0"
  Corrupt: 53 51 4c 69 74 17 03 03 00 13 XX XX XX XX XX XX

Bytes 5-9 (\x17 \x03 \x03 \x00 \x13) are an exact TLS 1.2
`ContentType.application_data` record header with length=19. Pages beyond
the header were intact in both cases, and full row-count + integrity
recovery was possible by restoring the canonical 100-byte SQLite header.

SQLite cannot produce these bytes. The only source in the process at the
time of corruption was a Python HTTPS client (openai/httpx). The pattern is
consistent with concurrent in-process TLS buffer state intersecting
SQLite's write/flush path while the GIL was released.

Fix
===

Add a single `threading.RLock` (`self._write_lock`) on `MessageStore` and
wrap every call site that mutates `self._conn` (`append`, `append_batch`,
`reassign_session_messages`, `delete_session_messages`,
`gc_externalized_tool_result`, `pin`, `unpin`,
`normalize_legacy_blank_sources`). The lock is re-entrant so nested write
call sites are safe, and it is acquired alongside the existing
`with self._conn:` transaction managers where present so the SQLite-level
transaction and the Python-level mutex enter/exit together.

The lock is uncontended in single-threaded callers and adds only a single
`acquire`/`release` per write operation. `unpin` had no prior commit but
matched `pin` semantically; `commit` is now consistent across both.

Tests
=====

New `test_write_lock_serializes_concurrent_appends` (under
`TestMessageStore` in `tests/test_lcm_core.py`) exercises the contract:

- asserts `MessageStore._write_lock` exists and is re-entrant
- spins up 8 worker threads doing 25 single appends + 5 batches of 5
- asserts no exceptions, exact expected row count, and `PRAGMA quick_check
  = ok` afterward

Out of scope for this PR
========================

`LifecycleStateStore` in `lifecycle_state.py` uses the same shared
`check_same_thread=False` connection pattern and would benefit from the
same lock. Tracking that as a follow-up so this PR stays small and easy
to review.

Validation
==========

- `pytest tests/test_lcm_core.py tests/test_lcm_engine.py tests/test_packaging_install.py -q` → 588 passed
- `pytest -q` (full suite) → 817 passed, 2 pre-existing failures in
  `test_ingest_protection.py` unrelated to this change (verified against
  unpatched main HEAD).
- `python -m compileall -q .` clean
- `python -m py_compile scripts/import_lossless_claw.py` clean
- `bash -n scripts/install.sh scripts/update.sh` clean
- `git diff --check` clean

Co-authored-by: Gilfoyle (RVF infra) <gilfoyle@riftvalleyfarm.com>
Co-authored-by: Voscko <Tosko4@users.noreply.github.com>
* chore: release v0.13.0

* docs: refresh release references
Co-authored-by: Stephen Schoettler <stephenschoettler@gmail.com>
* chore: release v0.14.0

* docs: refresh release references
Add the demo video asset and README Demo section.
Use the raw MP4 URL so the README link bypasses GitHub's blob preview limit.
* test: guard public lcm tool contract drift

* test: stub host context engine in contract tests
* fix: prevent compression cascade on session split

When DM Topics cause session ID mismatch (old_session_id != bound
session), the skip-carry-over path in _continue_compression_boundary()
was calling _reset_session_scoped_runtime_state() which zeroed
_last_compacted_store_id and _ingest_cursor. This caused the new
session to re-ingest and re-compact all messages, triggering another
session split in a feedback loop.

Observed in production: 6 session splits in 9 minutes with 8 LCM
compactions, fragmenting context and causing Hermes Agent amnesia.

Fix (Option C — defense-in-depth):
- Preserve compaction state on skip-carry-over path (Option A)
- Add 60-second compression cooldown as safety net (Option B)
- Split _reset_session_scoped_runtime_state() into
  _reset_session_counters() (safe on boundary skip) and
  _reset_compaction_progress() (dangerous on boundary skip)

Follow-up to #209 (feat: register LCM diagnostic tools with Hermes host).

* fix: repair compression boundary skip invariants

---------

Co-authored-by: Stephen Schoettler <stephenschoettler@gmail.com>
* test: add deterministic LCM stress release check

* fix: reload stress harness submodules
* test: add lifecycle soak stress scenario

* test: tighten lifecycle soak assertions
* fix: preserve DAG carryover across session drift

* fix: scope compression boundary frontier

* fix: prefer conversation row for host boundary
* fix: preserve active replay after storage protection

* fix: preserve raw payload active stubs

---------

Co-authored-by: Stephen Schoettler <stephenschoettler@gmail.com>
* chore: release v0.14.1

* docs: refresh release references
* chore: release v0.15.0

* docs: refresh release references
* feat: classify lifecycle fragmentation diagnostics

* fix: gate lifecycle-reference categories on lifecycle state

---------

Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
* feat: align summary timeout diagnostics

* fix: reset auxiliary timeout fallback parser

* fix: scope summary quality diagnostics to session

* fix: flag zero-token large source summaries

---------

Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
* chore: release v0.16.0

* docs: refresh release references
Throttle startup FTS5 integrity-checks while keeping explicit repair unthrottled.

Co-authored-by: ma-pony <ma-pony@users.noreply.github.com>
Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
* fix: crash-safe WAL checkpoint on close for multi-process deployments

In a multi-agent setup (gateway + CLI sessions + sub-agents), each process
opens an independent sqlite3.Connection to the same lcm.db file. When any
process terminates unexpectedly, its WAL pages are left incomplete, and
other processes may see corrupted B-tree pages on the next open.

Three-part fix:

1. db_bootstrap.configure_connection() — add PRAGMA hardening:
   - synchronous=FULL          : WAL survives power loss / crash
   - wal_autocheckpoint=500    : cap WAL growth automatically
   - journal_size_limit=64MB   : force passive checkpoint at threshold
   - mmap_size=256MB           : shared memory reads reduce WAL contention

2. store.MessageStore.close() — checkpoint before conn.close():
   Graceful shutdown now runs PRAGMA wal_checkpoint(PASSIVE) so sibling
   processes don't inherit a truncated WAL log.

3. dag.SummaryDAG.close() + lifecycle_state.LifecycleStateStore.close():
   Same PASSIVE checkpoint treatment for all three connections that share
   the same database file.

All three classes also gain return-type hints (-> None) on close() and
__del__() for consistency.

* fix: correct WAL sizing claims in docstring; add crash-safe WAL regression tests

- db_bootstrap.configure_connection(): rewrite docstring to accurately
  describe what each PRAGMA actually guarantees, acknowledging that
  journal_size_limit does not cap WAL growth under checkpoint starvation
  and wal_autocheckpoint is a best-effort hint only.

- tests/test_crash_safe_wal.py: 13 new tests covering:
  * All 6 PRAGMAs set by configure_connection()
  * MessageStore.close() WAL checkpoint reduces WAL size
  * MessageStore.close() idempotent
  * SummaryDAG.close() WAL checkpoint reduces WAL size
  * LifecycleStateStore.close() WAL checkpoint reduces WAL size
  * close() paths do not mask errors when conn is None

Reviewed-by: Tosko4 (issue report)

* fix: narrow WAL durability claims

Maintainer follow-up for PR #237. Keep the contributor's WAL hardening intact while making comments and tests explicit that passive checkpoints run only on graceful close and abnormal exit still depends on SQLite WAL recovery.

---------

Co-authored-by: root <root@hermes-nova.lan>
Co-authored-by: Stephen Schoettler <stephenschoettler@gmail.com>
* chore: release v0.16.1

* docs: refresh release references
* fix(config): tune compaction defaults and add lcm.context_threshold config priority

Problem: Default compaction settings cause context overflow on large
context windows (200k+ tokens). With context_threshold=0.75, LCM waits
until 75% of the context window is consumed before compacting — too
late for models that error on hard context limits. incremental_max_depth=1
produces flat DAGs (leaf-only compression), preventing hierarchical
summarization. fresh_tail_count=64 protects too many recent messages
per cycle, leaving less room for useful history.

Changes:
- context_threshold: 0.75 → 0.35 (compaction fires at 35% of context
  window, e.g. ~71k tokens on a 204k context)
- fresh_tail_count: 64 → 32 (fewer messages shielded from compaction)
- incremental_max_depth: 1 → 3 (enables hierarchical DAG: leaf → d1
  → d2 → d3)

Also: _hermes_compression_threshold() now reads lcm.context_threshold
from config.yaml with priority over compression.threshold. This lets
operators tune LCM compaction independently of the Hermes compression
setting. Works both with and without pyyaml installed.

Tests: updated default assertions, added two new tests for
lcm.context_threshold priority (with and without pyyaml). All 19
TestConfig tests pass.

* test: update preset test to expect fresh_tail_count default of 32

* fix(config): restore indent tracking in no-yaml fallback parser

The no-yaml fallback parser dropped direct_indent tracking from the
original code, meaning deeply nested keys under lcm: or compression:
could be falsely matched. This restores proper indent-level tracking
for both sections and adds regression tests for nested keys.

* docs: update README defaults and prose to reflect new compaction defaults

* test: add explicit default assertion for incremental_max_depth
* fix: prefer bound session on sibling chain when host has zero DAG

Fixes a post-#224 regression where a stale host session with zero DAG
nodes causes the compression boundary to orphan the bound session's
chain, even when both share the same parent session ID.

When old_session_id has no summary nodes AND the bound session belongs
to a different conversation_id (lifecycle fork from _gateway_session_key
being None at init) but shares the same last_finalized_session_id
(parent), prefer the bound session as the carry-over source.

Closes #241

* fix: source_state.conversation_id takes priority over kwargs binding

In _continue_compression_boundary(), the sibling-chain fallback correctly
selects the bound session as source_state despite conversation_id mismatch,
but the downstream lifecycle binding ignored it — kwargs.get('conversation_id')
won because it was position 1 in the OR chain. Result: nodes landed under
the gateway session's conversation instead of the bound session's.

Fixed by swapping the priority chain:
  source_state.conversation_id (position 1) → kwargs (position 2).

Blast radius: zero. When source.conversation_id == kwargs[conversation_id]
(normal case), the OR chain produces the same value. The only delta is the
sibling-chain fork case, which is the bug being fixed.

Tests: 401 passed, 73 warnings — zero regressions.

Fixes review feedback on PR #242 — stephenschoettler/hermes-lcm

* fix: sibling-chain conversation_id priority + 7 test cases

conversation_id binding now respects source_state over kwargs in
_continue_compression_boundary(). The sibling-chain fallback correctly
selects the bound session but kwargs.get('conversation_id') was
position 1, ignoring the selection. Swapped to source_state first.

Test cases (7):
- Positive: zero-DAG host + same-parent active bound sibling
- Negative: bound source has no DAG
- Negative: parent mismatch
- Negative: host has DAG -> stays host-authoritative
- Positive: active sibling with different conversation_id
- Edge: source_state=None -> kwargs fallback
- Regression: conversation_id from bound session, not kwargs

All 7 pass. Full suite: 408 passed, 73 warnings, zero regressions.

Addresses review on PR #242 — stephenschoettler/hermes-lcm

---------

Co-authored-by: Britney <britney@hermes-fleet.local>
Co-authored-by: Emil Tsoi <emil.tsoi@gmail.com>
Co-authored-by: 3ASiC <the3asic@users.noreply.github.com>
Co-authored-by: Stephen Schoettler <stephenschoettler@gmail.com>
* chore: release v0.16.2

* docs: refresh release references
* fix: skip unbacked active summary scaffolds

* fix: preserve backed summary scaffolds on noop
* fix(lifecycle): gc empty session rows to prevent unbounded accumulation

why: every gateway restart, cron tick, and ephemeral TUI open creates a
lifecycle row via bind_session(). when that session never ingests a single
message, the row persists forever. on a 30-day-old live instance, 573 of
1393 rows (41%) had zero stored data — pure orphans.

how:
- add prune_empty_sessions() to LifecycleStateStore: deletes rows where
  both current_session_id and last_finalized_session_id have zero
  messages and zero summary_nodes
- expose 3 config knobs (LCM_EMPTY_LIFECYCLE_GC_ENABLED,
  LCM_EMPTY_LIFECYCLE_GC_THRESHOLD, LCM_EMPTY_LIFECYCLE_GC_MAX_AGE_HOURS)
- call gc from _bind_lifecycle_state() when table exceeds threshold
  (default 200 rows, so fresh installs skip the work)
- add /lcm doctor clean lifecycle (dry-run) and
  /lcm doctor clean lifecycle apply commands
- protects the actively-bound session from deletion
- 8 unit tests + 2 integration tests

* fix: address stephen's review comments (backup-first, atomic gc, tests, docs, revert version bump)

* chore: finish lifecycle cleanup surface review

* fix: guard automatic lifecycle gc against live sessions

---------

Co-authored-by: Stephen Schoettler <stephenschoettler@gmail.com>
Auto-derive a compact compression focus hint from recent real user turns so automatic LCM compression prioritizes current intent when no explicit focus topic is supplied.

Includes committed coverage for user-turn selection, context-summary skipping, explicit focus-topic behavior, truncation bounds, multimodal text extraction, and sensitive-value leakage prevention for structured/dict content, JSON token strings, bearer auth text, and configured sensitive patterns.

Closes #257
* fix: per-turn ingest for WebUI sessions + batch timestamp dedup

Two fixes for LCM message persistence:

1. **Per-turn ingest (engine.py):** Add public ingest() method that calls
   _ingest_messages() every turn, not just on compression. This fixes WebUI
   sessions where short conversations never hit the compression threshold
   and never expire like Telegram sessions, leaving messages invisible to
   LCM forever. Uses the existing ingest cursor, so no duplicates when
   compress() runs later the same turn.

2. **Batch timestamp dedup (store.py):** Move time.time() inside the for-loop
   in append_batch(). Previously, a single timestamp was calculated before the
   loop and reused for ALL messages in the batch, causing 50-122+ messages to
   share identical timestamps. This broke all date-based queries (journal
   entries, cross-session search, time-range filtering).

* fix: per-turn ingest via post_llm_call hook + batch timestamp dedup

Address review feedback from stephenschoettler:

1. Register post_llm_call hook from the LCM plugin itself (not from
   turn_context.py) so every completed turn is persisted to the durable
   store.  The hook fires after the tool-calling loop and receives
   conversation_history including the assistant response.  The existing
   _ingest_messages cursor prevents duplicates if compress() runs later
   the same turn.

2. Remove inline arrow comment from store.py timestamp fix.

3. Add deterministic regressions:
   - test_append_batch_timestamps_are_unique_per_row (store-level)
   - test_below_threshold_turn_persists_without_compression
   - test_ingest_then_compress_no_duplicate_rows
   - test_ignored_session_ingest_persists_nothing
   - test_stateless_session_ingest_persists_nothing

The companion hermes-agent PR (#47243) is no longer needed — the fix is
now self-contained in the LCM plugin.

---------

Co-authored-by: root <root@Hermes.milim.dev>
Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
* feat: expand query with recursive evidence

* fix: keep raw snippets out of query context

* fix: make evidence descent iterative

* fix: charge retrieval metadata to context budget

* fix: window raw evidence around search matches

---------

Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
* chore: release v0.18.0

* docs: refresh release references
Co-authored-by: Hermes Agent <hermes-agent@users.noreply.github.com>
* fix: add demote old topics instruction to prevent iterative compaction from keeping completed topics alive

Our previous auto-derive focus_topic PR (eaddaba) only implemented the
'auto-derive' half of the upstream fix/compression-auto-focus-topic (#44674).
The full upstream fix includes both:
1. Auto-derive focus topic from recent user turns (we had this)
2. Demote old / completed topics in the summary (we were missing this)

Without the demote instruction, iterative compaction kept completed topics
alive and overrode the current active topic (hermes-agent issue #9631).

This change adds explicit 'Demote old / completed topics' guidance to
_build_l1_focus_brief and _build_l2_focus_brief, mirroring the upstream
hermes-agent fix.

References:
- hermes-agent issue #9631: Iterative context compaction summary keeps
  completed topics alive and overrides the current active topic
- hermes-agent PR #44674: fix(compression): auto-derive focus_topic +
  demote old topics in iterative compaction

* fix: align demote instruction with upstream hermes-agent template structure

Align _build_l1_focus_brief and _build_l2_focus_brief with upstream
hermes-agent fix/compression-auto-focus-topic (#44674) by adding:

- _HISTORICAL_HEADING_MARKERS constant mirroring upstream HISTORICAL_XXX
  constants (Historical Task Snapshot, Historical In-Progress State,
  Historical Pending User Asks, Historical Remaining Work)

- Explicit historical heading markers in demote instruction so the
  summariser uses structured section headings instead of informal prose

- Consistent token budget wording ('60-70% of the summary token budget')
  matching upstream FOCUS TOPIC guidance

This makes the demote signal structural rather than advisory, closer to
how upstream prevents completed topics from surviving iterative
compaction (issue #9631).

References:
- hermes-agent issue #9631: Iterative context compaction summary keeps
  completed topics alive
- hermes-agent PR #44674: fix(compression): auto-derive focus_topic +
  demote old topics

* test: update l1 focus brief test to match demote guidance

* fix: preserve blocker/handoff protection and clarify heading semantics

* fix: correct upstream PR provenance (#44687) and make heading markers deterministic

Tosko4 review feedback:
- #44674 is the Slack/plugin-registry PR; the auto-focus compression
  fix is #44687. Update all references in engine.py, escalation.py.
- _HISTORICAL_HEADING_MARKERS was a frozenset, making the generated
  prompt order dependent on PYTHONHASHSEED. Convert to an ordered
  tuple for deterministic summarizer guidance.

* fix: correct upstream PR provenance (#44687 + #44454)

* fix: keep historical heading markers aligned with upstream

---------

Co-authored-by: Nova <nova@herms.local>
Co-authored-by: redashes1984 <redashes1984@users.noreply.github.com>
Co-authored-by: root <root@hermes-nova.lan>
Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
* fix: clarify lcm operability and release checks

* Fix source lineage failure guidance

* fix: validate committed release diffs

* fix: keep release validation side effects contained

* fix: validate release commits without base remote

* fix: preserve doctor failure severity in guidance

* fix: keep dag diagnostic failures inspect-only

* fix: make release pytest and payload triage standalone-safe

---------

Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
* fix: avoid payload integrity false positives from log examples

* fix: preserve real payload refs in integrity scan

* fix: count raw tool argument payload refs

* fix: preserve escaped tool-call ref context

* fix: count escaped JSON tool content refs

* fix: count free-form tool argument refs

* fix: count parsed object argument refs

* fix: distinguish escaped examples from parsed refs

* fix: keep apostrophes in parsed refs

* fix: limit escaped quote suppression scope

* fix: scan parsed JSON argument refs

* fix: keep unmatched caption quote refs

* fix: scope escaped quote example guards

* fix: allow output payload refs

* fix: require labeled example quote context

* fix: scan embedded tool call field refs

* fix: distinguish example refs in logs

* fix: preserve escaped duplicate-key JSON strings

---------

Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
* fix: harden doctor diagnostics paths

* fix: keep unchecked FTS diagnostics as warnings

* fix: align FTS repair diagnostics

---------

Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
* fix: pin summary role to user after system anchor to prevent HTTP 400

After two compactions the DAG summary becomes the first message reaching
the provider (the fresh tail and any prior turns have been compacted away,
leaving only the system prompt as the leading anchor). _assemble_context
assigned that summary role=assistant, so once the Anthropic adapter
extracts the system prompt into its separate field, messages[0] is an
assistant turn. Anthropic requires the first message to be role=user and
rejects it with a misleading 'tool_use ids were found without tool_result
blocks immediately after' HTTP 400.

Pin the summary to role=user when the system prompt is the only leading
anchor, mirroring the built-in compressor fix in hermes-agent #52160.

Adds a regression test asserting the first non-system message is role=user
when a summary follows a system anchor, and updates three role-snapshot
assertions whose intent (budget selection, durable-row dedup on replay) is
unaffected — the preserved-objective scaffold is still recognized by its
content prefix regardless of role.

* fix(engine): pin leading summary role to user in no-system assembly path

The system-anchor fix only covered _assemble_context() when a system
prompt is present. The no-system/gateway assembly path still emitted the
DAG summary as messages[0] with role assistant, the same structural class
HTTP 400 the PR eliminates.

Generalize the rule: when the summary block would be the first
provider-visible message (no leading anchor, or system prompt only),
assign role user; otherwise preserve alternation against the preceding
visible message.

Add a regression for _assemble_context(None, tail) asserting the first
non-system message is user, and update the backed-summary no-op test
whose leading marker now reflects the canonical user role.
## Summary
- add explicit MIT LICENSE file
- add CODE_OF_CONDUCT.md and SECURITY.md
- link community health docs from README

## Validation
- ad-hoc /tmp/hermes-verify-* verification passed
- GitHub CI passed: workflow-lint, test (3.11), test (3.12), test (3.13), test (3.14)

Refs #270
Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
Refs #290
Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
* fix: strip injected context before compaction

* fix: harden injected context stripping

* fix: treat injected delimiters as untrusted

* fix: preserve separated injected context blocks

* fix: keep stripping close-only injected delimiters

* fix: preserve short inline injected block separators

* fix: strip line-ending injected close delimiters

* fix: preserve inline text between injected blocks

* fix: strip spoofed line-delimited injected closes

* fix: strip inline spoofed injected closes

* fix: treat block-delimited injected bodies as untrusted

* fix: preserve obvious text between injected blocks

* fix: preserve long text between injected blocks

* fix: prefer safe block context stripping

* fix: strip ambiguous inline injected tails

* fix: preserve inline memory wording between blocks

* fix: preserve trailing text after block close

* fix: preserve shell-like inline inter-block text

* fix: strip ambiguous inline inter-block payloads

* fix: ignore spoofed same-line block closes

* fix: strip active memory wrapper context

* fix: preserve security-term requests after context blocks

* fix: sanitize preserved objective anchors

* fix: preserve requests after content-line context closers

* fix: strip attributed injected context wrappers

* fix: sanitize carried objective tail scaffolds

* fix: preserve requests after self-closing context tags

* fix: preserve literal close-tag user text

* fix: preserve text between inline context blocks

* fix: preserve whitespace without context stripping

* fix: preserve text after unmatched inline context markers

* fix: externalize tool outputs before summary sanitization

---------

Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
* fix: exclude ignored backlog from compaction

* fix: keep ignored dependency markers across boundaries

* docs: add PR 283 visual summary

* fix: trigger preflight for sensitive replay cleanup

* fix: drop replies after ignored system turns

* fix: preserve stored placeholder literals on rollover

* fix: filter ignored stored externalized rows

* fix: harden ignored externalized replay filtering

* fix: require dependent reply store provenance

* fix: preserve externalized placeholder source mapping

* fix: clear abandoned boundary ingest state

* fix: preserve duplicate dependent rollover markers

* fix: skip ignored stored rows for objective anchors

* fix: page prior externalized placeholder scan

* fix: exclude dependent replies from summary lineage

---------

Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
Co-authored-by: Stephen Schoettler <stephenschoettler@gmail.com>
Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
* Serialize lifecycle/DAG writes and keep the frontier monotonic

The lifecycle store connection is opened check_same_thread=False in autocommit
mode and shared across the gateway thread, dispatcher, and sub-agents, but its
read-modify-write methods held no lock. Two callers advancing the checkpoint
frontier could interleave: T1 reads 10 -> writes 15 while T2 reads 10 -> writes
12, regressing the frontier and forcing the same raw range to be compacted
again (duplicate summary nodes / GC passes).

- Add a reentrant lock and a @_synchronized decorator over bind_session,
  finalize_session, record_rollover, record_debt, record_maintenance_attempt,
  record_reset, prune_empty_sessions.
- advance_frontier now computes the new value with SQL MAX(current, ?) so it
  stays monotonic even against a stale read.
- SummaryDAG.delete_below_depth/delete_session_nodes/reassign_session_nodes now
  take _db_lock like the other mutators.

Adds a regression test proving a stale-read advance cannot regress the frontier.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Guard frontier advance by session owner

---------

Co-authored-by: ai-ag2026 <m.fuechtenkoetter@posteo.de>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Stephen Schoettler <stephenschoettler@gmail.com>
* Harden ingest protection: ReDoS-safe redaction, wrapped base64, path containment

Three storage-boundary robustness fixes:

- Sensitive-pattern redaction ran the private_key pattern (lazy .*? under
  DOTALL, which rescans to end-of-string per unmatched BEGIN header) with stdlib
  re and no timeout, so a multi-MB payload with many BEGIN headers and no END
  blocked ingest for seconds-to-minutes. Route only that pattern through the
  optional 'regex' engine with a match timeout (or a length bound without it);
  the other patterns are linear and always run stdlib so a redaction bypass
  under load can't silently leak a secret.

- Line-wrapped base64 (MIME 76 / PEM 64 chars per line, incl. canonical CRLF)
  never forms a single 4096-char contiguous run, so it slipped past the
  externalizer and landed inline in SQLite/FTS/WAL/backups. Detect and
  externalize wrapped blocks; compute the base64 ratio over whitespace-stripped
  content so CRLF line endings don't sink it below threshold.

- Externalized-payload path containment was enforced only when
  LCM_HERMES_BASE_DIR was set. Default deployments now warn (once) when a
  configured path escapes the hermes_home base, without breaking legitimate
  custom volumes; strict enforcement still applies when the base is set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: keep ingest redaction and wrapped-base64 scans linear

* fix: preserve redactions on timeout and ignore hex inventories

* fix: cover wrapped base64 terminal line redaction

* test: cover wrapped base64 terminal line

---------

Co-authored-by: ai-ag2026 <m.fuechtenkoetter@posteo.de>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Stephen Schoettler <stephenschoettler@gmail.com>
* Add a summary spend guard and a schema-too-new open guard

Two safety guards:

- SummarySpendGuard: the existing circuit breaker reacts only to summarizer
  *failures*, but a pathologically looping compaction that keeps succeeding can
  burn auxiliary-model spend without bound. Add a per-scope sliding-window rate
  limit with backoff; when the budget is exhausted, escalation falls back to
  deterministic L3 truncation. The guard is checked per-route so a mid-chain
  trip stops remaining fallbacks, and it is deliberately NOT cleared on the
  automatic force_overflow path (which is the very state a runaway loop
  produces). Config via optional getattr fields; summary_spend_max_calls=0
  disables it.

- SchemaVersionTooNewError: run_versioned_migrations now refuses to open a
  database whose stored schema_version exceeds this build's SCHEMA_VERSION,
  instead of silently degrading after a downgrade/rollback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: reject newer schemas before startup ddl

* fix: reject future schemas before migration ddl

* fix: expose summary spend and guard future schema opens

* test: prove future schema guard precedes pragmas

---------

Co-authored-by: ai-ag2026 <m.fuechtenkoetter@posteo.de>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Stephen Schoettler <stephenschoettler@gmail.com>
* fix: recover persisted Hermes tool outputs losslessly

* fix: preserve persisted output recovery metadata

* fix: recover persisted Hermes tool outputs losslessly

* fix: hash persisted-output preview metadata

---------

Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
Co-authored-by: Stephen Schoettler <stephenschoettler@gmail.com>
_get_store_id_map_for_messages aligns active messages to store_ids by scanning
candidates and comparing replay identities. It already computes each candidate's
_message_replay_identity(stored_row=True) once (for the identity counts), but the
match-probe loops recomputed it for every (message, probe) pair - O(candidates^2)
recomputes per call, and _message_replay_identity reads the externalized payload
file from disk for rows that carry one, so on tool-output-heavy histories that is
O(candidates^2) disk reads.

Capture each candidate's identity and cleanup identity once (no extra work - both
are already computed for the counts) and reuse them in the probe loops. Keep
raw-placeholder identities lazy (memoized on demand) since most rows never need
them, so the common case adds nothing.

Behavior-preserving (identical values, computed once). Measured with a
steady-state replay: ignore-patterns history ~25% faster per turn payload-free,
~40% faster with externalized tool payloads.

Co-authored-by: ai-ag2026 <m.fuechtenkoetter@posteo.de>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Stephen Schoettler <stephenschoettler@gmail.com>
Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
Co-authored-by: Stephen Schoettler <stephenschoettler@gmail.com>
* feat: add read-only LCM inspect tool

* fix: include ingest protection in LCM inspect

* fix: scope inspect payload readability to session

* fix: validate inspect payload refs

* fix: inspect payload refs without body loads

* fix: require inspect payload content

* fix: keep inspect payload checks bounded

---------

Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
Co-authored-by: Stephen Schoettler <stephenschoettler@gmail.com>
Merge approved PR #310 after current-head green CI and review approval.
Pin an explicit ruff rule set in pyproject.toml and add a `lint` job to CI so
style regressions are caught automatically and identically in both places.

The config selects ruff's default rules (pyflakes F + pycodestyle E4/E7/E9) and
nothing more: line-length (E501) and import sorting (I) stay disabled because
the codebase predates both and enforcing either would be a large, purely
cosmetic reformat. E402 is ignored because several modules import after a
sys.path / optional-dependency bootstrap. Declaring the settings in-repo also
makes the gate independent of any inherited machine-level ruff config, so
local runs match CI.

To make the tree pass under the new gate, this also applies the handful of
behaviour-neutral cleanups the linter flagged:
- config.py: replace an assigned lambda with a nested def (E731).
- presets.py: alias the dataclasses `field` import so it is no longer shadowed
  by loop variables (F402).
- store.py, tests: drop unused imports (F401) and unused local assignments,
  keeping the side-effecting calls (F841).

Validation (standalone, local agent.context_engine stub):
- ruff check . -> All checks passed
- PYTHONPATH="<stub>:." python3 -m pytest tests/ -q -> 1385 passed, 12 xfailed
- python3 -m compileall -q . / py_compile scripts/import_lossless_claw.py -> ok
- bash -n scripts/install.sh scripts/update.sh -> ok
- git diff --check -> clean

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Persist a lightweight, per-conversation compaction-telemetry record and surface
it in get_status(), so operators can see cache behaviour and compaction cadence
without a schema change.

What is recorded (once per turn, upserted as a single JSON row in the existing
metadata table -- no new table, no SCHEMA_VERSION bump):
- last observed prompt / cache-read / cache-write tokens
- cache_state (hot | cold | unknown) and a consecutive-cold-observation streak
- turns_since_leaf_compaction and peak_prompt_tokens_since_leaf_compaction
- total_compactions, last_leaf_compaction_at, last_compaction_duration_ms
- provider / model and last_api_call_at
- activity_band (reserved; carried forward as "low", no live computation yet)

Design notes:
- The since-compaction accumulators reset off the monotonic compression_count
  (which also drops to 0 on a session reset) instead of instrumenting the
  compress() hot path -- one comparison in the per-turn recorder, no invasive
  change to the compaction pipeline.
- Best-effort and non-blocking: turns with no token/cache signal are skipped so
  idle turns never churn the row; the write is skipped when the payload is
  unchanged; and every read/write is guarded so telemetry never breaks a turn.
- Missing cache signals degrade to cache_state "unknown" with the cold streak
  carried, mirroring the reference behaviour.
- compress() also logs the leaf-compaction wall-clock at INFO (log-only, as in
  the reference; durations/queue-wait are not persisted).

New MessageStore.read_compaction_telemetry / write_compaction_telemetry keep the
engine out of the store's private connection/lock. (These are purpose-named and
independent of the generic metadata helpers proposed separately.)

Validation (standalone, local agent.context_engine stub):
- New tests/test_compaction_telemetry.py (8 tests): per-turn snapshot, turns/peak
  accumulation, cold streak + hot reset, idle-turn skip, reset-on-compaction,
  no-conversation no-op, unknown state, store roundtrip + skip-unchanged -> pass.
- PYTHONPATH="<stub>:." python3 -m pytest tests -q -> 1393 passed, 12 xfailed
- python3 -m compileall -q . / py_compile scripts/import_lossless_claw.py -> ok
- bash -n scripts/install.sh scripts/update.sh -> ok
- git diff --check -> clean

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Data-safety fixes: GC tombstone, ingest visibility, ignore-drop count, recall order

Five correctness fixes around the lossless guarantee:

- Transcript GC could tombstone an entire tool row when an externalized
  placeholder was merely EMBEDDED in surrounding text (e.g. a recall-tool
  result that quotes one), discarding the never-externalized surrounding text.
  Only take the fast ref-branch when the whole row IS the placeholder
  (is_externalized_placeholder); otherwise fall through to the content-equality
  lookup, which only tombstones an exact payload match.

- Per-turn ingest and preflight swallowed persistence errors at debug level, so
  a failing store (disk full, lock, corruption) silently stopped persisting
  turns. Track failures, surface them in get_status()/doctor, escalate to ERROR
  when consecutive, and fail closed in preflight - while still honoring
  emergency overflow recovery so the prompt can't overflow the provider.

- ignore_message_patterns dropped matching messages with only a debug log.
  Count the drops, log at INFO, and surface them in status/doctor so an
  over-broad pattern eating substantive turns is visible.

- The relevance/hybrid LIKE search fallback scored an arbitrary storage-order
  slice with no ORDER BY. Scan deterministically recent-first up to the
  candidate cap so matches beyond the first page are found.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: surface live ingest failures and rank like candidates

* fix: preserve exact like fallback candidates before cap

* fix: keep recency like fallback bindings aligned

* fix: bind exact like fallback ordering args

* fix: bind LIKE fallback order args before exact match

---------

Co-authored-by: ai-ag2026 <m.fuechtenkoetter@posteo.de>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Stephen Schoettler <stephenschoettler@gmail.com>
Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
Co-authored-by: Stephen Schoettler <stephenschoettler@gmail.com>
Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
Co-authored-by: Stephen Schoettler <stephenschoettler@gmail.com>
The engine's per-scope metadata helpers each re-implemented the same SQLite
boilerplate: fetch `self._store._conn`, guard it, `SELECT value FROM metadata`,
`json.loads`, and on the write side acquire `self._store._write_lock`, UPSERT
each key, and commit (the placeholder count/ordinal writers additionally skip
the write when the stored value is unchanged, per #299). This was duplicated
across eight methods and reached into the store's private connection and lock
from the engine.

Introduce two `MessageStore` methods and route every metadata helper through
them:
- `read_metadata_json(key)` -> the JSON-decoded value, or None for a
  closed connection / missing / empty row. Decoding is intentionally not
  wrapped so a malformed value still raises, preserving each caller's own
  try/except scoping (e.g. the hash-list loader discards a partial result on
  error, the count/ordinal loaders keep what they accumulated).
- `write_metadata_json(keys, serialized, *, skip_unchanged=False)` -> UPSERT
  the pre-serialized payload to each key under the write lock with a single
  commit; `skip_unchanged=True` keeps the #299 hot-path optimization for the
  count/ordinal writers.

Behaviour-preserving: signatures, return values, error handling, and the
unchanged-skip commit semantics are unchanged. The engine no longer touches
`self._store._conn` / `self._store._write_lock` at all. Net engine.py is 111
lines lighter.

Validation (standalone, local agent.context_engine stub):
- Direct helper checks: read/write roundtrip, skip_unchanged=False->True /
  True->no-op, missing key -> None, empty value -> None, malformed JSON
  propagates -> all pass.
- PYTHONPATH="<stub>:." python3 -m pytest tests/ -q -> 1385 passed, 12 xfailed
  (identical to pre-change)
- python3 -m compileall -q . / py_compile scripts/import_lossless_claw.py -> ok
- bash -n scripts/install.sh scripts/update.sh -> ok
- git diff --check -> clean

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Stephen Schoettler <stephenschoettler@gmail.com>
# Conflicts:
#	store.py
Add compaction telemetry (per-conversation snapshot)
* fix: use auxiliary usage for bypass checks

* fix: honor stack-marked auxiliary usage

---------

Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
Co-authored-by: Stephen Schoettler <stephenschoettler@gmail.com>
Add a ruff lint configuration and CI gate
Hotfix: port missed PR 306 and 308 fixes
The engine's `/lcm` command handlers and inspect/doctor tools reached into
`engine._store._conn` directly for every ad-hoc read (integrity / quick /
journal-mode checks, FTS sync counts, schema health, payload risk scans) and
for the backup path's flush + snapshot. This coupled diagnostics and the CLI
to the store's private connection field and its name.

Finish the WS5 encapsulation started in #316 (metadata JSON) for the remaining
connection reach-ins:
- add `MessageStore.connection` -- a documented, read-oriented property that
  returns the live connection (or `None` once closed), for ad-hoc diagnostic
  queries the store does not wrap in a purpose method;
- add `MessageStore.commit()` and `MessageStore.backup(dest)` so the backup
  path snapshots and flushes the store without touching `_conn`.

Route every remaining `_store._conn` read in command.py and tools.py through
`.connection`, and the backup/commit calls through the new methods.

Behaviour-preserving: the property returns the same object callers used before
(None-guards are unchanged), and commit/backup are thin wrappers that keep the
live-connection requirement -- a closed store still raises. The `_dag` and
`_lifecycle` connections are separate classes and stay out of this MessageStore
change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
Extract the store backup/rotate data operations out of the /lcm command
handler into a dedicated `maintenance.py`:
- `flush_engine_connections`, `backup_database`, `rotate_backup_database`
  move verbatim (renamed to public module functions) from command.py.

command.py imports the two entry points and keeps only the text-formatting
command helpers (`_backup_text`, `_rotate_text`, `_rotate_apply_text`) plus the
doctor/clean/repair/source-apply callers, which now call the imported
functions. `flush_engine_connections` is used only by the two backup writers,
so it stays internal to the module.

This isolates the store/dag/lifecycle connection-flush and snapshot logic in one
place, separate from `/lcm` presentation. Behaviour-preserving: the functions
are moved unchanged (same signatures, same return dicts, same error handling);
no test references them by name, only via `handle_lcm_command`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the pure active-context sanitization helpers out of LCMEngine into a new
`sanitize.py` module:
- `_contains_sensitive_redaction`, `_structured_part_text`,
  `_structured_part_has_visible_assistant_content`,
  `_assistant_message_has_visible_content`, `_strip_structured_text_part`,
  `_sanitize_active_assistant_content`, `_clean_active_assistant_message`,
  `_should_drop_active_assistant_message`, and the
  `_VISIBLE_TEXT_PART_TYPES` / `_INTERNAL_ASSISTANT_PART_TYPES` constants.

These are pure functions that shape the active replay context (strip
internal/reasoning assistant content, decide visible-content, detect sensitive
redaction markers); they never touch stored rows. engine.py imports the three
with external callers and calls them as module functions; the remaining
call sites were the cluster's own internal cross-calls, which move with it.

Behaviour-preserving: bodies are moved verbatim -- the only edits are dropping
the `@staticmethod`/`@classmethod` decorators and `cls`/`self` params and
rewriting the intra-cluster `cls._x(...)` calls to bare module calls. No import
cycle: the module depends only on `escalation._strip_reasoning_blocks` and
itself. The stateful/config-coupled sanitizers (`_sanitize_active_context_messages`
orchestrator, `_sanitize_tool_pairs`, the preserved-objective helpers, and
`_redact_active_replay_messages`) intentionally stay on the engine.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the ~35 repetitive `c.X = _int/_float/_bool/_str("LCM_X", c.X)`
assignments in `LCMConfig.from_env` with a single declarative registry
`ENV_FIELD_SPECS` (name, env_key, py_type) applied by a uniform loop, and let
`presets.py` derive its preset-field env/parse lookups from the same registry
instead of two hand-maintained parallel dicts.

Source-tracked fields (provenance recording and/or computed defaults --
fresh_tail_count, leaf_chunk_tokens, context_threshold, the summary_spend_* and
summary_timeout_ms fields, codex autoraise) stay explicit and are skipped by the
loop; pattern-list overrides with a source sidecar and the inline
gc_max_age_hours parse also stay explicit.

Behaviour-preserving: verified byte-identical with an equivalence harness that
snapshots `from_env()` (full asdict + config_sources + config_source_warnings)
and the preset override discovery across 600 scenarios (every LCM_* key absent /
valid / invalid) -- 0 differing scenarios. Full suite: 1545 passed, 12 xfailed.

Removes the config/presets field-mapping duplication the design flagged, giving
one source of truth for the scalar LCM_* env overrides.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The /lcm doctor clean and retention scanners ran their session-stat SQL
directly against the store connection from the command layer. Move those two
raw queries behind named MessageStore methods:
- `scan_session_cleanup_stats()` -> (session_id, message_count, token_total,
  node_count) rows for clean-candidate scanning.
- `scan_session_retention_stats(session_id)` -> per-session activity/token
  stats for retention scanning.

`command.py`'s `_scan_clean_candidates` / `_scan_retention_candidates` keep all
the pattern-match / staleness / protection policy and just call these methods
inside their existing try/except. The store now owns the SQL and its connection.

Behaviour-preserving: the queries and their parameters are unchanged, callers'
error handling is unchanged (the method raises, the caller's `except` still
catches it). Full suite: 1545 passed, 12 xfailed.

First slice of WS5.5 (layer discipline: raw command/tools queries -> Store/DAG
methods). Stacked on #323 (store-connection encapsulation).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Encapsulate MessageStore connection behind a public accessor
Move backup/rotate maintenance ops into maintenance.py (WS5 seam 3)
Lift /lcm doctor session-stat scans into MessageStore (WS5.5)
Extract active-context sanitizers into sanitize.py (WS5 seam 2)
Drive scalar env config from a declarative field spec (WS5.6)
Move the four Codex-route helpers and the effective context-cap table out
of engine.py into a dedicated codex_routing.py module: _bare_model_slug,
_is_openai_codex_route, _codex_oauth_context_cap, _is_codex_gpt55_route, and
_CODEX_OAUTH_CONTEXT_CAPS. engine.py imports the two it calls
(_codex_oauth_context_cap, _is_codex_gpt55_route); the internal helpers and
the caps table travel with them. The gpt-5.5 compaction threshold constant
(_CODEX_GPT55_COMPACTION_THRESHOLD) stays in engine.py as engine-side policy.

Behaviour-preserving refactor (WS5 engine decomposition). The moved code is
byte-identical to its pre-move form. Rollback: git revert this commit
restores the inline block; no data/state migration involved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the SQLite busy/lock utilities out of engine.py into a dedicated
sqlite_util.py module: _is_sqlite_locked_error (exception-chain lock detection),
_sqlite_busy_timeout_ms (PRAGMA probe, internal), and the
_temporary_sqlite_busy_timeout context manager. engine.py imports the two it
calls (_is_sqlite_locked_error, _temporary_sqlite_busy_timeout); the probe
travels with them. The session-end timeout budget constant
(_SESSION_END_BUSY_TIMEOUT_MS) stays in engine.py as caller-side policy. Drops
the now-unused `contextmanager` and `typing.Iterator` imports from engine.py.

Behaviour-preserving refactor (WS5 engine decomposition). The moved code is
byte-identical to its pre-move form. Rollback: git revert this commit restores
the inline block; no data/state migration involved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the plugin- and git-identity provenance helpers out of engine.py into a
new runtime_identity.py module: _strip_metadata_scalar, _plugin_metadata (with
its _PLUGIN_METADATA manifest cache and _PLUGIN_ROOT), and _git_runtime_identity.
These feed the plugin_* fields of the runtime status/doctor payload. engine.py
imports the three symbols it references (_plugin_metadata, _git_runtime_identity,
_PLUGIN_ROOT); _strip_metadata_scalar and the cache stay internal to the module.
The move drops engine.py's now-unused `subprocess` import.

Tests that reached these through the engine module are retargeted to the new
module: the two _plugin_metadata tests patch/read runtime_identity._PLUGIN_METADATA
(the cache moved with its owner), and the two _git_runtime_identity packaging
tests patch runtime_identity.subprocess (engine no longer imports subprocess).

Behaviour-preserving refactor (WS5 engine decomposition). The moved functions are
byte-identical to their pre-move form; only their module-local logger name
changes. Rollback: git revert this commit restores the inline block; no data or
state migration involved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the pure message-list inspection helpers out of engine.py into a new
message_analysis.py module: _tool_call_id, _assistant_tool_call_ids,
_matched_tool_call_ids, and _is_synthetic_assistant_noise (with its
_SYNTHETIC_ASSISTANT_NOISE vocabulary). engine.py imports the four it calls;
the noise vocabulary stays internal to the module.

Behaviour-preserving refactor (WS5 engine decomposition). The moved code is
byte-identical to its pre-move form. Rollback: git revert this commit restores
the inline block; no data or state migration involved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the process-wide active-LCM-clone registry out of engine.py into a new
engine_registry.py module: the registry lock, the two WeakValueDictionary
registries (by session id / conversation id), the resolver/matcher helpers
(_is_usable_lcm_engine, _engine_matches_session_binding,
_engine_matches_conversation_binding), the removal helper
(_remove_registry_entries_for_engine), and the public resolve_active_lcm_engine.

engine.py imports the shared lock, the two registries, the removal helper, and
resolve_active_lcm_engine. The LCMEngine binding methods
(_register_active_engine_binding / _unregister_active_engine_binding) keep their
bodies verbatim: the lock and the weak registries are shared mutable objects, so
mutating them through the imported names hits the same objects. The matcher
helpers are internal to the module. resolve_active_lcm_engine is re-exported from
engine.py so hosts importing it from hermes_lcm.engine keep working.

Behaviour-preserving refactor (WS5 engine decomposition). The moved code is
byte-identical to its pre-move form. Rollback: git revert this commit restores
the inline block; no data or state migration involved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the compaction gate + pipeline out of LCMEngine into a CompactionMixin in
the new compaction.py, and mix it in AHEAD of ContextEngine
(`class LCMEngine(CompactionMixin, ContextEngine)`).

Moved verbatim (byte-identical bodies): should_compress, should_compress_preflight
(public), _replay_diff_requests_ingest_cleanup, _has_ignored_backlog_outside_fresh_tail,
_leaf_compaction_candidate_status, _working_leaf_chunk_tokens, _select_oldest_leaf_chunk,
and the main compress entry point. They keep running bound to the engine instance,
so `_ingest_cursor`, `_store`, `_dag`, `_lifecycle`, the per-turn caches and the
~60 sibling helpers stay on the engine and resolve via self.

Two deliberate boundary choices:
- The mixin is placed BEFORE ContextEngine in the bases so its compress /
  should_compress / should_compress_preflight override the ContextEngine
  protocol defaults (which also define those names).
- The summarize-with-rescue sub-cluster (_summarize_leaf_chunk_with_rescue and
  its _next_leaf_rescue_chunk / _is_retry_worthy_leaf_summary_error helpers)
  stays on the engine, because ~40 tests patch the module-level
  summarize_with_escalation on hermes_lcm.engine to intercept the LLM call.
  compress() reaches it via self as a callback-out, so behaviour is unchanged and
  no test is touched.

engine.py shrinks by ~752 lines. Drops engine.py's now-exclusive
`_contains_sensitive_redaction` import (moved to compaction.py).

Behaviour-preserving refactor (WS5 engine decomposition). Rollback: git revert
this commit re-inlines the methods; no data or state migration involved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the 35 ignored-active-replay placeholder / dependent-reply bookkeeping
methods out of LCMEngine into a PlaceholderLedgerMixin in the new
placeholder_ledger.py, and mix it into LCMEngine
(`class LCMEngine(ContextEngine, PlaceholderLedgerMixin)`).

The methods are moved verbatim (byte-identical bodies) and keep running bound to
the engine instance: `self` is the LCMEngine, so they read/write the engine's
shared runtime state (`_store`, `_session_id`, the two
`_generated_ignored_active_replay_placeholder_*` sets, the per-turn
`_current_compress_store_ids_by_message_id` cache) and call engine helpers
(`_get_store_id_map_for_messages`, `_matches_ignore_message_patterns`,
`_stable_tool_calls_identity`, `_message_replay_identity`,
`_copy_active_replay_messages_preserving_generated_ids`) through normal attribute
lookup via the MRO. No call sites and no tests change; the ~130 white-box tests
in tests/test_lcm_engine.py exercise the methods unchanged.

engine.py shrinks by ~657 lines. The two shared placeholder-id sets and the
per-turn store-id cache stay owned by LCMEngine (they are also touched by
sanitize/reconcile methods that remain on the engine), so the mixin is a pure
relocation, not a state-ownership change.

Behaviour-preserving refactor (WS5 engine decomposition). Rollback: git revert
this commit re-inlines the methods; no data or state migration involved.

Bases order: the mixin precedes ContextEngine so the LCM decomposition mixins
resolve consistently across seams (required for CompactionMixin, which overrides
the compress/should_compress protocol methods; harmless for private-only mixins).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the ingest-cursor reconciliation machinery and the stable replay-identity
primitives it relies on out of LCMEngine into a ReconcileMixin in the new
reconcile.py, and mix it in (`class LCMEngine(ContextEngine, ReconcileMixin)`).

Moved (20 methods): the tool-call/message replay-identity primitives, the
persisted-output durable-replay evidence helpers, and the reconciliation
orchestration (_find_reconciled_cursor_for_store_tail,
_reconcile_ingest_cursor_from_store, _get_store_id_map_for_messages, etc.). They
keep running bound to the engine instance, so _store, _session_id, _config and
the safety-critical _ingest_cursor stay on the engine and resolve via self;
_ingest_cursor is still written by _ingest_messages from the reconciler's return.

Method bodies are byte-identical to their pre-move form except two mechanical,
behaviour-preserving adjustments:
- The two tool-call-identity staticmethods call the identity canonicalizer via
  the mixin class (ReconcileMixin._canonicalize_tool_call_identity_value) instead
  of LCMEngine, to avoid an engine<->reconcile import cycle. staticmethod
  resolution is identical (LCMEngine inherits it from the mixin).
- _PRESERVED_OBJECTIVE_CONTEXT_PREFIX moves to reconcile.py (used by the
  reconciliation scan) and is re-imported into engine.py for the other users, so
  no code that reads it changes.

engine.py shrinks by ~1105 lines and drops six now-exclusive ingest_protection/
externalize imports (moved to reconcile.py). No call sites and no tests change.

Behaviour-preserving refactor (WS5 engine decomposition). Rollback: git revert
this commit re-inlines the methods; no data or state migration involved.

Bases order: the mixin precedes ContextEngine so the LCM decomposition mixins
resolve consistently across seams (required for CompactionMixin, which overrides
the compress/should_compress protocol methods; harmless for private-only mixins).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the auxiliary / subagent session lifecycle tracking out of LCMEngine into
an AuxiliarySessionMixin in the new aux_session.py, and mix it in
(`class LCMEngine(ContextEngine, AuxiliarySessionMixin)`).

Moved verbatim (27 methods): the per-thread auxiliary stack, active/lineage
session-id sets, register/deactivate/handoff, the process-local generation
tokens, and the host-coupled caller-frame + state.db ancestor detection. They
keep running bound to the engine instance, so the auxiliary state (initialised
in __init__, cleared in _reset_profile_runtime_state, guarded by
_auxiliary_session_lock) and the callbacks (_end_host_fallback_compressor_for_session,
_state_db_path) stay on the engine and resolve via self.

The bypass-lineage family (which shares _auxiliary_session_lock but is a separate
concern) and the active-engine registry stay on the engine. engine.py shrinks by
~695 lines and drops its now-unused `inspect` import (moved to aux_session.py).
No call sites and no tests change; the ~117 white-box test references that call
these methods on an LCMEngine instance resolve via the mixin.

Behaviour-preserving refactor (WS5 engine decomposition). Rollback: git revert
this commit re-inlines the methods; no data or state migration involved.

Bases order: the mixin precedes ContextEngine so the LCM decomposition mixins
resolve consistently across seams (required for CompactionMixin, which overrides
the compress/should_compress protocol methods; harmless for private-only mixins).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
LCM detects whether a session is a subagent/auxiliary child (and who its parent
is) so it can attribute lineage correctly. Until now the only signal it had at
on_session_start time was to walk up ~32 call-stack frames with
inspect.currentframe() and sniff private agent attributes (_subagent_id,
_delegate_depth, log_prefix, enabled_toolsets, …). That is fragile: it breaks
silently if the host renames any of those internals, and it couples LCM to
undocumented host internals.

Hosts that expose the plugin hook bus already publish the same information
explicitly: the subagent_start / subagent_stop events carry child_session_id and
parent_session_id (plus the subagent ids and role). This subscribes LCM to those
events and records the child->parent linkage in a small process-wide registry in
aux_session.py. _in_process_parent_session_id now consults that explicit registry
before falling back to the frame walk, so on a modern host the parent is resolved
from the host's own signal with no stack inspection.

Behaviour is unchanged where the explicit signal is absent: an explicit
parent_session_id kwarg still wins first, the frame walk still runs when no
subagent_start was recorded, and hosts without a register_hook bus simply skip
the subscription (guarded with getattr/callable) and keep the legacy path.

- aux_session.py: _SUBAGENT_LINEAGE_BY_SESSION_ID registry + record_subagent_start
  / record_subagent_stop / explicit_subagent_lineage helpers (locked, memory-
  bounded); explicit lookup wired into _in_process_parent_session_id.
- __init__.py: register subagent_start/stop hooks when the host provides them.
- tests/test_subagent_lineage.py: registry roundtrip, explicit-over-frame-walk
  precedence, hook registration, and the no-hook-bus no-op fallback.

Full suite: 1575 passed, 12 xfailed. Builds on Seam 5 (aux_session.py, #337).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
command.py and tools.py reached into the private SQLite connections of the
summary DAG (engine._dag._conn) and the lifecycle store
(engine._lifecycle._conn) to run read-only doctor/inspect diagnostics — FTS sync
counts, integrity checks, latest-node lookups, the maintenance-debt scan. That
bypasses each store's encapsulation and its write-lock contract.

Mirror the MessageStore.connection accessor added in #323: add a read-only
`connection` property to SummaryDAG and LifecycleStateStore (returns the live
connection or None), and route the seven cross-module reach-ins in command.py /
tools.py through it. Writes still go through each store's own methods, so the
lock contract stays in one place; this only makes the read-only diagnostic
access explicit and layer-clean.

Behaviour-preserving: the accessor returns the same connection object the
reach-in read. Full suite: 1570 passed, 12 xfailed (199 doctor/inspect/fts/
lifecycle tests now exercise the accessors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract Codex OAuth route detection into codex_routing.py (WS5 seam)
Extract SQLite lock-contention helpers into sqlite_util.py (WS5 seam)
Extract active-engine registry into engine_registry.py (WS5 seam)
Extract placeholder-ledger bookkeeping into a mixin (WS5 Seam 1)
Encapsulate DAG/lifecycle connections behind a public accessor (WS5.5)
# Conflicts:
#	engine.py
Prefer the host's explicit subagent signal over the call-stack frame walk (WS5.7)
# Conflicts:
#	engine.py
Extract plugin/git runtime-identity helpers into runtime_identity.py (WS5 seam)
# Conflicts:
#	engine.py
Extract message tool-call/noise analysis into message_analysis.py (WS5 seam)
# Conflicts:
#	engine.py
Extract ingest-cursor reconciliation into a mixin (WS5 Seam 4)
# Conflicts:
#	engine.py
Extract the leaf-compaction pipeline into a mixin (WS5 Seam 6)
* docs: document the WS5 engine decomposition and module layout

Update docs/architecture.md's "Development" file map (which listed only 8 of the
plugin's modules) into a complete, concern-grouped module map, and add an
"Engine decomposition" subsection describing the behaviour-preserving mixin /
module extraction pattern used to break up the ~9k-line engine.py. Add an
Unreleased CHANGELOG entry for the decomposition.

Documents the structure established by the WS5 seam PRs (mixins: compaction.py,
reconcile.py, placeholder_ledger.py, engine_registry.py; pure modules:
codex_routing.py, sqlite_util.py, runtime_identity.py, message_analysis.py) plus
the modules already on main. Docs-only; no code change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: align WS5 architecture notes with merged modules

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Stephen Schoettler <stephenschoettler@gmail.com>
Move the 3 methods that clear session-scoped counters, compaction
progress, and per-turn placeholder-boundary bookkeeping on session
reset/rollover (_reset_session_counters, _reset_compaction_progress,
_reset_session_scoped_runtime_state) verbatim out of engine.py into a
new ResetStateMixin in reset_state.py. LCMEngine mixes it in before
ContextEngine, so `self` still resolves every reference; call sites,
shared state, and method names are unchanged.

Behaviour-neutral: the moved method bodies are byte-identical to main
(AST-slice compare, 0 diffs). ruff clean; full suite 1575 passed /
12 xfailed (unchanged from the v0.19.0 baseline).
In the multi-agent deployment (gateway + CLI sessions + sub-agents)
every process opens its own connection to the same lcm.db and runs the
startup migrations concurrently. Each column migration did a
check-`PRAGMA table_info`-then-`ALTER TABLE ADD COLUMN` in autocommit
with no guard, so two processes could both observe a column as absent
and both issue the ALTER; the loser raised
`sqlite3.OperationalError: duplicate column name`, which propagated out
of `_init_db` and crashed store construction. This bites exactly at an
upgrade boundary, when many processes restart together.

Add `add_column_if_missing(conn, existing_columns, column, alter_sql)`
in db_bootstrap.py: it keeps the existing "skip if already present"
check and additionally swallows exactly the `duplicate column name`
OperationalError (any other error still propagates), making the ALTER
idempotent under concurrency. Route all eleven column migrations
through it (db_bootstrap `ensure_lifecycle_state_columns` /
`ensure_message_origin_columns`, `store._ensure_source_column` /
`_ensure_conversation_id_column`, `dag._ensure_source_window_columns`).

Behaviour is unchanged on the non-racing path (same check, same ALTERs);
only the concurrent loser now skips instead of crashing.

Rollback: revert this commit; the migrations return to the prior
unguarded check-then-ALTER.

Validation:
- New regression test `TestConcurrentStartupMigration` races
  `ensure_message_origin_columns` from 8 threads (each its own
  connection to one seeded pre-v5 DB, FTS-free to isolate the column
  DDL). Without the guard 7/8 threads raise
  `duplicate column name: conversation_id`; with it every thread
  migrates and the column exists exactly once.
- ruff clean; full suite passes.

Note (out of scope, follow-up): the FTS structural rebuild
(`repair_external_content_fts`) has a separate concurrency window on a
different trigger (missing shadow tables / corruption, not column
upgrades); a column migration does not change row counts so it does not
enter that path. Left for a focused follow-up.
`_strip_reasoning_blocks` only removes *closed* <think>...</think> pairs
via a backreferenced regex. A reasoning model (Qwen/QwQ/DeepSeek-R1,
GLM, MiniMax) that runs into `max_tokens` before emitting the closing
tag leaves an *unclosed* block the regex cannot match. The leftover raw
reasoning — which, per the existing module comment, often quotes the
summarizer system prompt verbatim — was then accepted as the summary
purely because `count_tokens(result) < source_tokens`, persisted as a
SummaryNode, and replayed into the model as context (and re-fed by
`lcm_expand_query`). That is both a quality bug and a system-prompt
leak.

Add `_sanitize_reasoning_summary(text)`: after stripping closed blocks
it returns "" when the remainder is empty or still begins with a
reasoning marker (new `_REASONING_START_RE`, which also covers non-tag
shapes: `<|think|>`, `[think]`, and prose headers like
`Thinking Process:` / `Chain of thought:`). `_call_llm_for_summary`
now returns that sanitized value, so a reasoning-only response is falsy
and the existing escalation path advances to the next model / L2 /
deterministic L3 truncation of the real source text instead of storing
the reasoning. A one-line warning records the discard for operators.

Behaviour is unchanged for normal summaries and for already-handled
closed reasoning blocks; only unusable reasoning-only output now
escalates instead of being persisted.

Rollback: revert this commit; `_call_llm_for_summary` returns to
`_strip_reasoning_blocks(content).strip()`.

Validation:
- `_sanitize_reasoning_summary` unit test (closed->clean, unclosed->"",
  prose/pipe/bracket shapes->"", empty->"").
- `_call_llm_for_summary` integration: an unclosed <think> response now
  returns "" (proven to return the raw reasoning blob without the fix).
- Escalation test: a reasoning-only primary escalates to the fallback
  model rather than accepting the empty result.
- ruff clean; full suite 1578 passed / 12 xfailed.

Note: the same closed-tag-only strip is used in extraction.py, tools.py
and sanitize.py; those non-summary paths are left for a focused
follow-up so this PR stays scoped to the persisted-summary leak.

Refs: mirrors lossless-claw `sanitizeReasoningSummaryText`
(src/summarize.ts), which forces the empty-summary path on
`reasoningOnly` output.
The Level-3 deterministic-truncation fallback sized its head/tail cut
with a flat `char_budget = max_tokens * 4`. HL counts tokens accurately
with tiktoken everywhere else, but here CJK / dense scripts (~1-2
chars/token) made the char budget overshoot the real token budget ~2-4x
-- so an L3 summary aimed at N tokens could emit 2-4N, overflowing the
very budget L3 exists to guarantee and defeating overflow recovery on
non-English conversations.

Add `truncate_text_to_tokens(text, max_tokens, *, from_end=False)` in
tokens.py: an exact tiktoken-encoder cut when available, with a
script-density-aware char fallback (corrected in a few bounded steps so
it never exceeds the budget) when tiktoken is absent. Rewrite
`_deterministic_truncate` to budget head+tail in tokens, reserving the
marker's token cost so the total honours `max_tokens`.

Behaviour for Latin text is materially unchanged (chars*4 was already
about right there); CJK/dense-script output now respects the budget.

Rollback: revert; `_deterministic_truncate` returns to the chars*4 cut.

Validation:
- `truncate_text_to_tokens` respects the budget (head/tail, short text,
  zero budget).
- `_deterministic_truncate` on dense CJK now yields far fewer tokens
  than the reconstructed old chars*4 output and stays within ~1.4x of
  the budget (vs the old ~2-4x); converges; Latin head/tail preserved.
- ruff clean; full suite 1579 passed / 12 xfailed.

Refs: lossless-claw budgets its deterministic cap with the Unicode-aware
estimateTokens (compaction.ts capSummaryText), which HL's L3 bypassed.
Make schema-column migrations idempotent under concurrent startup
Discard reasoning-only summaries instead of persisting them (unclosed <think>)
Budget L3 deterministic truncation in tokens, not chars*4 (CJK overshoot)
Extract session-scoped state resets into a mixin (WS5 seam)
fix: prevent LCM scaffold recursion and preflight skips
Move the 14 methods that handle sessions bypassing LCM context
management -- bypass detection, host-fallback-compressor mirroring,
and the deterministic bypass tail-compaction -- verbatim out of
engine.py into a new BypassMixin in bypass.py. LCMEngine mixes it in
before ContextEngine, so `self` still resolves every reference; call
sites, shared state, and method names are unchanged.

engine.py -474 lines. The two imports the block solely used
(`importlib`, `_assistant_tool_call_ids`) are dropped from engine.py
and declared in bypass.py.

Behaviour-neutral: the moved method bodies are byte-identical to
main (AST-slice compare, 0 diffs). ruff clean; full suite
1575 passed / 12 xfailed (unchanged from the v0.19.0 baseline).
Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com>
Recheck auxiliary/background-review markers on the first durable ingest before any rows are stored, preventing background review replay pollution when host markers appear after bind-time.

Co-authored-by: Hermes Turing <turing@local>
Co-authored-by: Hermes Turing <turing@local>
OpenAI's Codex model catalog reports context_window=372000 for gpt-5.6
Sol/Terra/Luna (https://github.com/openai/codex/blob/main/codex-rs/models-manager/models.json).
The previous _CODEX_OAUTH_CONTEXT_CAPS table had no gpt-5.6 entry, so
bare-slug matching fell through to the generic 'gpt-5' -> 272_000 fallback,
clamping the real 372K window down to 272K and firing compression ~100K
tokens too early.

Add a 'gpt-5.6' key (372_000) so all gpt-5.6* slugs resolve to the real
window. Mirrors NousResearch/hermes-agent PR #61910 (core side) and the
Reddit 'Fix the context window for GPT-5.6' report.
test: preserve full legacy content in import expansion
Some checks failed
CI / workflow-lint (pull_request) Failing after 9s
CI / lint (pull_request) Successful in 56s
CI / test (3.11) (pull_request) Failing after 14m58s
CI / test (3.12) (pull_request) Failing after 10m48s
CI / test (3.14) (pull_request) Has been cancelled
CI / test (3.13) (pull_request) Has been cancelled
1dc164ecbd
clawlter changed title from chore: synchronize canonical upstream v0.19.0 to [DRAFT] chore: synchronize canonical upstream v0.19.0 2026-07-12 18:32:30 -04:00
ci: provision actionlint and shard pytest
Some checks failed
CI / workflow-lint (pull_request) Successful in 7m5s
CI / lint (pull_request) Failing after 45s
CI / test (3.11, 0) (pull_request) Failing after 2m19s
CI / test (3.11, 1) (pull_request) Successful in 8m34s
CI / test (3.12, 0) (pull_request) Failing after 2m35s
CI / test (3.12, 1) (pull_request) Successful in 9m17s
CI / test (3.13, 0) (pull_request) Failing after 2m40s
CI / test (3.13, 1) (pull_request) Successful in 9m20s
CI / test (3.14, 0) (pull_request) Failing after 2m48s
CI / test (3.14, 1) (pull_request) Successful in 3m26s
CI / static-validation (pull_request) Successful in 6m59s
7daa69cfb2
ci: fix shard runner ruff violation
Some checks failed
CI / workflow-lint (pull_request) Successful in 1m8s
CI / lint (pull_request) Successful in 1m10s
CI / test (3.11, 0) (pull_request) Failing after 23m32s
CI / test (3.11, 1) (pull_request) Successful in 10m46s
CI / test (3.12, 0) (pull_request) Failing after 3m20s
CI / test (3.12, 1) (pull_request) Successful in 11m0s
CI / test (3.13, 0) (pull_request) Failing after 3m38s
CI / test (3.13, 1) (pull_request) Successful in 11m15s
CI / test (3.14, 0) (pull_request) Failing after 2m48s
CI / test (3.14, 1) (pull_request) Successful in 10m23s
CI / static-validation (pull_request) Successful in 42s
f523a5dc7c
Author
Owner

Closed because this cross-project audit should not modify upstream forks without a separate explicit contribution request. The campaign has been stopped and this work is being reverted/removed from scope.

Closed because this cross-project audit should not modify upstream forks without a separate explicit contribution request. The campaign has been stopped and this work is being reverted/removed from scope.
clawlter closed this pull request 2026-07-13 08:04:40 -04:00
Some checks are pending
CI / workflow-lint (pull_request) Successful in 1m8s
CI / lint (pull_request) Successful in 1m10s
CI / test (3.11, 0) (pull_request) Failing after 23m32s
CI / test (3.11, 1) (pull_request) Successful in 10m46s
CI / test (3.12, 0) (pull_request) Failing after 3m20s
CI / test (3.12, 1) (pull_request) Successful in 11m0s
CI / test (3.13, 0) (pull_request) Failing after 3m38s
CI / test (3.13, 1) (pull_request) Successful in 11m15s
CI / test (3.14, 0) (pull_request) Failing after 2m48s
CI / test (3.14, 1) (pull_request) Successful in 10m23s
CI / static-validation (pull_request) Successful in 42s
CI / test (3.11) (pull_request)
Required
CI / test (3.12) (pull_request)
Required
CI / test (3.13) (pull_request)
Required

Pull request closed

Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
clawlter/hermes-lcm!2
No description provided.