diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 6fdae5f..b522862 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "name": "memware", "source": "./integrations/claude-code", "description": "Session transcripts indexed for recall; a belief ledger that only remembers the latest truth. Hooks: SessionEnd/PreCompact sync, prompt-time belief context.", - "version": "0.2.5", + "version": "0.2.6", "author": { "name": "ericwalisko" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c6e0aa..9db867b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +## [0.2.6] - 2026-09-03 + +### Added +- A `SessionStart` plugin hook that catches up any session whose `SessionEnd` never ran. Some + environments force-kill Claude Code — a worktree/pane manager can `SIGKILL` the process + group on close, and a `SIGKILL` cannot run `SessionEnd` — so its sync + backup were skipped. + On the next start, a bare `memware sync` (new: no path = catch up the configured + `backup.transcript_src`, default `~/.claude/projects`) plus a throttled backup run, + **backgrounded** so startup is never delayed. Raw transcripts were durable regardless; this + makes the *index* current without relying on a clean exit. + +### Changed +- The store now sets `PRAGMA busy_timeout=5000`, so a concurrent writer waits and retries instead of failing with "database is locked" — the SessionStart catch-up, a session-end sync, and the backup cron can now overlap safely. + ## [0.2.5] - 2026-09-03 ### Added diff --git a/README.md b/README.md index 0f06ca6..4e1803b 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ transcripts. It fills as you work (via the `remember` tool, or a derive job you Transcript recall is what backfill gives you immediately, and it is where most of the value is. Requires the `memware` CLI on your `PATH` (see [Install](#install)). Hooks: -`SessionEnd`/`PreCompact` sync the transcript into the index; an optional `UserPromptSubmit` +a `SessionStart` hook catches up any session whose `SessionEnd` was skipped (some environments force-kill Claude Code — a worktree manager may `SIGKILL` it — and a kill cannot run `SessionEnd`); `SessionEnd`/`PreCompact` sync the transcript into the index; an optional `UserPromptSubmit` hook injects the handful of currently valid beliefs whose subject the prompt names (beliefs only — transcript search is on demand through the MCP tools). Set `MEMWARE_DB` to move the store, and `MEMWARE_NO_CAPTURE=1` for any session you do not want indexed. See diff --git a/docs/integrations.md b/docs/integrations.md index 0eedb43..374376a 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -22,9 +22,12 @@ Hooks (`hooks/hooks.json`): | event | command | effect | |---|---|---| +| `SessionStart` | `memware sync` (catch-up) + `memware backup --if-stale 20`, backgrounded | indexes any session whose `SessionEnd` never ran, then a throttled backup — see note | | `SessionEnd`, `PreCompact` | `memware sync --harness claude-code --from-hook` | indexes the session's new turns from `transcript_path` | | `UserPromptSubmit` (optional) | `memware context --from-hook` | injects the few currently valid beliefs relevant to the prompt as `additionalContext` | +`SessionEnd` runs when Claude Code exits cleanly, but some environments **force-kill** it (a worktree/pane manager may `SIGKILL` the process group on close), and a `SIGKILL` cannot run any hook. The `SessionStart` hook covers that: it runs a bare `memware sync` — which catches up the configured `backup.transcript_src` (default `~/.claude/projects`) — plus a throttled backup, **backgrounded** so it never delays startup. So the previous session is indexed at the next start even if its `SessionEnd` was skipped; the raw transcript is durable on disk regardless. + The prompt-time hook injects **beliefs only**, capped by `-k`. Transcript search is on demand through the MCP server. Add it at **user** scope so every project sees it — the default (`local`) scopes the server to the one directory diff --git a/integrations/claude-code/.claude-plugin/plugin.json b/integrations/claude-code/.claude-plugin/plugin.json index f64aaee..24ce2a4 100644 --- a/integrations/claude-code/.claude-plugin/plugin.json +++ b/integrations/claude-code/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "memware", "description": "Session transcripts indexed for recall; a belief ledger that only remembers the latest truth.", - "version": "0.2.5", + "version": "0.2.6", "author": { "name": "ericwalisko" } diff --git a/integrations/claude-code/hooks/hooks.json b/integrations/claude-code/hooks/hooks.json index d61fc6e..fc2f2fc 100644 --- a/integrations/claude-code/hooks/hooks.json +++ b/integrations/claude-code/hooks/hooks.json @@ -1,5 +1,16 @@ { "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "nohup sh -c 'memware sync --harness claude-code; memware backup --if-stale 20 --quiet' >/dev/null 2>&1 & true", + "timeout": 10 + } + ] + } + ], "SessionEnd": [ { "hooks": [ diff --git a/src/memware/__init__.py b/src/memware/__init__.py index 4cdd26f..0a6292c 100644 --- a/src/memware/__init__.py +++ b/src/memware/__init__.py @@ -22,4 +22,4 @@ "history", "reject", ] -__version__ = "0.2.5" +__version__ = "0.2.6" diff --git a/src/memware/cli.py b/src/memware/cli.py index fc12f32..fbf8cf2 100644 --- a/src/memware/cli.py +++ b/src/memware/cli.py @@ -54,6 +54,15 @@ def cmd_sync(a: argparse.Namespace) -> int: tp = _hook_payload().get("transcript_path") if tp: paths.append(str(tp)) + if not paths and not a.from_hook: + # Bare `memware sync` = catch up the configured transcript source (default + # ~/.claude/projects). The SessionStart hook uses this to index sessions whose + # SessionEnd never ran — e.g. a worktree manager that SIGKILLs the process group. + from memware.config import get_dotted, load_config + + src = get_dotted(load_config(), "backup.transcript_src") + if src: + paths.append(str(src)) if not paths: print("nothing to sync", file=sys.stderr) return 0 diff --git a/src/memware/store.py b/src/memware/store.py index 422e7b1..cfe6c1a 100644 --- a/src/memware/store.py +++ b/src/memware/store.py @@ -144,6 +144,10 @@ def __init__(self, path: str | os.PathLike[str] | None = None) -> None: self.conn.row_factory = sqlite3.Row self.conn.execute("PRAGMA journal_mode=WAL") self.conn.execute("PRAGMA synchronous=NORMAL") + # WAL allows one writer at a time; wait-and-retry rather than failing a concurrent + # write with "database is locked". Several memware processes can touch the store at + # once — the SessionStart catch-up, a session-end sync, and the backup cron can overlap. + self.conn.execute("PRAGMA busy_timeout=5000") if not self._has_fts5(): raise RuntimeError("this SQLite build lacks FTS5; memware requires it") self.conn.executescript(SCHEMA) diff --git a/tests/test_cli.py b/tests/test_cli.py index 193eb8f..158e17c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -107,3 +107,22 @@ def test_setup_hint_shows_until_backups_configured(tmp_path, capsys, monkeypatch capsys.readouterr() main(["--db", db, "stats"]) # once a destination exists the tip is gone assert "memware setup" not in capsys.readouterr().err + + +def test_bare_sync_catches_up_configured_transcript_src(tmp_path, capsys, monkeypatch): + """`memware sync` with no path indexes the configured transcript source — what the + SessionStart hook runs to catch up sessions whose SessionEnd never fired (e.g. a worktree + force-killed by Orca). A path or --from-hook still targets exactly what's given.""" + monkeypatch.setenv("MEMWARE_HOME", str(tmp_path / "home")) + projects = tmp_path / "projects" + (projects / "p").mkdir(parents=True) + write_claude_jsonl( + projects / "p" / "s.jsonl", + "s", + [("assistant", "2026-08-25T00:00:00Z", "the indexer catches up on the next session start")], + ) + db = str(tmp_path / "m.db") + main(["--db", db, "config", "backup.transcript_src", str(projects)]) + capsys.readouterr() + assert main(["--db", db, "sync", "--json"]) == 0 # no path -> configured source + assert json.loads(capsys.readouterr().out)["added"] == 1