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
15 changes: 15 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "synapt-plugins",
"owner": {
"name": "synapt"
},
"description": "Local-first memory and continuity plugins from synapt.",
"plugins": [
{
"name": "synapt-recall",
"source": "./claude-plugin",
"description": "Persistent recall and automatic bounded session context for Claude Code",
"category": "Productivity"
}
]
}
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,22 @@ synapt init

`synapt init` installs session hooks for automatic transcript archiving.

The repository's `claude-plugin/` package also owns a bounded `SessionStart`
hook. Install it from this repository's marketplace:

```text
/plugin marketplace add synapt-dev/recall
/plugin install synapt-recall@synapt-plugins
```

Current recall context is then injected automatically without resuming an old
conversation. Do not also install the legacy global `SessionStart` hook because
Claude Code runs both registrations.

For shared workspaces, link `claude-plugin/project-settings.json` to
`.claude/settings.json`. After the folder is trusted, Claude Code prompts once
for the marketplace and plugin consent, then keeps the plugin updated.

### Codex CLI

Install synapt and register the MCP server:
Expand All @@ -404,6 +420,11 @@ synapt init

`synapt init` installs the `dev-loop` skill automatically, giving Codex recall search, channel coordination, and journal access.

The repository's `codex-plugin/` package owns the equivalent bounded
`SessionStart` hook. A gripspace can link that package's `hooks/hooks.json` to
workspace `.codex/hooks.json`, keeping the capability in recall while making
continuity automatic for every Codex session in the workspace.

## What `synapt init` does

Run from a project root:
Expand Down
4 changes: 2 additions & 2 deletions claude-plugin/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "synapt-recall",
"version": "0.1.0",
"description": "Persistent memory for Claude Code sessions via synapt recall",
"version": "0.2.0",
"description": "Persistent memory and automatic session context for Claude Code via synapt recall",
"author": {
"name": "synapt",
"url": "https://github.com/synapt-dev"
Expand Down
17 changes: 17 additions & 0 deletions claude-plugin/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"description": "Load bounded synapt recall context when a Claude Code session starts.",
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "synapt recall hook session-start",
"statusMessage": "Loading current synapt context",
"timeout": 30
}
]
}
]
}
}
15 changes: 15 additions & 0 deletions claude-plugin/project-settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"enabledPlugins": {
"synapt-recall@synapt-plugins": true
},
"extraKnownMarketplaces": {
"synapt-plugins": {
"source": {
"source": "github",
"repo": "synapt-dev/recall"
},
"autoUpdate": true
}
}
}
6 changes: 4 additions & 2 deletions claude-plugin/skills/recall/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@ description: Persistent memory across Claude Code sessions. Search before you ac

# synapt recall

Persistent memory for Claude Code. Search past sessions, save durable knowledge, and maintain context across conversations.
Persistent memory for Claude Code. The plugin loads bounded current context at
session start. Search past sessions for deeper context, save durable knowledge,
and maintain context across conversations.

## When to use (do this automatically, without being asked)

- **Before making a design decision**: `recall_search` for prior discussion
- **When debugging an error**: `recall_search` for past fixes
- **When user references past work**: `recall_search` immediately
- **Starting a session**: `recall_journal` to read recent entries
- **Starting a session**: use the injected current context first, then `recall_journal` when more detail is needed
- **When unsure if something was discussed**: `recall_quick` (fast, cheap)
- **When you need file history**: `recall_files` for who changed what and why

Expand Down
19 changes: 16 additions & 3 deletions codex-plugin/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
{
"name": "synapt-recall",
"version": "0.1.0",
"description": "Codex plugin scaffold for synapt recall MCP access.",
"version": "0.2.0",
"description": "Persistent memory and automatic session context for Codex via synapt recall.",
"author": {
"name": "synapt",
"url": "https://github.com/synapt-dev"
},
"homepage": "https://synapt.dev",
"repository": "https://github.com/synapt-dev/recall",
"license": "MIT",
"keywords": ["memory", "recall", "persistent", "context", "sessions"],
"interface": {
"displayName": "synapt Recall",
"shortDescription": "Local-first memory and recall for Codex.",
Expand All @@ -11,7 +19,12 @@
"capabilities": [
"MCP",
"Recall",
"Local Memory"
"Local Memory",
"Automatic Session Context"
],
"defaultPrompt": [
"Find the most relevant context from our past work.",
"Save this decision so a future session inherits it."
]
}
}
1 change: 1 addition & 0 deletions codex-plugin/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Use this plugin when Codex should have direct access to synapt recall over MCP.
## What it provides

- local `synapt server` MCP access
- bounded recall context on every session start
- recall search, save, context, journal, and channel tools
- no identity or org behavior; this is OSS-only packaging of existing recall primitives

Expand Down
18 changes: 18 additions & 0 deletions codex-plugin/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"description": "Load bounded synapt recall context when a Codex session starts.",
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "synapt recall hook session-start",
"statusMessage": "Loading current synapt context",
"timeout": 30,
"additionalContextLimit": 3000
}
]
}
]
}
}
74 changes: 51 additions & 23 deletions src/synapt/recall/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1255,18 +1255,31 @@ def cmd_stats(args: argparse.Namespace) -> None:

def cmd_sessions(args: argparse.Namespace) -> None:
"""List recent sessions with date, turn count, and first message."""
from synapt.recall.sharding import is_sharded

index_dir = _resolve_index_dir(args)
if not (index_dir / "recall.db").exists() and not (index_dir / "chunks.jsonl").exists():
if (
not (index_dir / "recall.db").exists()
and not (index_dir / "chunks.jsonl").exists()
and not is_sharded(index_dir)
):
print(f"Error: no index found at {index_dir}", file=sys.stderr)
print("Run 'synapt build' or 'synapt setup' first.", file=sys.stderr)
sys.exit(1)

index = TranscriptIndex.load(index_dir, use_embeddings=False)
sessions = index.list_sessions(
max_sessions=args.max_sessions,
after=args.after,
before=args.before,
)
from synapt.recall.resume import load_resume_index

index = load_resume_index(index_dir)
try:
sessions = index.list_sessions(
max_sessions=args.max_sessions,
after=args.after,
before=args.before,
)
finally:
db = getattr(index, "_db", None)
if db is not None:
db.close()

if not sessions:
print("No sessions found.")
Expand All @@ -1290,30 +1303,45 @@ def cmd_resume(args: argparse.Namespace) -> None:
request was wrong). Collapsing them would send the reader down the wrong path.
"""
from synapt.recall.journal import _journal_path
from synapt.recall.resume import ResumeError, build_resume_view, format_resume
from synapt.recall.resume import (
ResumeError,
build_resume_view,
format_resume,
load_resume_index,
)
from synapt.recall.sharding import is_sharded

index_dir = _resolve_index_dir(args)
if not (index_dir / "recall.db").exists() and not (index_dir / "chunks.jsonl").exists():
if (
not (index_dir / "recall.db").exists()
and not (index_dir / "chunks.jsonl").exists()
and not is_sharded(index_dir)
):
print(f"Error: no index found at {index_dir}", file=sys.stderr)
print("Run 'synapt recall build' or 'synapt init' first.", file=sys.stderr)
sys.exit(1)

index = TranscriptIndex.load(index_dir, use_embeddings=False)
index = load_resume_index(index_dir)

try:
view = build_resume_view(
index,
session_id=getattr(args, "session", None),
limit=getattr(args, "turns", None) or 10,
journal_path=_journal_path(),
)
except ResumeError as exc:
# An empty index is an honest empty state, not a failure to act on.
if not index._session_order:
print("No sessions indexed yet. Nothing to resume.")
return
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)
try:
view = build_resume_view(
index,
session_id=getattr(args, "session", None),
limit=getattr(args, "turns", None) or 10,
journal_path=_journal_path(),
)
except ResumeError as exc:
# An empty index is an honest empty state, not a failure to act on.
if not index._session_order:
print("No sessions indexed yet. Nothing to resume.")
return
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)
finally:
db = getattr(index, "_db", None)
if db is not None:
db.close()

# Freshness is attached AFTER the view is built, so build_resume_view keeps
# its no-implicit-I/O contract and the check can never change what is shown
Expand Down
47 changes: 36 additions & 11 deletions src/synapt/recall/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -4185,7 +4185,7 @@ def list_sessions(
before: str | None = None,
) -> list[dict]:
"""Return recent sessions with summary info, newest-first."""
results = []
candidates = []
for session_id in self._session_order:
chunks = self.sessions[session_id]

Expand All @@ -4201,15 +4201,43 @@ def list_sessions(
if before and earliest_ts >= before:
continue

# First user message as summary (skip journal chunks)
transcript_chunks = [c for c in chunks if c.turn_index >= 0]
sorted_chunks = sorted(
transcript_chunks or chunks,
key=lambda c: c.turn_index,
)
candidates.append((
session_id,
chunks,
transcript_chunks,
earliest_ts,
))
if len(candidates) >= max_sessions:
break

hydrated_by_id: dict[str, TranscriptChunk] = {}
if self._lazy_chunks and self._db is not None:
rowids = []
for _, chunks, _, _ in candidates:
for chunk in chunks:
idx = self._id_to_idx.get(chunk.id)
rowid = self._idx_to_rowid.get(idx) if idx is not None else None
if rowid is not None:
rowids.append(rowid)
hydrated_by_id = {
chunk.id: chunk
for chunk in self._db.load_chunks_by_rowids(rowids).values()
}
else:
hydrated_by_id = {
chunk.id: chunk
for _, chunks, _, _ in candidates
for chunk in chunks
}

results = []
for session_id, chunks, transcript_chunks, earliest_ts in candidates:
# First user message as summary (skip journal chunks)
sorted_chunks = sorted(transcript_chunks or chunks, key=lambda c: c.turn_index)
first_msg = ""
for c in sorted_chunks:
full = self._get_chunk(self._id_to_idx[c.id])
full = hydrated_by_id.get(c.id, c)
if full.user_text:
first_msg = full.user_text[:120]
if len(full.user_text) > 120:
Expand All @@ -4218,7 +4246,7 @@ def list_sessions(

all_files = set()
for c in chunks:
full = self._get_chunk(self._id_to_idx[c.id])
full = hydrated_by_id.get(c.id, c)
all_files.update(full.files_touched)

results.append({
Expand All @@ -4229,9 +4257,6 @@ def list_sessions(
"files_count": len(all_files),
})

if len(results) >= max_sessions:
break

return results

def session_tail(self, session_id: str) -> list[TranscriptChunk]:
Expand Down
Loading
Loading