# Hermes Session Compactor

Finds related sessions in your Hermes Agent database (`~/.hermes/state.db`),
merges them into ONE clean consolidated session written **back into the
same database**, and archives the originals so you can roll back. You
resume the new session with `hermes -r "<clean title>"` — no separate
folders, no copy-paste wiring.

Three commands:

- **`compact`** — find duplicate/continuation sessions, merge them into a new
  one with a clean title, archive + delete originals.
- **`rename-titles`** — give Hermes sessions that have auto-generated junk
  titles (`20260515_100000_dddddd`) clean LLM-generated names. No merging,
  just renames.
- **`dream`** — extract durable findings from compacted digests into a
  cross-session memory index (`HERMES_MEMORY.md`). Optional; overlaps
  partly with Hermes's native MEMORY.md (see overlap table below).

All three are dry-run by default. `--apply` does the writes.

Restore is one command away: `hermes-compact restore <archive-path>.json`
re-inserts an archived original back into `state.db` under its original ID.

Also handles cross-harness consolidation (Hermes + OpenCode + Aider +
Goose) if you enable those sources in `config.yaml`. Hermes is the only
one enabled by default.

Built to slot into Hermes as a standalone script or as an agent-invokable
skill (see `SKILL.md`).

## Status / what's verified vs. guessed

This was built without access to your actual machine, so be honest with
yourself about what's confirmed and what isn't before trusting `--apply`:

| Piece | Confidence |
|---|---|
| Hermes (NousResearch) state.db location, sessions/messages schema, parent_session_id lineage | Verified against NousResearch/hermes-agent hermes_state.py and official docs |
| OpenCode SQLite storage (`opencode.db`/`opencode-prod.db`, schema with project/session/message/part tables) | Verified against multiple community projects and OpenCode docs; adapter uses defensive `PRAGMA table_info()` for schema variations |
| OpenCode legacy JSON storage layout | Verified, kept as automatic fallback when no DB exists |
| OpenCode subagent (`parent_id`) handling | Verified, attached to parent rather than treated as independent |
| Claude Code storage location (`~/.claude/projects/*.jsonl`) | Verified |
| Claude Code per-line JSON schema | Read defensively (each line's shape may vary across versions) |
| Aider session boundaries (`.aider.chat.history.md` + header regex) | Conventional, **not verified against a live install** |
| Goose storage location/format | **Unverified** -- treat `sources.goose.sessions_dir` as a placeholder |
| Clustering, judge/synthesis prompts, archive/manifest, lockfile, memory consolidation | Fully implemented and tested (33 tests, all passing) |

## Important: overlap with Hermes Agent's native features

Hermes already ships with several features that overlap what this tool
does. **Use ours when you need cross-harness consolidation; use theirs
when you only care about Hermes.** The overlap matrix:

| Feature | Hermes native | This tool |
|---|---|---|
| Prune old sessions by age | `hermes sessions prune --older-than-days 30` | `purge-archive --older-than 30 --confirm` (but on OUR archive, not Hermes's DB) |
| Search past sessions | FTS5 `session_search` tool (built into Hermes) | We don't do search at all |
| Compress long-running sessions | Built-in (`compression.threshold: 0.50`) | Out of scope -- different problem |
| Auto-curated cross-session memory | `~/.hermes/memories/MEMORY.md` + `USER.md` | `~/.hermes/memory/HERMES_MEMORY.md` (different file!) via `dream` |
| External memory providers (Honcho/Mem0/Supermemory/etc) | Built-in via `hermes memory setup` | We don't integrate -- they're separate paths |
| Skills auto-generation | Built-in self-improvement loop | Out of scope |
| **Find duplicate/continuation sessions and merge them** | **Not done by Hermes** | **This is our unique value** |
| **Cross-harness consolidation (Hermes + OpenCode + Aider + Goose)** | **Not done by Hermes** | **This is our unique value** |

If you only run Hermes (no other harnesses) AND you're happy with
age-based pruning, you may not need this tool at all -- Hermes's
built-ins cover most of it. The reason to use ours is specifically:
finding duplicate/continuation sessions (which Hermes doesn't),
and/or consolidating across multiple harnesses.

In short: the pipeline (clustering → judge → synthesize → write-back →
archive) is solid and tested. The adapters are the part that need a few
minutes of `--inspect` against your real data before you trust them.

## Setup

```bash
cd hermes-session-compactor
pip install -r requirements.txt --break-system-packages   # or use a venv
cp config.example.yaml config.yaml
# edit config.yaml: confirm/fix paths, leave llm.backend: mock for now
```

## Two complementary commands: `compact` and `dream`

**`compact`** — finds duplicate/continuation sessions across your harnesses,
synthesizes one clean session per cluster, archives originals. This is the
"clean up the session archive" part.

**`dream`** — reads the compacted digests, extracts durable findings
(decisions, code-change patterns, gotchas), and maintains
`~/.hermes/memory/HERMES_MEMORY.md` plus per-entry topic files. This is the
"give every new session a curated knowledge base to start from" part. See
`ATTRIBUTION.md` for which patterns came from the dream-tool ecosystem.

Typical flow:

```bash
hermes-compact compact --apply     # consolidate sessions, write digests
hermes-compact dream --apply        # consolidate digests into memory index
```

`dream` prints copy-paste wiring instructions for each harness at the end of
`--apply` — it never auto-modifies your existing `AGENTS.md`, `CLAUDE.md`, or
Aider config.

## First run -- verify before you trust anything

```bash
# 1. See what raw OpenCode session JSON actually looks like on your machine
python -m hermes_compactor.cli discover --inspect opencode --limit 2

# Compare the keys you see against GUESS_TITLE_KEYS / GUESS_DIR_KEYS / etc
# in hermes_compactor/adapters/opencode.py. Fix the lists if your install
# uses different field names.

# 2. Once that looks right, check discovery end to end
python -m hermes_compactor.cli discover

# 3. Check heuristic clustering (no LLM calls, free, fast)
python -m hermes_compactor.cli cluster

# 4. Point llm.backend at your real llama-swap endpoint in config.yaml,
#    then dry-run the full pipeline
python -m hermes_compactor.cli compact

# 5. Only once the dry-run report looks right:
python -m hermes_compactor.cli compact --apply
```

## Enabling other sources

Each source is off by default except OpenCode. In `config.yaml`:

```yaml
sources:
  claude_code:
    enabled: true
    projects_dir: ~/.claude/projects
  aider:
    enabled: true
    project_dirs:
      - ~/code/unspooled
      - ~/code/opnsense-notes
      # one entry per repo aider has been run in
```

Then re-run `discover --inspect <source>` for each one you enable before
trusting it.

## Pointing at your llama-swap endpoint

```yaml
llm:
  backend: openai_compatible
  base_url: http://localhost:8080/v1   # your llama-swap port
  model: qwen3.6-35b                    # whatever alias llama-swap routes to it
```

Cost/runtime scales with how much transcript text you feed it -- the
`excerpt_chars_per_session` setting per-adapter and `min_confidence` /
`match_threshold` in `clustering` are your levers if a run is slow or the
judge is being too trigger-happy.

## Cleaning up old archives

Archiving never auto-deletes. When you're confident you don't need the
older backups:

```bash
python -m hermes_compactor.cli purge-archive --older-than 30          # dry run
python -m hermes_compactor.cli purge-archive --older-than 30 --confirm # actually delete
```

## Running the test suite

```bash
pip install pytest --break-system-packages
pytest tests/ -v
```

Tests run entirely against bundled fixtures with the mock LLM backend --
no network calls, no real session files touched.

## Project layout

```
hermes_compactor/
  models.py        Session / Cluster / SynthesisResult dataclasses
  adapters/         one file per tool, each turns native format -> Session
  cluster.py        cheap heuristic candidate clustering (no LLM)
  llm_client.py      openai_compatible + mock backends
  judge.py           classifies clusters: duplicate / continuation / related_not_mergeable / unrelated
  synthesize.py       turns a mergeable cluster into a clean title + markdown body
  writeback.py         writes the new session + archives (never deletes) originals
  cli.py               discover / cluster / compact / purge-archive subcommands
config.example.yaml
SKILL.md              agent-invokable skill definition
RESEARCH_AND_VISION.md  why this is built this way
tests/
```

## Design rules this code follows (don't relax these casually)

1. **Never modify or delete an original session file directly.** Originals
   are atomically moved (`shutil.move`) into the archive, never copied and
   then unlinked separately. There is no window during which both copies or
   neither copy exists.
2. **Dry run by default, no LLM by default.** `compact` without flags runs
   discover + cluster + judge only — no synthesis calls, no GPU time. Pass
   `--preview-synthesis` to also synthesize drafts (still no writes). Pass
   `--apply` for the full real pipeline.
3. **Exclusive lockfile on `--apply`.** Two concurrent `compact --apply`
   runs can't race on the manifest. A stale lock (>1h by default, configurable)
   auto-recovers, so a dead process can't lock you out forever.
4. **Active sessions are protected.** On `--apply`, any session whose
   underlying file was touched within `archive.min_idle_seconds` (default 2h)
   is excluded as presumed-active — so the tool can't archive a conversation
   you're currently in.
5. **Protected paths are protected.** Anything matching a glob in
   `clustering.protected_path_globs` is excluded from every run, regardless
   of how the heuristic or judge would have scored it.
6. **Conservative judge.** Below `llm.min_confidence`, a cluster is
   downgraded to `related_not_mergeable` rather than merged. See
   `tests/test_pipeline.py::test_judge_is_conservative_below_confidence_threshold`.
7. **Provenance always.** Every synthesized session's markdown body has a
   footer listing its source session ids; every manifest entry lists the
   full set of sibling sources from the same consolidation event so a
   rollback can find them all in one query.
8. **One bad session shouldn't kill a run.** Adapters log a warning and skip
   on parse failure rather than raising. Judge/synthesis failures are
   captured to `archive.failures_path` so problem clusters are easy to find
   later instead of being lost in stdout.
9. **The compactor never feeds on its own output by default.** Sessions it
   previously synthesized are excluded from candidate clustering unless you
   explicitly pass `--include-compacted`. If one IS swept back in, its linked
   markdown digest is archived alongside the JSON stub, never orphaned.
10. **ASCII-only filenames, unique within a second.** `_slugify` strips
    non-ASCII characters for portability; filenames include a short content
    hash so two clusters processed in the same second don't collide.

## Adding a new harness later

Two options, in order of preference:

1. **Config only, no code** -- if the new tool stores sessions as JSON
   (either one file per session, or a metadata file + folder of per-message
   files), add an entry under `sources.generic` in `config.yaml`. See the
   commented example in `config.example.yaml` and
   `hermes_compactor/adapters/generic.py`. Proven against a fictional
   fifth tool in `tests/test_pipeline.py::test_generic_adapter_handles_a_brand_new_harness_via_config_only`.
2. **A real adapter file** -- needed when session boundaries aren't simply
   "one file = one session" (Aider is the example here: it appends
   everything to one continuous file per project, so the adapter has to
   infer boundaries by splitting on a header pattern). Implement the
   `Adapter` interface in `adapters/base.py` (just `discover_sessions()` and
   `inspect_raw()`), register it in `adapters/__init__.py`'s `REGISTRY`.
   Nothing in `cluster.py`, `judge.py`, `synthesize.py`, or `writeback.py`
   needs to change either way -- they only ever see the normalized `Session`
   object.
