Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,34 @@ commits and tag annotations of those releases.

---

## [Unreleased]

### Added

- **The Codex finalizer.** Codex threads now get the full pipeline —
embeddings, episodes, project inference — on their own, 30 minutes after
the rollout goes quiet. It is the Codex twin of Claude Code's SessionEnd
hook: Codex sends no session-end signal, so quiet stands in for it. Until
now an active thread that had been indexed with `--semantic` regressed to
exact-record-only the moment it grew, and nothing ever indexed it again.
`longhand codex-sync` runs the finalizer after its exact-record pass
(`--finalize-after SECONDS` to tune the bound, `--no-finalize` to opt out),
one thread per run so the every-minute poller stays bounded; `reconcile
--fix` clears the whole quiet backlog. The embedding model loads only on a
run that has a quiet thread to finalize. A finalized thread that resumes is
captured exact-only again and finalized again once it settles — one
re-embed per resume, never a treadmill.
- `longhand.codex.finalize_codex()`, a `settling` bucket and
`min_idle_seconds` on `scan_codex_sessions()` / `sync_codex()`, and
`codex_unfinalized` / `codex_settling` / `codex_finalized` on the
`reconcile` report and MCP tool.

### Changed

- `doctor`'s "Codex sessions archived" row is now "Codex finalizer": green
while archived threads are still being written, yellow only when one has
been quiet past the bound and nothing has finalized it yet.

## [1.1.0] — 2026-09-07

Longhand now keeps one archive for Claude Code **and** Codex. This is the
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,4 @@ Beyond the decision tree above: `get_session_timeline` with `tail` (the last N e

## Codex sessions (1.1.0+)

Codex Desktop / CLI threads live in the same archive with `codex:`-prefixed session ids. This server lists and pages them (`list_sessions`, `get_session_timeline`) and `find_commits` sees commits made from Codex — but `recall` and semantic `search` only see a Codex session after `longhand codex-sync --semantic` has indexed it. For "what did I do in Codex" questions, use the `longhand-shared` server's keyword search (literal phrases; its session listing takes source="codex"), or tell the user to run the semantic sync.
Codex Desktop / CLI threads live in the same archive with `codex:`-prefixed session ids. This server lists and pages them (`list_sessions`, `get_session_timeline`) and `find_commits` sees commits made from Codex — but `recall` and semantic `search` only see a Codex session once it has been finalized — automatically, 30 minutes after the thread goes quiet (`longhand codex-sync --semantic` does it immediately). For "what did I do in Codex" questions about a thread that is still active, use the `longhand-shared` server's keyword search (literal phrases; its session listing takes source="codex").
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
[![PyPI version](https://img.shields.io/pypi/v/longhand?label=PyPI&color=blue)](https://pypi.org/project/longhand/)
![Python](https://img.shields.io/badge/python-3.10+-blue)
![License](https://img.shields.io/badge/license-MIT-green)
![Tests](https://img.shields.io/badge/tests-580%20passing-brightgreen)
![Tests](https://img.shields.io/badge/tests-588%20passing-brightgreen)
![Local](https://img.shields.io/badge/100%25-local-informational)
[![SafeSkill 93/100](https://img.shields.io/badge/SafeSkill-93%2F100_Verified%20Safe-brightgreen)](https://safeskill.dev/scan/wynelson94-longhand)

Expand Down Expand Up @@ -87,7 +87,7 @@ longhand analyze --all # fill in episodes + vectors whenever, safe to

Exact-text search, timelines, file history, and commit lookup all work after `--skip-analysis`. Semantic `recall` needs the `analyze --all` pass to complete. Typical throughput on an M-class Mac is ~1–2 sessions/sec for full analysis.

> *Status: v1.1.0 — stable, daily-driver tested, security-audited (zero critical findings), on PyPI, available as a Claude Code plugin. Validated against 433 real Claude Code sessions across 37 inferred projects (measured 2026-08-12). 580 unit tests passing.*
> *Status: v1.1.0 — stable, daily-driver tested, security-audited (zero critical findings), on PyPI, available as a Claude Code plugin. Validated against 433 real Claude Code sessions across 37 inferred projects (measured 2026-08-12). 588 unit tests passing.*

**Full docs:** [Longhand Wiki](https://github.com/Wynelson94/longhand/wiki) — getting started, CLI reference, MCP tools reference, architecture, and troubleshooting.

Expand Down Expand Up @@ -448,7 +448,7 @@ claude mcp add --scope user longhand-shared -- longhand shared-mcp # keyword s
codex mcp add longhand -- longhand shared-mcp # the same server from Codex (Desktop: config.toml, see the docs)
```

From then on `reconcile --fix` captures new Codex threads too, so the scheduled reconciler keeps both clients current. Codex sessions are stored exact-record-only — verbatim and searchable, no model loaded — until `longhand codex-sync --semantic` makes them recallable. Threads Codex spawns for itself are skipped, UI mirrors are never stored twice, and unknown record shapes surface in `doctor` like any other drift. Setup, bounds, and the macOS launchd template: **[docs/codex.md](docs/codex.md)**.
From then on `reconcile --fix` captures new Codex threads too, so the scheduled reconciler keeps both clients current. A thread is stored exact-record-only while it is being written — verbatim and searchable, no model loaded — and gets the full pipeline 30 minutes after it goes quiet, so `recall` sees it with no manual step (`codex-sync --semantic` does it immediately). Threads Codex spawns for itself are skipped, UI mirrors are never stored twice, and unknown record shapes surface in `doctor` like any other drift. Setup, bounds, and the macOS launchd template: **[docs/codex.md](docs/codex.md)**.

---

Expand Down Expand Up @@ -547,7 +547,7 @@ Longhand is flat-cost: the cap is per-call, not per-corpus. Recalling across 10

---

580 unit tests passing. All 13 MCP tools stress-tested. Full security audit: zero critical findings, zero high findings. `~/.longhand/` created with 0700 permissions, all SQL parameterized, all inputs bounded. Dependencies: chromadb, typer, rich, pydantic, mcp.
588 unit tests passing. All 13 MCP tools stress-tested. Full security audit: zero critical findings, zero high findings. `~/.longhand/` created with 0700 permissions, all SQL parameterized, all inputs bounded. Dependencies: chromadb, typer, rich, pydantic, mcp.

---

Expand Down
40 changes: 26 additions & 14 deletions docs/codex.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ commands — one archive is the whole point.
| ------------------------------------------------- | ----------------------------- | -------------------------- |
| Lists and pages Codex sessions | yes | yes |
| Keyword search across both clients | no | yes (`search`, literal) |
| `recall` and semantic search over Codex sessions | after `codex-sync --semantic` | no |
| `recall` and semantic search over Codex sessions | 30 min after a thread quiets | no |
| Commits made in Codex | yes (`find_commits`) | through `search` |
| Loads the embedding model | yes | never |

Expand All @@ -63,21 +63,32 @@ Chroma, and never loads a model; long texts and raw records page in
8,000-character slices. A broad query on a large archive can hit the server's
instruction budget — narrow it to a session.

Codex sessions are captured exact-record-only by default (ingestion stage
`archived`): every message, reasoning summary, tool call, and output is stored
verbatim and searchable, but not embedded. `longhand codex-sync --semantic`
runs the full pipeline — embeddings, episodes, project inference — on captured
sessions so `recall` and semantic `search` see them. It is the remedy `doctor`
names for archived sessions; `analyze` is not, because it never embeds events.
Capture runs in two passes, the Codex twin of Claude Code's Stop and SessionEnd
hooks. A new or changed rollout is first captured exact-record-only (ingestion
stage `archived`): every message, reasoning summary, tool call, and output is
stored verbatim and keyword-searchable, with no vector model loaded — the same
reason Claude's per-turn Stop hook skips embeddings. Codex sends no session-end
signal, so quiet stands in for it: once a rollout has been untouched for 30
minutes (`--finalize-after`, in seconds) it gets the full pipeline —
embeddings, episodes, project inference — and `recall` and semantic `search`
see it. A finalized thread that resumes is captured exact-only again and
finalized again once it settles: one re-embed per resume. `longhand codex-sync
--semantic` runs the full pipeline on everything immediately, and
`--no-finalize` keeps a run exact-only. `doctor` shows a "Codex finalizer" row
while any thread is archived; `analyze` never embeds events, so it is not the
remedy for one.

## Keeping capture current

`longhand reconcile --fix` captures new or changed Codex rollouts along with
everything it already does for Claude transcripts, so the scheduled reconciler
(`longhand schedule install-reconciler`, every 30 minutes on macOS) keeps Codex
current with no extra setup. For an immediate capture run `longhand codex-sync`;
for a foreground loop run `longhand codex-sync --watch` (every 60 seconds until
interrupted).
`longhand reconcile --fix` runs both passes — captures new or changed Codex
rollouts and finalizes the quiet ones — along with everything it already does
for Claude transcripts, so the scheduled reconciler (`longhand schedule
install-reconciler`, every 30 minutes on macOS) keeps Codex current and
recallable with no extra setup. For an immediate run use `longhand codex-sync`;
for a foreground loop, `longhand codex-sync --watch` (every 60 seconds until
interrupted). On a 60-second schedule a thread is recallable about 30 minutes
after its last message; the poller loads the embedding model only on the run
that has a quiet thread to finalize, one thread per run.

### Faster capture with launchd (macOS)

Expand Down Expand Up @@ -125,7 +136,8 @@ Scheduler.
- **Bounds.** Per run: up to 50 sessions, each up to 16 MiB and 20,000 events.
Larger rollouts are reported as `deferred`, never partially imported. Raise
the bounds with `--limit`, `--max-file-kb`, and `--max-events`. These are
input bounds, not a memory ceiling — the default capture never loads a model.
input bounds, not a memory ceiling — the exact-record pass never loads a
model, and the finalizer loads it only when a quiet thread is waiting.
- **Drift is never silent.** A record shape Longhand does not recognize is
preserved as an `unknown` event with its raw JSON intact, and surfaces in
`longhand doctor`'s "Transcript format" row as `response_item/<kind>` or
Expand Down
64 changes: 57 additions & 7 deletions longhand/cli/_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,12 @@
_resolve_prefix,
console,
)
from longhand.codex import DEFAULT_MAX_EVENTS, DEFAULT_MAX_FILE_BYTES, DEFAULT_SESSION_LIMIT
from longhand.codex import (
DEFAULT_FINALIZE_AFTER_SECONDS,
DEFAULT_MAX_EVENTS,
DEFAULT_MAX_FILE_BYTES,
DEFAULT_SESSION_LIMIT,
)
from longhand.parser import JSONLParser, discover_sessions
from longhand.recall import recall as recall_pipeline
from longhand.replay import ReplayEngine
Expand Down Expand Up @@ -327,6 +332,18 @@ def codex_sync_cmd(
help="Also capture threads Codex spawned for itself (e.g. its approval reviewer); "
"skipped by default because they re-quote the parent thread",
),
finalize_after: int = typer.Option(
DEFAULT_FINALIZE_AFTER_SECONDS,
"--finalize-after",
min=0,
help="Seconds a thread must stay quiet before it gets the full pipeline "
"(embeddings, episodes, project inference) so recall and search see it",
),
no_finalize: bool = typer.Option(
False,
"--no-finalize",
help="Capture exact records only; never run the full pipeline from this command",
),
dry_run: bool = typer.Option(
False,
"--dry-run",
Expand All @@ -345,17 +362,22 @@ def codex_sync_cmd(
):
"""Capture Codex history into the same archive Claude uses.

Exact records only by default, with no vector model loaded. `reconcile
--fix` runs this capture too, so a scheduled reconciler keeps Codex
current on its own; run this directly for an immediate capture, or with
--watch for a 60-second loop.
Two passes, the Codex twin of Claude Code's Stop and SessionEnd hooks:
every new or changed rollout is captured exact-record-only (no vector
model loaded), then any rollout quiet for --finalize-after seconds gets
the full pipeline so recall and search see it. The model loads only when
a quiet thread is waiting. `reconcile --fix` runs both passes too, so a
scheduled reconciler keeps Codex current on its own; run this directly
for an immediate capture, with --watch for a 60-second loop, or with
--semantic to run the full pipeline on everything now.
"""
import json
import time

from longhand.codex import (
CodexArchiveStore,
discover_codex_sessions,
finalize_codex,
is_subagent_rollout,
read_session_meta,
sync_codex,
Expand Down Expand Up @@ -385,6 +407,20 @@ def codex_sync_cmd(
max_events=max_events,
include_subagents=include_subagents,
)
if not semantic and not no_finalize:
finalized = finalize_codex(
store.sqlite,
codex_home,
lambda: _get_store(data_dir),
idle_seconds=finalize_after,
max_file_bytes=max_file_kb * 1024,
max_events=max_events,
include_subagents=include_subagents,
)
report["finalized"] = finalized["finalized"]
report["settling"] = finalized["settling"]
report["deferred"] = list(dict.fromkeys(report["deferred"] + finalized["deferred"]))
report["errors"].extend(finalized["errors"])
console.print(json.dumps(report))
if not watch:
if report["errors"]:
Expand Down Expand Up @@ -607,6 +643,15 @@ def reconcile(
console.print(f"[bold]Codex rollouts:[/bold] {report.codex_on_disk} on disk")
console.print(f" [green]{current}[/green] captured and current")
console.print(f" [yellow]{report.codex_pending}[/yellow] new or changed since capture")
if report.codex_unfinalized:
console.print(
f" [yellow]{report.codex_unfinalized}[/yellow] quiet thread(s) awaiting the "
"full pipeline"
)
if report.codex_settling:
console.print(
f" [dim]{report.codex_settling} still being written (finalized once quiet)[/dim]"
)
if report.codex_skipped_subagents:
console.print(
f" [dim]{report.codex_skipped_subagents} subagent thread(s) skipped "
Expand All @@ -619,16 +664,17 @@ def reconcile(
)

fixable_claude = bool(report.missing or report.null_project or report.partially_indexed)
fixable_codex = bool(report.codex_pending or report.codex_unfinalized)
if not fix:
if fixable_claude or report.codex_pending:
if fixable_claude or fixable_codex:
console.print("\n[dim]Run with --fix to re-ingest.[/dim]")
return

if report.lock_unavailable:
console.print("[yellow]Another ingest is running — aborting reconcile.[/yellow]")
raise typer.Exit(1)

if not fixable_claude and not report.codex_pending:
if not fixable_claude and not fixable_codex:
console.print("\n[green]Nothing to fix.[/green]")
return

Expand All @@ -645,6 +691,10 @@ def reconcile(
f" ([dim]{len(report.codex_deferred)} deferred[/dim])" if report.codex_deferred else ""
)
console.print(f"[bold]Captured {report.codex_ingested}[/bold] Codex rollout(s){deferred}")
settling = (
f" ([dim]{report.codex_settling} settling[/dim])" if report.codex_settling else ""
)
console.print(f"[bold]Finalized {report.codex_finalized}[/bold] Codex thread(s){settling}")


# -----------------------------------------------------------------------------
Expand Down
Loading
Loading