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
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions docs/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion integrations/claude-code/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
}
Expand Down
11 changes: 11 additions & 0 deletions integrations/claude-code/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand Down
2 changes: 1 addition & 1 deletion src/memware/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,4 @@
"history",
"reject",
]
__version__ = "0.2.5"
__version__ = "0.2.6"
9 changes: 9 additions & 0 deletions src/memware/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/memware/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading