diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..63bbd3d --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "recall-marketplace", + "interface": { + "displayName": "Recall" + }, + "plugins": [ + { + "name": "recall", + "source": { + "source": "local", + "path": "./plugins/recall" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/.gitignore b/.gitignore index 4a9dc7c..820114b 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,11 @@ dist/ .claude/worktrees .maestro/ .agents/ +!.agents/ +.agents/* +!.agents/plugins/ +.agents/plugins/* +!.agents/plugins/marketplace.json .pi-subagents/ .pi/* !.pi/APPEND_SYSTEM.md diff --git a/AGENTS.md b/AGENTS.md index b0277f3..213889e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,7 @@ Top-level directories, by purpose (one line each — not a file enumeration): - `tests/` — `bun:test` suite mirroring source areas, plus install-lifecycle tests - `benchmarks/` — wake-up context-efficiency benchmark harness - `agent-skills/` — Agent Skills (SKILL.md, one per skill dir) installed to `~/.claude/skills`, `~/.pi/agent/skills`, `~/.omp/agent/skills` — the single `recall-*` command surface (the former `/Recall:*` slash commands, #228) +- `plugins/` — native host plugin bundles; Codex packages MCP plus generated host-adapted skills in `plugins/recall/` - `docs/` — user-facing published docs + ADRs (`docs/adr/`) + agent skill docs (`docs/agents/`) - `lib/` — shared bash for the install / update / uninstall lifecycle scripts - `opencode/` — OpenCode host integration (plugins / hooks / guide) @@ -174,5 +175,13 @@ Child AGENTS.md files own domain-specific local rules. Read the applicable one b - [`benchmarks/AGENTS.md`](benchmarks/AGENTS.md) — wake-up context-efficiency benchmark harness - [`docs/AGENTS.md`](docs/AGENTS.md) — user-facing published docs, ADRs, agent skill docs (never plans/specs) - [`agent-skills/AGENTS.md`](agent-skills/AGENTS.md) — `recall-*` Agent Skill definitions +- [`plugins/AGENTS.md`](plugins/AGENTS.md) — native host plugin manifests, MCP registration, and generated adapters Owned at root (no child doc): lifecycle scripts (`install.sh`, `update.sh`, `uninstall.sh`) + their shared `lib/install-lib.sh`; platform guides (`FOR_CLAUDE.md`, `FOR_OPENCODE.md`, `FOR_PI.md`); `CONTEXT.md`; and `assets/` (banner + VHS demo tapes/gifs). + +## Maintaining this file + +Keep this file for knowledge useful to almost every future agent session in this project. +Do not repeat what the codebase already shows; point to the authoritative file or command instead. +Prefer rewriting or pruning existing entries over appending new ones. +When updating this file, preserve this bar for all agents and keep entries concise. diff --git a/README.md b/README.md index c1ec095..3f03014 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Recall is a retrieval-first memory layer: everything lands in one searchable database, the best of it is ranked and injected at session start, and decisions carry confidence, importance, and a lifecycle across any coding agent/harness. -> **A SQLite-backed persistent memory layer for coding agents.** Stop-hook extraction captures sessions as you work, MCP tools expose them mid-session, hybrid search (FTS5 + embeddings) retrieves them, and a tiered L0/L1 recall block injects identity + top-ranked records at every session start. Works across Claude Code, OpenCode, and Pi from one local database. +> **A SQLite-backed persistent memory layer for coding agents.** Stop-hook extraction captures sessions where a host lifecycle adapter exists, MCP tools expose them mid-session, hybrid search (FTS5 + embeddings) retrieves them, and a tiered L0/L1 recall block injects identity + top-ranked records on supported hosts. Works across Claude Code, OpenCode, Pi, and Codex from one local database. Got questions about the project? I'd suggest using [DeepWiki](https://deepwiki.com/edheltzel/Recall) from Devin/Cognition to ask questions about the project. @@ -18,7 +18,7 @@ All coding agents forget when a session ends. Recall doesn't — it extracts, in Built on the [Model Context Protocol](https://modelcontextprotocol.io). One SQLite file. No phone-home. No vendor lock-in. -> Stable on [Claude Code](https://claude.com/claude-code). Beta on [Pi](https://pi.dev/) and Alpha for [OpenCode](https://opencode.ai/) (MCP works; lifecycle extensions are early). [Codex CLI](https://github.com/openai/codex) and [Gemini CLI](https://github.com/google-gemini/gemini-cli) on the roadmap. See [Roadmap](#roadmap). +> Stable on [Claude Code](https://claude.com/claude-code). Beta on [Pi](https://pi.dev/) and Alpha for [OpenCode](https://opencode.ai/) (MCP works; lifecycle extensions are early). [Codex CLI](https://github.com/openai/codex) uses a native plugin for MCP and skills; lifecycle auto-capture is not yet supported. [Gemini CLI](https://github.com/google-gemini/gemini-cli) remains on the roadmap. See [Roadmap](#roadmap). --- @@ -51,7 +51,7 @@ Install once, then forget about it. Recall runs silently in the background: Four things that set Recall apart from cloud-hosted memory layers and from agent-specific scratch files: - **Local-first, zero infrastructure.** One SQLite file at `~/.agents/Recall/recall.db` (override via `RECALL_DB_PATH`). WAL mode, `0600` perms. No vector database, no graph database, no agent server, no API keys for retrieval. Nothing leaves your machine — no telemetry, no phone-home. Optional Ollama for embeddings (also local). -- **Multi-agent native.** One memory layer across the agents you actually use. Stable on Claude Code today; Pi and OpenCode connect via MCP; Codex CLI and Gemini CLI on the way. Memories captured by one agent are searchable from any other agent on the same machine. +- **Multi-agent native.** One memory layer across the agents you actually use. Stable on Claude Code today; Pi, OpenCode, and Codex connect through MCP. Memories captured by one agent are searchable from any other agent on the same machine. - **Structured taxonomy, not a flat blob.** Decisions (with supersede/revert lifecycle and confidence scoring), learnings, breadcrumbs, and curated **Library of Alexandria** entries — each has a purpose and a query path. Importance scoring (1–10) surfaces what matters first. - **Hybrid search that works offline.** FTS5 keyword search ships with SQLite — no embedding infrastructure required to find anything. Optional Ollama embeddings layer on top for semantic queries. Both are merged via Reciprocal Rank Fusion. Lose Ollama, lose nothing — the keyword path keeps working. @@ -90,6 +90,8 @@ recall doctor # Health check Restart your agent (Claude Code, Pi, or OpenCode) to load the MCP server and hooks. +Codex uses its native plugin marketplace instead of the lifecycle installer; see [Codex Integration](docs/CODEX_INTEGRATION.md). + ### First run: set your identity Recall's tiered RecallStart injects a small identity file at the top @@ -217,7 +219,7 @@ The source `.excalidraw` file lives at [`assets/how-recall-works.excalidraw`](as 4. **Extraction pipeline** — The conversation JSONL is filtered, deduplicated, and sent to the `claude` CLI running Haiku (with chunking for large sessions >120K chars). Optional Ollama fallback if the CLI fails. A quality gate rejects low-quality extractions before they're stored. 5. **PreCompact flush** — When Claude Code is about to compact its context, a `PreCompact` hook (`RecallPreCompact.ts`) flushes the in-flight messages first, so the squashed window is never lost. 6. **Dual-write storage** — Results are written to SQLite (the only query surface — every CLI/MCP read hits this) and to markdown artifacts (`DISTILLED.md`, `HOT_RECALL.md`, etc., write-only, human-readable). -7. **Batch catchup (optional)** — A cron job (`RecallBatchExtract.ts`) sweeps any sessions the Stop hook missed during crashes or interruptions, and ingests sessions dropped by the OpenCode plugin and Pi extension into `~/.claude/MEMORY/{opencode,pi}-sessions/`. `install.sh` prints the registration command at the end — opt in by running it once; nothing is auto-scheduled. +7. **Batch catchup (optional)** — A cron job (`RecallBatchExtract.ts`) sweeps any sessions the Stop hook missed during crashes or interruptions, and ingests sessions dropped by the OpenCode plugin and Pi extension into `~/.agents/Recall/MEMORY/{opencode,pi}-sessions/`. `install.sh` prints the registration command at the end — opt in by running it once; nothing is auto-scheduled. 8. **TELOS auto-sync (PAI users)** — If you use [Personal AI Infrastructure (PAI)](https://github.com/danielmiessler/Personal_AI_Infrastructure), Recall ships a `RecallTelosSync.ts` SessionStart hook that watches `~/.claude/skills/PAI/USER/TELOS/` for changes and silently runs `recall telos import --update` when any file is newer than the last import. This is **automatic** — no action required once Recall is installed and PAI's TELOS directory exists. You can also import manually at any time with `recall telos import --yes`. If you don't use PAI, the hook checks for the directory, finds nothing, and exits in under 1ms. ### Search Strategies @@ -246,7 +248,7 @@ The source `.excalidraw` file lives at [`assets/how-recall-works.excalidraw`](as - **Importance scoring (1–10)** — every record carries an importance score that drives what surfaces in L1. Manage with `recall pin` / `recall unpin` / `recall importance backfill` - **PreCompact flush** — `RecallPreCompact.ts` writes in-flight messages to SQLite before Claude compacts its context window, so the squashed chunk is never lost - **Decision lifecycle** — `recall decision supersede/revert` tracks when a decision was replaced or rolled back; confidence scoring (high/medium/low) on every decision and learning -- **Cross-host ingestion** — OpenCode plugin and Pi extension drop sessions into `~/.claude/MEMORY/{opencode,pi}-sessions/`; RecallBatchExtract pulls them into the same SQLite DB. One memory layer across agents +- **Cross-host ingestion** — OpenCode plugin and Pi extension drop sessions into `~/.agents/Recall/MEMORY/{opencode,pi}-sessions/`; RecallBatchExtract pulls them into the same SQLite DB. One memory layer across agents - **Library of Alexandria** — curated knowledge entries (session distillations, imported docs, telos goals, quotes) with Fabric `extract_wisdom` analysis. Default importance 8 — these get reserved L1 slots - **TELOS integration ([PAI](https://github.com/danielmiessler/Personal_AI_Infrastructure) users)** — `RecallTelosSync.ts` auto-imports your TELOS framework files (goals, mission, projects, strategies) from PAI's `USER/TELOS/` directory on every session start. Changes are detected by mtime; unchanged files are skipped. Manual import: `recall telos import --yes` - **Breadcrumbs, decisions, learnings** — three structured record types for non-session memory, addable from CLI (`recall add`), MCP (`memory_add`), or the `recall-add` agent skill @@ -305,6 +307,7 @@ If you're an AI agent reading this repository: | **Using Recall from Claude Code** (MCP tools, CLI, core rules) | [`FOR_CLAUDE.md`](FOR_CLAUDE.md) | | **Using Recall from OpenCode** | [`FOR_OPENCODE.md`](FOR_OPENCODE.md) | | **Using Recall from Pi** | [`FOR_PI.md`](FOR_PI.md) | +| **Using Recall from Codex** | [`docs/CODEX_INTEGRATION.md`](docs/CODEX_INTEGRATION.md) | | **Developing Recall** (build, test, conventions) | [`CLAUDE.md`](CLAUDE.md) | ## Roadmap @@ -316,7 +319,7 @@ Recall is built around two integration surfaces: **MCP** (memory search and add, | [**Claude Code**](https://claude.com/claude-code) | ✅ | ✅ Stop · SessionStart · PreCompact | **Stable** — reference implementation | | [**Pi**](https://pi.dev/) | ✅ | ⚠ Beta — `recall-compaction` + `recall-extract` extensions | In progress | | [**OpenCode**](https://opencode.ai/) | ✅ | ⚠ Alpha — `recall-extract` plugin | In progress | -| [**Codex CLI**](https://github.com/openai/codex) | — | — | Coming soon | +| [**Codex CLI**](https://github.com/openai/codex) | ✅ | — native plugin, explicit dump only | MCP + skills available | | [**Gemini CLI**](https://github.com/google-gemini/gemini-cli) | — | — | Coming soon | **Candidate** — [Cursor](https://cursor.com): both `.cursor/hooks.json` and MCP are first-class; the integration model maps cleanly onto Recall's existing hook architecture. Tracked but not started. @@ -334,6 +337,7 @@ Have an agent you'd like to see supported? [Open an issue](https://github.com/ed | [Architecture](docs/architecture.md) | Database, search, extraction pipeline | | Codebase Map (local) | Interactive visual map at `.agents/atlas/artifacts/2026-06-10-recall-codebase-map.html` — generated from the codegraph index, not committed (`.agents/` is gitignored) | | [Agent Skills](docs/agent-skills.md) | `recall-*` skills for Claude Code, Pi, and omp | +| [Codex Integration](docs/CODEX_INTEGRATION.md) | Native plugin install, MCP coverage, and lifecycle limits | | [Upgrading](docs/upgrading.md) | Update, backup, migration system | | [Troubleshooting](docs/troubleshooting.md) | Common issues and fixes | | [Changelog](CHANGELOG.md) | Release notes and breaking changes | diff --git a/agent-skills/AGENTS.md b/agent-skills/AGENTS.md index ddbce3e..bf66782 100644 --- a/agent-skills/AGENTS.md +++ b/agent-skills/AGENTS.md @@ -4,7 +4,7 @@ ## Purpose -Agent Skill definitions (one `SKILL.md` per skill directory, per the [agentskills.io](https://agentskills.io) standard) for the `recall-*` namespace — the single command surface across all skill hosts. The installer symlinks canonicals into `~/.claude/skills/`, `~/.pi/agent/skills/`, and `~/.omp/agent/skills/`. +Agent Skill definitions (one `SKILL.md` per skill directory, per the [agentskills.io](https://agentskills.io) standard) for the `recall-*` namespace — the canonical command source across skill hosts. The installer symlinks canonicals into `~/.claude/skills/`, `~/.pi/agent/skills/`, and `~/.omp/agent/skills/`; `scripts/build-codex-plugin.ts` generates Codex-native adapters from them. ## Ownership @@ -14,17 +14,18 @@ Agent Skill definitions (one `SKILL.md` per skill directory, per the [agentskill - These are Markdown skill specs, not code; each maps to underlying `recall` CLI / MCP behavior — keep its body aligned with that command and with `docs/agent-skills.md`. - `recall-dump/SKILL.md` carries `disable-model-invocation: true` — dumping a session is always the user's call; do not remove that gate. +- Codex does not accept every canonical frontmatter key. Keep host adaptations in `scripts/build-codex-plugin.ts` and verify the generated `plugins/recall/skills/` behavior; never infer parity from matching bytes. - `recall-scout/SKILL.md` output is chat-only by default; a persisted scout artifact goes to `.agents/atlas/artifacts/` (see root `AGENTS.md`), never under `agent-skills/` or `docs/`. - `recall-scout/SKILL.md` grounds its repo map / key paths / risks in **CodeGraph (primary)** via the external `codegraph` CLI: a `codegraph status --json` capability probe (on an unindexed repo scout **offers** `codegraph init` and runs it only on an explicit user yes — never auto-runs), a cheap orientation bundle (`status --json` + `files --max-depth 2 --no-metadata`), and at most two narrow `explore` calls under the query discipline pinned in the skill, with a grep/tree-walk backstop. CodeGraph is an enhancement, never a hard dependency — scout degrades gracefully when it isn't present. - The former `/Recall:*` slash commands (`commands/Recall/`) were retired in favor of these skills (#228); `install.sh`/`update.sh` clean up their symlinks. Do not reintroduce a parallel command surface. ## Work Guidance -- Add a skill: create `/SKILL.md`, document it in `docs/agent-skills.md`, and add `` to `RECALL_SKILL_NAMES` in `uninstall.sh`. +- Add a skill: create `/SKILL.md`, document it in `docs/agent-skills.md`, add `` to `RECALL_SKILL_NAMES` in `uninstall.sh`, and regenerate the Codex plugin adapters. ## Verification -`tests/install/skills.test.ts` (install/uninstall lifecycle), `tests/commands/scout-workflow.test.ts` (scout workflow contracts). +`tests/install/skills.test.ts` (install/uninstall lifecycle), `tests/commands/scout-workflow.test.ts` (scout workflow contracts), `tests/plugins/codex-plugin.test.ts` (generated Codex adapters). ## Child DOX Index diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 748e97e..c7c3f05 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -8,7 +8,7 @@ User-facing published documentation: installation, CLI / MCP / agent-skill refer ## Ownership -- Reference & guides — `installation.md`, `cli-reference.md`, `mcp-tools.md`, `agent-skills.md`, `architecture.md`, `troubleshooting.md`, `releasing.md`, `upgrading.md`, `OPENCODE_INTEGRATION.md`, `PI_INTEGRATION.md` +- Reference & guides — `installation.md`, `cli-reference.md`, `mcp-tools.md`, `agent-skills.md`, `architecture.md`, `troubleshooting.md`, `releasing.md`, `upgrading.md`, `OPENCODE_INTEGRATION.md`, `PI_INTEGRATION.md`, `CODEX_INTEGRATION.md` - `adr/` — architectural decision records - `agents/` — agent skill docs (`issue-tracker.md`, `triage-labels.md`, `board-status.md`, `domain.md`, `worker-flow.md`, `dox-framework.md`) @@ -17,7 +17,7 @@ User-facing published documentation: installation, CLI / MCP / agent-skill refer - `docs/` is EXCLUSIVELY user-facing published docs. NEVER store plans, specs, designs, handoffs, or scout artifacts here — those live under `.agents/atlas/` (see root `AGENTS.md`). - ADRs are numbered and append-only: change a decision by adding a new `NNNN-*.md`; don't rewrite a past ADR's decision. - Keep docs in sync with behavior — a command, MCP, or lifecycle change must update the matching reference (`cli-reference`, `mcp-tools`, `agent-skills`, `installation`, `upgrading`). -- Host integration guides stay aligned with their root counterparts (`FOR_OPENCODE.md`, `FOR_PI.md`). +- Host integration guides stay aligned with their root counterparts (`FOR_OPENCODE.md`, `FOR_PI.md`). Codex's canonical guide is `CODEX_INTEGRATION.md` because its native plugin is repository-distributed rather than lifecycle-installed. ## Work Guidance diff --git a/docs/CODEX_INTEGRATION.md b/docs/CODEX_INTEGRATION.md new file mode 100644 index 0000000..8429c66 --- /dev/null +++ b/docs/CODEX_INTEGRATION.md @@ -0,0 +1,69 @@ +# Codex Integration + +[Back to README](../README.md) + +Recall is packaged for Codex with Codex's native plugin primitive. + +The checked-in marketplace manifest is `.agents/plugins/marketplace.json`, and the plugin bundle is `plugins/recall/`. + +The bundle registers `recall-memory` through `.mcp.json` and exposes nine generated Codex skill adapters. + +## Install + +Install Recall first so `recall-mcp` is on `PATH`: + +```bash +bun install -g recall-memory +recall init +``` + +Then add this repository as a Codex marketplace and install its plugin: + +```bash +codex plugin marketplace add /absolute/path/to/Recall +codex plugin add recall@recall-marketplace +``` + +The repository path is deliberate for the current checked-in marketplace. + +A future remote marketplace can remove that local-clone prerequisite after its distribution and update policy are defined. + +## What MCP covers + +MCP is the primary cross-host seam. + +The plugin exposes all nine Recall operations: `memory_search`, `memory_hybrid_search`, `memory_recall`, `context_for_agent`, `memory_add`, `memory_stats`, `loa_show`, `memory_dump`, and `decision_update`. + +They query and write the same SQLite store as the CLI. + +Set `RECALL_DB_PATH` in the MCP server environment when the store is not at the default path. + +## What the plugin does not cover + +This bundle has no verified transcript-aware Codex hook contract equivalent to Recall's Claude `Stop`, `SessionStart`, or `PreCompact` lifecycle contracts. + +Therefore this plugin does not claim lifecycle auto-capture, automatic L0/L1 injection, or pre-compaction flushing. + +Codex can call `memory_dump` only when it supplies the visible messages explicitly; Recall does not infer Codex's private transcript location or format. + +The generated `recall-dump` adapter is marked explicit-only with `agents/openai.yaml`, because Codex does not interpret Claude's `disable-model-invocation` frontmatter. + +## Current boundaries + +The following remain intentionally unresolved instead of being guessed: + +- Codex transcript format and whether a stable, supported transcript API exists. +- Trust and consent rules for any future automatic capture. +- The installed plugin-cache path as a durable runtime dependency. +- Ownership and repair policy for user-managed Codex MCP configuration, including custom database paths. +- Remote marketplace publication, update, and release ownership. + +These unknowns do not block the nine MCP operations. + +They do block any claim that Codex has lifecycle parity with Claude Code. + +## Development verification + +`bun run build:codex-plugin` regenerates the Codex skill adapters from the canonical `agent-skills/` sources. + +`bun run test:e2e:codex-plugin` builds Recall, installs the plugin with the current local Codex CLI in an isolated `CODEX_HOME`, invokes every MCP tool against a disposable `RECALL_DB_PATH`, and verifies that the production database metadata did not change. diff --git a/docs/OPENCODE_INTEGRATION.md b/docs/OPENCODE_INTEGRATION.md index d7eb495..24f2370 100644 --- a/docs/OPENCODE_INTEGRATION.md +++ b/docs/OPENCODE_INTEGRATION.md @@ -125,7 +125,8 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs" import { join } from "path" import { homedir } from "os" -const DROP_DIR = join(homedir(), ".claude", "MEMORY", "opencode-sessions") +const RECALL_HOME = process.env.RECALL_HOME || join(homedir(), ".agents", "Recall") +const DROP_DIR = join(RECALL_HOME, "MEMORY", "opencode-sessions") const TRACKER_PATH = join(DROP_DIR, ".extracted.json") // Load persistent dedup tracker from disk @@ -177,7 +178,7 @@ export const RecallExtract: Plugin = async ({ $ }) => { **Why this approach:** - `opencode session export` is a **verified, stable CLI command** — not an assumed API - `ctx.$` Bun shell is **confirmed in plugin docs** with examples -- Persistent dedup via JSON file at `~/.claude/MEMORY/opencode-sessions/.extracted.json` — survives plugin restarts +- Persistent dedup via JSON file at `~/.agents/Recall/MEMORY/opencode-sessions/.extracted.json` — survives plugin restarts - Defensive event property access covers all three known shapes: `event.sessionId`, `event.session_id`, `event.properties.sessionId` - Drop directory pattern: `RecallBatchExtract.ts` already runs every 30 minutes and can scan this directory - No new CLI commands needed — `RecallBatchExtract.ts` reads markdown files the same way it reads JSONL @@ -360,7 +361,7 @@ Add a `source` column to `sessions` table (the natural place — messages FK to ALTER TABLE sessions ADD COLUMN source TEXT DEFAULT 'claude-code'; ``` -The extraction pipeline tags OpenCode sessions with `source: 'opencode'`. Existing records default to `claude-code`. +The extraction pipeline tags OpenCode sessions with `source: 'opencode'`. The historical migration preserves `claude-code` on legacy records; fresh host-neutral sessions default to `unknown` when no adapter supplies a source. **No changes to MCP tools** — they return results from all sources. The `source` field is metadata for provenance, not filtering. @@ -370,7 +371,8 @@ The existing cron job needs one addition: scan the OpenCode drop directory along ```typescript // In RecallBatchExtract.ts — add to the session discovery logic: -const OPENCODE_DROP_DIR = join(homedir(), ".claude", "MEMORY", "opencode-sessions") +const RECALL_HOME = process.env.RECALL_HOME || join(homedir(), ".agents", "Recall") +const OPENCODE_DROP_DIR = join(RECALL_HOME, "MEMORY", "opencode-sessions") function findOpenCodeSessions(): string[] { if (!existsSync(OPENCODE_DROP_DIR)) return [] @@ -421,7 +423,7 @@ OpenCode prefixes MCP tools with the server name + underscore: - `RecallExtract.ts` plugin using `opencode session export` CLI via `$` shell - Persistent dedup tracker (`.extracted.json`) - Defensive `session.idle` event property access -- Drop directory at `~/.claude/MEMORY/opencode-sessions/` +- Drop directory at `~/.agents/Recall/MEMORY/opencode-sessions/` - `RecallBatchExtract.ts` updated to scan drop directory ### Phase 3: Context Injection diff --git a/docs/PI_INTEGRATION.md b/docs/PI_INTEGRATION.md index 8f91054..321fb20 100644 --- a/docs/PI_INTEGRATION.md +++ b/docs/PI_INTEGRATION.md @@ -36,7 +36,7 @@ Pi stores conversation history as a tree JSONL (branching structure from edits a 1. **Linearize**: Read the tree JSONL and walk the active branch (most recent path from root to leaf) 2. **Convert**: Render the active-branch messages as markdown (user/assistant turns) -3. **Drop**: Write the markdown file to `~/.claude/MEMORY/pi-sessions/.md` +3. **Drop**: Write the markdown file to `~/.agents/Recall/MEMORY/pi-sessions/.md` 4. **Extract**: The existing `RecallBatchExtract.ts` cron job scans the drop directory every 30 minutes and processes new files via Claude Haiku This reuses the RecallBatchExtract infrastructure without modification — it already handles markdown input from the OpenCode drop dir. @@ -45,7 +45,7 @@ The extraction logic lives in `~/.pi/agent/extensions/RecallExtract.ts`, which h ### Deduplication -A JSON tracker at `~/.claude/MEMORY/pi-sessions/.extraction_tracker.json` records processed session IDs. It persists across extension restarts to avoid re-extracting sessions. +A JSON tracker at `~/.agents/Recall/MEMORY/pi-sessions/.extraction_tracker.json` records processed session IDs. It persists across extension restarts to avoid re-extracting sessions. ## Memory Injection @@ -78,7 +78,7 @@ If `recall` is unavailable or times out (5s limit), the hook skips silently. | `~/.pi/agent/mcp.json` | MCP server registration | | `~/.pi/agent/Recall_GUIDE.md` | Agent guide (installed from `FOR_PI.md` with Pi-specific tool names) | | `~/.pi/agent/AGENTS.md` | Syntax-free pointer to the installed Recall guide and live MCP schemas | -| `~/.claude/MEMORY/pi-sessions/` | Drop directory for extracted session markdown | +| `~/.agents/Recall/MEMORY/pi-sessions/` | Drop directory for extracted session markdown | | `~/.agents/Recall/recall.db` | Shared SQLite DB (same as Claude Code and OpenCode) | ## Tool Name Mapping diff --git a/docs/agent-skills.md b/docs/agent-skills.md index 51a8ab9..5f2599b 100644 --- a/docs/agent-skills.md +++ b/docs/agent-skills.md @@ -2,7 +2,7 @@ [Back to README](../README.md) -Recall ships its command surface as [Agent Skills](https://agentskills.io) — one `SKILL.md` per skill, installed identically to every supported host: Claude Code (`~/.claude/skills/`), Pi (`~/.pi/agent/skills/`), and omp (`~/.omp/agent/skills/`). In Claude Code, invoke them as `/recall-`; the model can also trigger most of them itself when the conversation calls for it. +Recall ships its command surface as [Agent Skills](https://agentskills.io) — one canonical `SKILL.md` per skill for Claude Code (`~/.claude/skills/`), Pi (`~/.pi/agent/skills/`), and omp (`~/.omp/agent/skills/`). Codex receives generated native adapters in `plugins/recall/skills/`; byte-identical skill text is not treated as behavioral equivalence across hosts. In Claude Code, invoke skills as `/recall-`; the model can also trigger most of them itself when the conversation calls for it. > **Migrating from `/Recall:*` slash commands?** The old namespaced slash commands were retired in favor of these skills (issue #228) — same bodies, one namespace across all hosts. `install.sh` / `update.sh` remove the stale `~/.claude/commands/Recall/` symlinks automatically. See [Upgrading](upgrading.md). @@ -19,7 +19,7 @@ What it does: 2. Creates a curated LoA entry with Fabric extract_wisdom analysis 3. Makes everything immediately searchable -This skill is user-invoked only (`disable-model-invocation: true`) — dumping a session is always your call, never the model's. +This skill is user-invoked only — dumping a session is always your call, never the model's. Claude uses `disable-model-invocation: true`; the generated Codex adapter uses `agents/openai.yaml` with `allow_implicit_invocation: false`. ### recall-search @@ -95,4 +95,6 @@ Browse and view Library of Alexandria entries. Skill canonicals live under `~/.agents/Recall/shared/skills//`; per-file symlinks are created in each host's skills directory during setup. Source files are in `agent-skills/` in the Recall repository. +The Codex plugin adapters are generated with `bun run build:codex-plugin` and packaged with the native plugin bundle documented in [Codex Integration](CODEX_INTEGRATION.md). + If skills are missing after an update, re-run `./install.sh` to relink them. diff --git a/docs/architecture.md b/docs/architecture.md index b59cfbc..79d07c1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -57,11 +57,27 @@ Project-local L0 override: `./.atlas-recall/identity.md` takes precedence over the global `~/.claude/MEMORY/identity.md`. `RECALL_IDENTITY_PATH` overrides both. +## Host Boundaries + +Host-neutral CLI and MCP logic lives outside `src/hosts/`. + +Native host adapters own config shapes, paths, transcript parsing, and native command discovery under `src/hosts/`. + +Lifecycle hooks use the same boundary under `hooks/lib/hosts/`; the generic extraction cascade depends only on the `ExtractionProvider` interface. + +Recall-owned logs and mutable state resolve from `RECALL_HOME` (default `~/.agents/Recall`) instead of a host configuration directory. + +Codex is distributed as the native plugin in `plugins/recall/`, discovered through `.agents/plugins/marketplace.json`. + +Its `.mcp.json` registers `recall-memory`, and `scripts/build-codex-plugin.ts` generates host-adapted skills from the canonical `agent-skills/` sources. + +MCP covers the nine query/write operations but does not define transcript lifecycle events; see [Codex Integration](CODEX_INTEGRATION.md). + ## Database Tables | Table | Purpose | FTS5 Indexed | |-------|---------|:---:| -| sessions | Claude Code session metadata (ID, timestamps, project, branch) | No | +| sessions | Cross-host session metadata (ID, timestamps, project, branch, source) | No | | messages | Conversation turns (user + assistant content); includes `importance` (1-10) column | Yes | | loa_entries | Library of Alexandria curated knowledge with Fabric extraction; includes `importance` (1-10, floor 5) column | Yes | | decisions | Architectural decisions with reasoning; includes `status` (active/superseded/reverted), `confidence` (high/medium/low), and `importance` (1-10) columns | Yes | @@ -155,7 +171,7 @@ graph TD D --> E{Size > 120K chars?} E -->|Yes| F[Chunk + Meta-Extract] E -->|No| G[Single Extraction] - F --> H[Send to Claude Haiku] + F --> H[Run native extraction provider] G --> H H --> I{Quality Gate} I -->|Pass| J[Store to Memory Files] @@ -174,7 +190,7 @@ graph TD The hook self-spawns in background so the session exits immediately (non-blocking). -If Haiku is unavailable, falls back to a local Ollama model (configurable via `RECALL_OLLAMA_MODEL`). +The current Claude lifecycle adapter tries Claude Haiku first and falls back to a local Ollama model (configurable via `RECALL_OLLAMA_MODEL`). ## Technical Details diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 31eab0d..9b5d459 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -2,7 +2,7 @@ [Back to README](../README.md) -All 8 tools available when Claude Code connects to the Recall MCP server (`recall-memory`). +All 9 tools available to any MCP client connected to the Recall MCP server (`recall-memory`). --- @@ -155,7 +155,7 @@ loa_show({ id: 1 }) ## memory_dump -Flush the current conversation session into SQLite. Extracts messages, decisions, and learnings from the session transcript and persists them to the database. Use when the user says `/dump`. +Persist an explicitly supplied conversation session into SQLite. Claude's native adapter can discover its current transcript; other hosts must supply visible messages instead of relying on an assumed private transcript format. **Parameters** @@ -163,14 +163,27 @@ Flush the current conversation session into SQLite. Extracts messages, decisions |------|------|----------|---------|-------------| | title | string | yes | — | Descriptive title for this session dump | | project | string | no | — | Override the auto-detected project name | +| session_id | string | no | generated | Stable session identifier supplied by the host | +| source | string | no | `mcp` | Host source: `claude`, `opencode`, `pi`, `codex`, or `mcp` | +| messages | array | no | — | Explicit `{ role, content, timestamp? }` messages. Required when the host has no native transcript adapter. | | skip_fabric | boolean | no | true | Skip Fabric processing (faster; uses a basic summary instead of `extract_wisdom`) | **Returns:** Summary of records imported: message count, decisions, learnings, and breadcrumbs extracted from the session. ```js -memory_dump({ title: "Auth middleware refactor — JWT validation approach", project: "my-app" }) +memory_dump({ + title: "Auth middleware refactor — JWT validation approach", + project: "my-app", + source: "codex", + messages: [ + { role: "user", content: "Refactor the auth middleware" }, + { role: "assistant", content: "Implemented JWT validation" } + ] +}) ``` +`memory_dump` is an explicit operation, not lifecycle auto-capture. MCP has no portable event contract for session stop, session start, or pre-compaction. + --- ## decision_update diff --git a/hooks/AGENTS.md b/hooks/AGENTS.md index a5e6ca0..7dad467 100644 --- a/hooks/AGENTS.md +++ b/hooks/AGENTS.md @@ -15,13 +15,14 @@ Standalone scripts that hosts run across a session lifecycle, plus cron jobs tha - `RecallBatchExtract.ts` — cron (batch-extract sessions missed during crashes) - `RecallTelosSync.ts` — cron (sync Telos goals/projects into memory) - `extract_prompt.md` — extraction prompt template (copied to `~/.claude/MEMORY/`) -- `lib/` — shared hook helpers +- `lib/` — shared host-neutral hook helpers; `lib/hosts/` owns native lifecycle payloads, paths, commands, authentication, and extraction providers Installed as per-file symlinks into `~/.claude/hooks/` from `~/.agents/Recall/shared/hooks/`. ## Local Contracts - Hooks are SELF-CONTAINED: never import from `src/`. Shared hook logic lives in `lib/` here. +- Generic hook helpers depend on `lib/events.ts`, `lib/extraction-provider.ts`, and the native-provider registry in `lib/hosts/`; native payloads, path encoding, commands, auth, and recursion guards stay in a host adapter. - Documented DRY exception: small utilities (e.g. bun-path resolution) are intentionally duplicated inside `RecallExtract.ts` / `RecallBatchExtract.ts` so they never reach into `src/`. Do not "DRY this up." - DB-path resolution is centralized in `lib/db-path.ts` — the CLI and every hook agree through it. - Shebang `#!/usr/bin/env bun`. No build step — editing the canonical file updates the live symlink. @@ -31,11 +32,11 @@ Installed as per-file symlinks into `~/.claude/hooks/` from `~/.agents/Recall/sh - Add a hook helper: create `lib/.ts` (standalone). - Modify extraction: edit `RecallExtract.ts`; the quality gate is `lib/extraction-quality.ts` (requires SUMMARY + MAIN IDEAS). -- The extraction-model cascade (Claude CLI + Ollama fallback) + topic/summary helpers live in `lib/extract-model.ts` — shared by the Stop hook and the in-session hook. Reuse it; don't re-invent the model call. +- The host-neutral extraction cascade + topic/summary helpers live in `lib/extract-model.ts`; native providers register through `lib/hosts/index.ts`, with Claude-specific behavior in `lib/hosts/claude/extraction-provider.ts`. Reuse the provider interface; don't call a native model command from generic hook code. - Mid-session learning loop logic is `lib/insession.ts` (pure/dbPath-injectable: config, cadence, window slice, lock-cooperative extraction). `RecallInSession.ts` is the thin hook wrapper. - Correction capture (#52) is the pure `lib/correction-detector.ts` (two-pass strong/weak+directive/negative classifier) plus `handleCorrection`/`correctionAllowed`/`readCorrectionsConfig` in `lib/insession.ts`. It writes VERBATIM user text, so it `scrub()`s before `writeLearningsBatch` and rate-limits 1-per-3 prompts via the MONOTONIC `prompts_seen`/`last_correction_turn` columns (never the per-window `turns_seen`, which `resetWindow` zeroes). - Reuse `lib/extraction-{lock,semaphore,tracker}.ts` for locking/concurrency/dedup state — don't reinvent it. -- Claude Code project-folder path encoding is `lib/path-encoding.ts`. +- Claude Code project-folder path encoding is `lib/hosts/claude/path-encoding.ts`. ## Verification @@ -43,4 +44,4 @@ Installed as per-file symlinks into `~/.claude/hooks/` from `~/.agents/Recall/sh ## Child DOX Index -No child docs — `lib/` (db-path, extraction-*, path-encoding, pid-utils) shares these contracts. +No child docs — `lib/` and `lib/hosts/` share these contracts. diff --git a/hooks/RecallBatchExtract.ts b/hooks/RecallBatchExtract.ts index f00ad9a..65ba982 100644 --- a/hooks/RecallBatchExtract.ts +++ b/hooks/RecallBatchExtract.ts @@ -33,8 +33,9 @@ import { getDbPath } from './lib/sqlite-writers'; const CLAUDE_DIR = join(process.env.HOME!, '.claude'); const PROJECTS_DIR = join(CLAUDE_DIR, 'projects'); -const MEMORY_DIR = join(CLAUDE_DIR, 'MEMORY'); -const SESSION_EXTRACT = join(CLAUDE_DIR, 'hooks', 'RecallExtract.ts'); +const RECALL_HOME = process.env.RECALL_HOME || join(process.env.HOME!, '.agents', 'Recall'); +const MEMORY_DIR = join(RECALL_HOME, 'MEMORY'); +const SESSION_EXTRACT = join(RECALL_HOME, 'shared', 'hooks', 'RecallExtract.ts'); // OpenCode drop directory — plugin exports markdown sessions here const OPENCODE_DROP_DIR = join(MEMORY_DIR, 'opencode-sessions'); diff --git a/hooks/RecallExtract.ts b/hooks/RecallExtract.ts index 7f08633..b41bcd1 100644 --- a/hooks/RecallExtract.ts +++ b/hooks/RecallExtract.ts @@ -32,7 +32,7 @@ import { join } from 'path'; import { spawn } from 'child_process'; import { shouldSkipExtraction } from './lib/extraction-quality'; import { runExtractionCascade, extractTopics, deriveSummary } from './lib/extract-model'; -import { encodeProjectDir } from './lib/path-encoding'; +import { encodeProjectDir } from './lib/hosts/claude/path-encoding'; import { wasAlreadyExtracted as trackerWasAlreadyExtracted, markAsExtracted as trackerMarkAsExtracted, diff --git a/hooks/RecallInSession.ts b/hooks/RecallInSession.ts index 797e849..5e9796c 100644 --- a/hooks/RecallInSession.ts +++ b/hooks/RecallInSession.ts @@ -29,11 +29,11 @@ import { readInSessionConfig, readCorrectionsConfig, handleCorrection, - eventFromHookInput, decideInSession, runInSessionExtraction, sessionIdFromArgs, } from './lib/insession'; +import { eventFromClaudeHookInput } from './lib/hosts/claude/lifecycle'; import { runExtractionCascade, extractTopics, deriveSummary } from './lib/extract-model'; import { resolveDbPath } from './lib/db-path'; @@ -119,7 +119,7 @@ async function main(): Promise { process.exit(0); } - const event = eventFromHookInput(input); + const event = eventFromClaudeHookInput(input); if (!event) process.exit(0); const convPath = input.transcript_path; diff --git a/hooks/RecallPreCompact.ts b/hooks/RecallPreCompact.ts index a1ae8de..c47e55e 100644 --- a/hooks/RecallPreCompact.ts +++ b/hooks/RecallPreCompact.ts @@ -33,7 +33,7 @@ import { existsSync, mkdirSync, openSync, closeSync, readFileSync, writeFileSync, readdirSync, statSync, unlinkSync, appendFileSync } from 'fs'; import { join } from 'path'; import { Database } from 'bun:sqlite'; -import { encodeProjectDir } from './lib/path-encoding'; +import { encodeProjectDir } from './lib/hosts/claude/path-encoding'; import { resolveDbPath as getDbPath } from './lib/db-path'; // ─── Path resolution (call-time, not load-time) ───────────────────── diff --git a/hooks/lib/consolidate-core.ts b/hooks/lib/consolidate-core.ts index 2b4a0ff..d2af09a 100644 --- a/hooks/lib/consolidate-core.ts +++ b/hooks/lib/consolidate-core.ts @@ -212,8 +212,8 @@ export async function applyConsolidation( // --------------------------------------------------------------------------- // Child entrypoint: `bun run consolidate-core.ts` with the plan on stdin. -// The src CLI spawns this on --execute (CLAUDECODE='' so the nested `claude -p` -// cascade doesn't recursively fire Stop hooks). Reads { clusters } JSON from +// The src CLI marks this as nested extraction; each native provider maps that +// host-neutral signal to any host-specific recursion guard it requires. Reads { clusters } JSON from // stdin, runs the real cascade, prints the ConsolidateApplyResult JSON. // --------------------------------------------------------------------------- diff --git a/hooks/lib/events.ts b/hooks/lib/events.ts new file mode 100644 index 0000000..1a59dfd --- /dev/null +++ b/hooks/lib/events.ts @@ -0,0 +1,2 @@ +/** Host-neutral event categories consumed by the in-session cadence engine. */ +export type EventKind = 'turn' | 'tool'; diff --git a/hooks/lib/extract-model.ts b/hooks/lib/extract-model.ts index 2d48d06..b9f9cb1 100644 --- a/hooks/lib/extract-model.ts +++ b/hooks/lib/extract-model.ts @@ -1,250 +1,16 @@ -// Shared extraction-model cascade — self-contained, no imports from src/. +// Host-neutral extraction-provider cascade shared by lifecycle hooks. // -// This is the single home for the "run the extraction model over raw text and -// derive topics/summary from the result" logic. Factored out of RecallExtract.ts -// (issue #51 P3) so BOTH the end-of-session Stop hook (RecallExtract.ts) and the -// mid-session in-session hook (RecallInSession.ts) reuse the SAME model call -// instead of re-inventing it — RecallExtract.ts self-executes main() on import, -// so it cannot be imported directly; this lib is the importable seam. -// -// Moved verbatim from RecallExtract.ts (behavior-preserving): the Claude CLI -// cascade (with chunking for very large inputs), the local Ollama fallback, and -// the topic/summary derivation helpers. Paths and logging are resolved at call -// time from $HOME so the lib stays self-contained and testable. +// Provider-specific commands, authentication, environment, and paths live +// under hooks/lib/hosts//. The cascade only orders providers and derives +// metadata from their common extracted-markdown contract. -import { existsSync, readFileSync, appendFileSync } from 'fs'; -import { join } from 'path'; import { execSync } from 'child_process'; +import type { ExtractionProvider } from './extraction-provider'; +import { nativeExtractionProviders } from './hosts'; -// Claude CLI extraction (uses Claude Code's existing auth — no API key needed) -const CLAUDE_CLI_MODEL = 'haiku'; - -// Local Ollama LLM fallback (configure OLLAMA_URL env var or defaults to localhost) const LOCAL_OLLAMA_URL = `${process.env.OLLAMA_URL || 'http://localhost:11434'}/api/generate`; const LOCAL_OLLAMA_MODEL = process.env.Recall_OLLAMA_MODEL || 'qwen2.5:3b'; -function getMemoryDir(): string { - return join(process.env.HOME!, '.claude', 'MEMORY'); -} - -function getExtractPatternPath(): string { - return join(getMemoryDir(), 'extract_prompt.md'); -} - -/** Append a line to the shared EXTRACT_LOG (best-effort, same file the Stop hook uses). */ -function logExtract(message: string): void { - try { - const line = `[${new Date().toISOString()}] ${message}\n`; - appendFileSync(join(getMemoryDir(), 'EXTRACT_LOG.txt'), line, 'utf-8'); - } catch { - // Ignore logging errors - } -} - -/** - * Find the claude CLI binary - */ -function findClaudeCli(): string | null { - const candidates = [ - '/usr/local/bin/claude', - '/usr/bin/claude', - join(process.env.HOME!, '.npm-global', 'bin', 'claude'), - join(process.env.HOME!, '.local', 'bin', 'claude'), - join(process.env.HOME!, '.bun', 'bin', 'claude'), - ]; - for (const p of candidates) { - if (existsSync(p)) return p; - } - // Try PATH lookup - try { - const which = execSync('which claude 2>/dev/null', { encoding: 'utf-8' }).trim(); - if (which) return which; - } catch {} - return null; -} - -/** - * Get the extraction system prompt from the fabric pattern file - */ -function getExtractionPrompt(): string { - try { - const patternPath = getExtractPatternPath(); - if (existsSync(patternPath)) { - return readFileSync(patternPath, 'utf-8').trim(); - } - } catch {} - // Inline fallback if pattern file missing - return `You are an expert at extracting meaningful, factual information from AI coding session transcripts. -Extract ONLY what actually happened. Follow this format EXACTLY: - -## ONE SENTENCE SUMMARY -[Single factual sentence] - -## MAIN IDEAS -- [Concrete thing 1] -- [Concrete thing 2] - -## DECISIONS MADE -- [Decision]: [reason] - -## THINGS TO REJECT / AVOID -- [Thing to avoid]: [why] - -## ERRORS FIXED -- [Error]: [fix] - -## SESSION CONTEXT -[One sentence about impact on infrastructure]`; -} - -/** - * Extract using the claude CLI (primary method) - * Uses Claude Code's existing authentication — no separate API key needed. - * Calls `claude -p --model haiku` with the extraction prompt piped via stdin. - */ -async function extractWithClaude(messages: string): Promise { - const claudePath = findClaudeCli(); - if (!claudePath) { - console.error('[FabricExtract] claude CLI not found in PATH'); - return null; - } - - const systemPrompt = getExtractionPrompt(); - - // Truncate to fit context window (~180K chars ≈ ~45K tokens, well within haiku's 200K) - // But keep it reasonable to control cost - const maxChars = 120000; - const truncated = messages.length > maxChars ? messages.slice(-maxChars) : messages; - - const userMessage = `${systemPrompt}\n\n---\n\nExtract the key information from this AI coding session transcript:\n\n${truncated}`; - - try { - const result = execSync( - `"${claudePath}" -p --model ${CLAUDE_CLI_MODEL} --output-format text --setting-sources ""`, - { - input: userMessage, - encoding: 'utf-8', - timeout: 300000, // 5 minute timeout - maxBuffer: 10 * 1024 * 1024, - env: { ...process.env, CLAUDECODE: '' }, - } - ); - - const text = result?.trim(); - if (text && text.length > 50) { - console.error(`[FabricExtract] Claude CLI extraction successful (model=${CLAUDE_CLI_MODEL}, ${text.length} chars)`); - logExtract(`Claude CLI extraction successful: model=${CLAUDE_CLI_MODEL}, output_chars=${text.length}`); - return text; - } - console.error('[FabricExtract] Claude CLI returned empty/short response'); - return null; - } catch (error: any) { - console.error(`[FabricExtract] Claude CLI extraction failed: ${error.message}`); - return null; - } -} - -/** - * Chunked extraction for large files (>120K chars of messages) - * Splits messages into chunks, extracts each, then meta-extracts a final summary - */ -async function extractWithClaudeChunked(messages: string): Promise { - const claudePath = findClaudeCli(); - if (!claudePath) return null; - - const CHUNK_SIZE = 80000; // ~20K tokens per chunk, well within limits - const chunks: string[] = []; - - // Split by lines to avoid breaking mid-message - const lines = messages.split('\n'); - let currentChunk = ''; - for (const line of lines) { - if (currentChunk.length + line.length > CHUNK_SIZE && currentChunk.length > 0) { - chunks.push(currentChunk); - currentChunk = ''; - } - currentChunk += line + '\n'; - } - if (currentChunk.trim()) chunks.push(currentChunk); - - console.error(`[FabricExtract] CHUNKED: Splitting ${messages.length} chars into ${chunks.length} chunks`); - logExtract(`CHUNKED: ${messages.length} chars -> ${chunks.length} chunks`); - - // Extract each chunk - const partials: string[] = []; - for (let i = 0; i < chunks.length; i++) { - console.error(`[FabricExtract] CHUNKED: Extracting chunk ${i + 1}/${chunks.length} (${chunks[i].length} chars)`); - const result = await extractWithClaude(chunks[i]); - if (result) { - partials.push(`--- Chunk ${i + 1}/${chunks.length} ---\n${result}`); - } - // Rate limit between chunks - if (i < chunks.length - 1) { - await new Promise((resolve) => setTimeout(resolve, 2000)); - } - } - - if (partials.length === 0) return null; - - // If only one chunk succeeded, use it directly - if (partials.length === 1) return partials[0].replace(/^--- Chunk \d+\/\d+ ---\n/, ''); - - // Meta-extract: merge partial extractions into final summary - console.error(`[FabricExtract] CHUNKED: Meta-extracting ${partials.length} partial results`); - const mergePrompt = partials.join('\n\n'); - - const systemPrompt = `You are merging multiple partial session extractions into one coherent summary. Combine all findings, deduplicate, and output in this exact format: - -## ONE SENTENCE SUMMARY -[Single comprehensive sentence covering the full session] - -## MAIN IDEAS -- [Key ideas from ALL chunks combined] - -## DECISIONS MADE -- [All decisions from all chunks] - -## THINGS TO REJECT / AVOID -- [All rejections from all chunks] - -## ERRORS FIXED -- [All errors from all chunks] - -## SESSION CONTEXT -[One comprehensive sentence about the full session's impact]`; - - const userMessage = `${systemPrompt}\n\n---\n\nMerge these ${partials.length} partial extractions into one comprehensive summary:\n\n${mergePrompt}`; - - try { - const result = execSync( - `"${claudePath}" -p --model ${CLAUDE_CLI_MODEL} --output-format text --setting-sources ""`, - { - input: userMessage, - encoding: 'utf-8', - timeout: 300000, - maxBuffer: 10 * 1024 * 1024, - env: { ...process.env, CLAUDECODE: '' }, - } - ); - - const text = result?.trim(); - if (text && text.length > 50) { - console.error(`[FabricExtract] CHUNKED: Meta-extraction successful`); - logExtract(`CHUNKED: Meta-extraction successful, output_chars=${text.length}`); - return text; - } - } catch (err: any) { - console.error(`[FabricExtract] CHUNKED: Meta-extraction failed: ${err.message}`); - } - - // Fallback: concatenate partials - return partials.map((p) => p.replace(/^--- Chunk \d+\/\d+ ---\n/, '')).join('\n\n'); -} - -/** - * Extract using local Ollama LLM as fallback - * Returns extracted text or null if failed - */ function extractWithOllama(messages: string): string | null { const systemPrompt = `You are an expert at extracting meaningful information from conversations. Extract in this exact format: @@ -276,15 +42,12 @@ function extractWithOllama(messages: string): string | null { [One sentence about overall impact on the project or infrastructure]`; try { - // Truncate messages to fit 3B model context (keep last ~8000 chars) const truncated = messages.length > 8000 ? messages.slice(-8000) : messages; - const payload = JSON.stringify({ model: LOCAL_OLLAMA_MODEL, - prompt: systemPrompt + '\n\n---\nCONVERSATION:\n' + truncated, + prompt: `${systemPrompt}\n\n---\nCONVERSATION:\n${truncated}`, stream: false, }); - const result = execSync( `curl -s --connect-timeout 10 --max-time 180 -X POST ${LOCAL_OLLAMA_URL} -H "Content-Type: application/json" -d @-`, { @@ -292,86 +55,65 @@ function extractWithOllama(messages: string): string | null { encoding: 'utf-8', timeout: 200000, maxBuffer: 10 * 1024 * 1024, - } + }, ); - - const parsed = JSON.parse(result); + const parsed = JSON.parse(result) as { response?: string }; if (parsed.response && parsed.response.trim().length > 50) { console.error('[FabricExtract] Ollama extraction successful'); return parsed.response.trim(); } return null; - } catch (error: any) { - console.error(`[FabricExtract] Ollama extraction failed: ${error.message}`); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.error(`[FabricExtract] Ollama extraction failed: ${message}`); return null; } } -/** - * Run the extraction-model cascade over raw transcript/markdown text: Claude CLI - * (chunked for very large inputs) with a local Ollama fallback. Returns the - * extracted markdown, or null if every method failed. Shared by the Stop path - * (RecallExtract.ts) and the in-session path (RecallInSession.ts), injected into - * runExtractCore as deps.extract. - */ -export async function runExtractionCascade(messages: string): Promise { - let extracted = ''; - - // Attempt 1: Claude CLI (uses Claude Code's existing auth — no API key needed). - // Chunked extraction for large files (>120K chars). - if (messages.length > 120000) { - console.error(`[FabricExtract] Large input (${messages.length} chars), using chunked extraction...`); - const chunkedResult = await extractWithClaudeChunked(messages); - if (chunkedResult) extracted = chunkedResult; - } else { - console.error('[FabricExtract] Trying Claude CLI extraction...'); - const claudeResult = await extractWithClaude(messages); - if (claudeResult) extracted = claudeResult; - } - - // Attempt 2: local Ollama LLM fallback (free, lower quality). - if (!extracted) { - console.error('[FabricExtract] Claude CLI failed, trying local Ollama LLM fallback...'); - const ollamaResult = extractWithOllama(messages); - if (ollamaResult) extracted = ollamaResult; +const ollamaExtractionProvider: ExtractionProvider = { + id: 'ollama', + extract: extractWithOllama, +}; + +export const DEFAULT_EXTRACTION_PROVIDERS: readonly ExtractionProvider[] = [ + ...nativeExtractionProviders, + ollamaExtractionProvider, +]; + +/** Run providers in order and return the first usable extraction. */ +export async function runExtractionCascade( + messages: string, + providers: readonly ExtractionProvider[] = DEFAULT_EXTRACTION_PROVIDERS, +): Promise { + for (const provider of providers) { + console.error(`[FabricExtract] Trying ${provider.id} extraction...`); + const extracted = await provider.extract(messages); + if (extracted) return extracted; } - - return extracted || null; + return null; } -/** - * Extract topics from fabric output - * Matches both "## HEADING" markdown format and "HEADING:" legacy format - */ +/** Extract topics from generated markdown or the legacy heading format. */ export function extractTopics(fabricOutput: string): string[] { const topics: string[] = []; - - // Extract from DECISIONS MADE, MAIN IDEAS, and INSIGHTS sections const patterns = [ /(?:##\s*DECISIONS\s*MADE|DECISIONS:)\s*([\s\S]*?)(?=\n##\s|$)/, /(?:##\s*MAIN\s*IDEAS|MAIN_IDEAS:)\s*([\s\S]*?)(?=\n##\s|$)/, /(?:##\s*INSIGHTS|INSIGHTS:)\s*([\s\S]*?)(?=\n##\s|$)/, ]; - for (const pattern of patterns) { const match = fabricOutput.match(pattern); - if (match) { - const lines = match[1].split('\n').filter((l) => l.trim().startsWith('-')); - for (const line of lines.slice(0, 3)) { - // Max 3 per section - // Strip markdown bold markers and leading bullet - const topic = line.replace(/^-\s*/, '').replace(/\*\*/g, '').split(':')[0].trim(); - if (topic && topic.length < 50) { - topics.push(topic); - } - } + if (!match) continue; + const lines = match[1].split('\n').filter(line => line.trim().startsWith('-')); + for (const line of lines.slice(0, 3)) { + const topic = line.replace(/^-\s*/, '').replace(/\*\*/g, '').split(':')[0].trim(); + if (topic && topic.length < 50) topics.push(topic); } } - - return [...new Set(topics)].slice(0, 5); // Dedupe, max 5 + return [...new Set(topics)].slice(0, 5); } -/** Pull the "## ONE SENTENCE SUMMARY" line from extracted markdown, else a label fallback. */ +/** Pull the one-sentence summary line, or use a stable fallback label. */ export function deriveSummary(extracted: string, fallbackLabel: string): string { const summaryMatch = extracted.match(/##\s*ONE\s*SENTENCE\s*SUMMARY\s*\n+(.+)/); return summaryMatch ? summaryMatch[1].trim() : `${fallbackLabel} session`; diff --git a/hooks/lib/extraction-provider.ts b/hooks/lib/extraction-provider.ts new file mode 100644 index 0000000..ec39506 --- /dev/null +++ b/hooks/lib/extraction-provider.ts @@ -0,0 +1,4 @@ +export interface ExtractionProvider { + id: string; + extract(messages: string): Promise | string | null; +} diff --git a/hooks/lib/hosts/claude/extraction-provider.ts b/hooks/lib/hosts/claude/extraction-provider.ts new file mode 100644 index 0000000..e81568b --- /dev/null +++ b/hooks/lib/hosts/claude/extraction-provider.ts @@ -0,0 +1,181 @@ +import { existsSync, readFileSync, appendFileSync } from 'fs'; +import { join } from 'path'; +import { execFileSync } from 'child_process'; +import type { ExtractionProvider } from '../../extraction-provider'; + +const CLAUDE_CLI_MODEL = 'haiku'; +const MAX_DIRECT_CHARS = 120000; +const CHUNK_SIZE = 80000; + +function getMemoryDir(): string { + return join(process.env.HOME!, '.claude', 'MEMORY'); +} + +function logExtract(message: string): void { + try { + appendFileSync( + join(getMemoryDir(), 'EXTRACT_LOG.txt'), + `[${new Date().toISOString()}] ${message}\n`, + 'utf-8', + ); + } catch { + // Claude lifecycle logging is best-effort. + } +} + +export function findClaudeCli(): string | null { + const candidates = [ + '/usr/local/bin/claude', + '/usr/bin/claude', + join(process.env.HOME!, '.npm-global', 'bin', 'claude'), + join(process.env.HOME!, '.local', 'bin', 'claude'), + join(process.env.HOME!, '.bun', 'bin', 'claude'), + ]; + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate; + } + try { + return execFileSync('which', ['claude'], { encoding: 'utf-8' }).trim() || null; + } catch { + return null; + } +} + +function getExtractionPrompt(): string { + try { + const patternPath = join(getMemoryDir(), 'extract_prompt.md'); + if (existsSync(patternPath)) return readFileSync(patternPath, 'utf-8').trim(); + } catch { + // Fall through to the self-contained prompt. + } + return `You are an expert at extracting meaningful, factual information from AI coding session transcripts. +Extract ONLY what actually happened. Follow this format EXACTLY: + +## ONE SENTENCE SUMMARY +[Single factual sentence] + +## MAIN IDEAS +- [Concrete thing 1] +- [Concrete thing 2] + +## DECISIONS MADE +- [Decision]: [reason] + +## THINGS TO REJECT / AVOID +- [Thing to avoid]: [why] + +## ERRORS FIXED +- [Error]: [fix] + +## SESSION CONTEXT +[One sentence about impact on infrastructure]`; +} + +function runClaude(claudePath: string, input: string): string | null { + try { + const result = execFileSync( + claudePath, + ['-p', '--model', CLAUDE_CLI_MODEL, '--output-format', 'text', '--setting-sources', ''], + { + input, + encoding: 'utf-8', + timeout: 300000, + maxBuffer: 10 * 1024 * 1024, + env: { + ...process.env, + ...(process.env.RECALL_NESTED_EXTRACTION ? { CLAUDECODE: '' } : {}), + }, + }, + ); + const text = result?.trim(); + return text && text.length > 50 ? text : null; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.error(`[FabricExtract] Claude CLI extraction failed: ${message}`); + return null; + } +} + +async function extractDirect(messages: string, claudePath: string): Promise { + const truncated = messages.length > MAX_DIRECT_CHARS ? messages.slice(-MAX_DIRECT_CHARS) : messages; + const input = `${getExtractionPrompt()}\n\n---\n\nExtract the key information from this AI coding session transcript:\n\n${truncated}`; + const text = runClaude(claudePath, input); + if (text) { + console.error(`[FabricExtract] Claude CLI extraction successful (model=${CLAUDE_CLI_MODEL}, ${text.length} chars)`); + logExtract(`Claude CLI extraction successful: model=${CLAUDE_CLI_MODEL}, output_chars=${text.length}`); + } else { + console.error('[FabricExtract] Claude CLI returned empty/short response'); + } + return text; +} + +function splitChunks(messages: string): string[] { + const chunks: string[] = []; + let current = ''; + for (const line of messages.split('\n')) { + if (current.length + line.length > CHUNK_SIZE && current.length > 0) { + chunks.push(current); + current = ''; + } + current += `${line}\n`; + } + if (current.trim()) chunks.push(current); + return chunks; +} + +async function extractChunked(messages: string, claudePath: string): Promise { + const chunks = splitChunks(messages); + console.error(`[FabricExtract] CHUNKED: Splitting ${messages.length} chars into ${chunks.length} chunks`); + logExtract(`CHUNKED: ${messages.length} chars -> ${chunks.length} chunks`); + + const partials: string[] = []; + for (let index = 0; index < chunks.length; index++) { + console.error(`[FabricExtract] CHUNKED: Extracting chunk ${index + 1}/${chunks.length} (${chunks[index].length} chars)`); + const result = await extractDirect(chunks[index], claudePath); + if (result) partials.push(`--- Chunk ${index + 1}/${chunks.length} ---\n${result}`); + if (index < chunks.length - 1) await new Promise(resolve => setTimeout(resolve, 2000)); + } + if (partials.length === 0) return null; + if (partials.length === 1) return partials[0].replace(/^--- Chunk \d+\/\d+ ---\n/, ''); + + const systemPrompt = `You are merging multiple partial session extractions into one coherent summary. Combine all findings, deduplicate, and output in this exact format: + +## ONE SENTENCE SUMMARY +[Single comprehensive sentence covering the full session] + +## MAIN IDEAS +- [Key ideas from ALL chunks combined] + +## DECISIONS MADE +- [All decisions from all chunks] + +## THINGS TO REJECT / AVOID +- [All rejections from all chunks] + +## ERRORS FIXED +- [All errors from all chunks] + +## SESSION CONTEXT +[One comprehensive sentence about the full session's impact]`; + const input = `${systemPrompt}\n\n---\n\nMerge these ${partials.length} partial extractions into one comprehensive summary:\n\n${partials.join('\n\n')}`; + const merged = runClaude(claudePath, input); + if (merged) { + logExtract(`CHUNKED: Meta-extraction successful, output_chars=${merged.length}`); + return merged; + } + return partials.map(partial => partial.replace(/^--- Chunk \d+\/\d+ ---\n/, '')).join('\n\n'); +} + +export const claudeExtractionProvider: ExtractionProvider = { + id: 'claude-cli', + async extract(messages) { + const claudePath = findClaudeCli(); + if (!claudePath) { + console.error('[FabricExtract] claude CLI not found in PATH'); + return null; + } + return messages.length > MAX_DIRECT_CHARS + ? extractChunked(messages, claudePath) + : extractDirect(messages, claudePath); + }, +}; diff --git a/hooks/lib/hosts/claude/lifecycle.ts b/hooks/lib/hosts/claude/lifecycle.ts new file mode 100644 index 0000000..1fbf012 --- /dev/null +++ b/hooks/lib/hosts/claude/lifecycle.ts @@ -0,0 +1,16 @@ +import type { EventKind } from '../../events'; + +export interface ClaudeHookEventInput { + hook_event_name?: string; + prompt?: unknown; + tool_name?: unknown; +} + +/** Normalize Claude Code hook payloads before they reach host-neutral logic. */ +export function eventFromClaudeHookInput(input: ClaudeHookEventInput): EventKind | null { + if (input.hook_event_name === 'UserPromptSubmit') return 'turn'; + if (input.hook_event_name === 'PostToolUse') return 'tool'; + if (typeof input.prompt === 'string') return 'turn'; + if (typeof input.tool_name === 'string') return 'tool'; + return null; +} diff --git a/hooks/lib/hosts/claude/path-encoding.ts b/hooks/lib/hosts/claude/path-encoding.ts new file mode 100644 index 0000000..cd483b5 --- /dev/null +++ b/hooks/lib/hosts/claude/path-encoding.ts @@ -0,0 +1,6 @@ +// Encode a cwd to the folder name Claude Code uses under ~/.claude/projects/. +// Claude folder names only contain [a-zA-Z0-9-]; every other character is +// replaced with "-". +export function encodeProjectDir(cwd: string): string { + return '-' + cwd.replace(/^\//, '').replace(/[^a-zA-Z0-9-]/g, '-'); +} diff --git a/hooks/lib/hosts/index.ts b/hooks/lib/hosts/index.ts new file mode 100644 index 0000000..b43e006 --- /dev/null +++ b/hooks/lib/hosts/index.ts @@ -0,0 +1,7 @@ +import type { ExtractionProvider } from '../extraction-provider'; +import { claudeExtractionProvider } from './claude/extraction-provider'; + +/** Native providers enabled by this installation, kept outside the generic cascade. */ +export const nativeExtractionProviders: readonly ExtractionProvider[] = [ + claudeExtractionProvider, +]; diff --git a/hooks/lib/insession.ts b/hooks/lib/insession.ts index b74b3dd..34c11ae 100644 --- a/hooks/lib/insession.ts +++ b/hooks/lib/insession.ts @@ -33,6 +33,9 @@ import type { DualWriteResult } from './extraction-parsers'; import { detectCorrection } from './correction-detector'; import { scrub } from './write-safety'; import { writeLearningsBatch } from './sqlite-writers'; +import type { EventKind } from './events'; + +export type { EventKind } from './events'; // ─── Config (design §3.5 — env vars, no config file) ──────────────────────── @@ -219,31 +222,6 @@ export function shouldRun(p: CadenceCounters, c: InSessionConfig): boolean { return cadenceMet && floorMet && underBudget; } -// ─── Event mapping ─────────────────────────────────────────────────────────── - -export type EventKind = 'turn' | 'tool'; - -interface HookEventInput { - hook_event_name?: string; - prompt?: unknown; - tool_name?: unknown; -} - -/** - * Map a Claude Code hook payload to a counter kind: UserPromptSubmit → 'turn' - * (incrementTurns), PostToolUse → 'tool' (incrementTools). Falls back to the - * event-specific fields (prompt / tool_name) if hook_event_name is absent. - * Returns null for anything else (the hook then exits without touching the DB). - */ -export function eventFromHookInput(input: HookEventInput): EventKind | null { - const name = input.hook_event_name; - if (name === 'UserPromptSubmit') return 'turn'; - if (name === 'PostToolUse') return 'tool'; - if (typeof input.prompt === 'string') return 'turn'; - if (typeof input.tool_name === 'string') return 'tool'; - return null; -} - // ─── Window slice (design §3.2) ────────────────────────────────────────────── /** Extract plain text from a JSONL message's content blocks (string / array / object). */ diff --git a/hooks/lib/path-encoding.ts b/hooks/lib/path-encoding.ts deleted file mode 100644 index 1a78e21..0000000 --- a/hooks/lib/path-encoding.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Encode a cwd to the folder name Claude Code uses under ~/.claude/projects/. -// CC folder names only contain [a-zA-Z0-9-]; every other character — slash, -// underscore, dot, space, tilde, plus, Unicode — is replaced with "-". A -// narrower rule (e.g. only "/" and "_") fails silently for worktree paths, -// dotfile roots, and iCloud paths with spaces. -export function encodeProjectDir(cwd: string): string { - return '-' + cwd.replace(/^\//, '').replace(/[^a-zA-Z0-9-]/g, '-'); -} diff --git a/hooks/lib/pick-previous-jsonl.ts b/hooks/lib/pick-previous-jsonl.ts index 29a256c..bcde7ed 100644 --- a/hooks/lib/pick-previous-jsonl.ts +++ b/hooks/lib/pick-previous-jsonl.ts @@ -7,7 +7,7 @@ import { existsSync, readdirSync, statSync } from 'fs'; import { join } from 'path'; -import { encodeProjectDir } from './path-encoding'; +import { encodeProjectDir } from './hosts/claude/path-encoding'; /** * Default mtime-guard: a JSONL whose mtime is within this many seconds of diff --git a/lib/install-lib.sh b/lib/install-lib.sh index 89ecc95..1ca33b2 100644 --- a/lib/install-lib.sh +++ b/lib/install-lib.sh @@ -1701,15 +1701,17 @@ _recall_copy_hook_files() { if [[ -d "$src_dir/lib" ]]; then mkdir -p "$hooks_dir/lib" - local libfile - for libfile in "$src_dir/lib/"*.ts; do - [[ -f "$libfile" ]] || continue - local base - base="$(basename "$libfile")" - recall_copy_canonical "$libfile" "$RECALL_SHARED_HOOKS_LIB_DIR/$base" - recall_link "$hooks_dir/lib/$base" "$RECALL_SHARED_HOOKS_LIB_DIR/$base" - done - log_success "Installed hooks/lib/ ($(ls -1 "$RECALL_SHARED_HOOKS_LIB_DIR" 2>/dev/null | wc -l | tr -d ' ') files)" + local libfile relative canonical installed count=0 + while IFS= read -r libfile; do + relative="${libfile#"$src_dir/lib/"}" + canonical="$RECALL_SHARED_HOOKS_LIB_DIR/$relative" + installed="$hooks_dir/lib/$relative" + mkdir -p "$(dirname "$canonical")" "$(dirname "$installed")" + recall_copy_canonical "$libfile" "$canonical" + recall_link "$installed" "$canonical" + count=$((count + 1)) + done < <(find "$src_dir/lib" -type f -name '*.ts' -print | sort) + log_success "Installed hooks/lib/ ($count files)" fi local memory_dir="$CLAUDE_DIR/MEMORY" diff --git a/opencode/RecallExtract.ts b/opencode/RecallExtract.ts index 9d1bce7..e3a9274 100644 --- a/opencode/RecallExtract.ts +++ b/opencode/RecallExtract.ts @@ -14,7 +14,8 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs" import { join } from "path" import { homedir } from "os" -const DROP_DIR = join(homedir(), ".claude", "MEMORY", "opencode-sessions") +const RECALL_HOME = process.env.RECALL_HOME || join(homedir(), ".agents", "Recall") +const DROP_DIR = join(RECALL_HOME, "MEMORY", "opencode-sessions") const TRACKER_PATH = join(DROP_DIR, ".extracted.json") /** Load persistent dedup tracker from disk */ diff --git a/package.json b/package.json index fce07eb..e9dbc0a 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,8 @@ "dev": "tsup src/index.ts src/mcp-server.ts --format esm --watch --external bun:sqlite", "test": "bun test", "lint": "tsc --noEmit -p tsconfig.lint.json", + "build:codex-plugin": "bun run scripts/build-codex-plugin.ts", + "test:e2e:codex-plugin": "bun run build && bun run scripts/e2e-codex-plugin.ts", "check:version": "bun run scripts/check-version.ts", "prepublishOnly": "bun run build" }, diff --git a/pi/RecallExtract.ts b/pi/RecallExtract.ts index 12e6710..a704e0e 100644 --- a/pi/RecallExtract.ts +++ b/pi/RecallExtract.ts @@ -3,7 +3,7 @@ // // Pi sessions use tree-structured JSONL (id/parentId) unlike Claude Code's linear format. // This extension linearizes the active branch into flat markdown, then drops it into -// ~/.claude/MEMORY/pi-sessions/ for RecallBatchExtract to pick up. +// Recall's canonical MEMORY/pi-sessions/ directory for RecallBatchExtract to pick up. // // VERIFIED APIs: // - pi.on("session_shutdown", handler) — fires on exit (Ctrl+C, Ctrl+D, SIGTERM) @@ -38,7 +38,8 @@ function extractTextFromContent(content: any): string { return "" } -const DROP_DIR = join(homedir(), ".claude", "MEMORY", "pi-sessions") +const RECALL_HOME = process.env.RECALL_HOME || join(homedir(), ".agents", "Recall") +const DROP_DIR = join(RECALL_HOME, "MEMORY", "pi-sessions") const TRACKER_PATH = join(DROP_DIR, ".extraction_tracker.json") const MIN_MESSAGE_LENGTH = 10 const MAX_MESSAGE_LENGTH = 4000 diff --git a/plugins/AGENTS.md b/plugins/AGENTS.md new file mode 100644 index 0000000..a29f1fd --- /dev/null +++ b/plugins/AGENTS.md @@ -0,0 +1,35 @@ +# plugins — native host bundles + +> Child DOX. Root `AGENTS.md` carries repo-wide rules; this file owns local detail for `plugins/`. + +## Purpose + +Native host plugin bundles distributed directly from the Recall repository. + +## Ownership + +- `recall/` — Codex native plugin manifest, MCP registration, and generated `recall-*` skill adapters + +The repository-level Codex marketplace manifest is `.agents/plugins/marketplace.json`, owned with this bundle even though it lives outside this subtree. + +## Local Contracts + +- `.codex-plugin/plugin.json` is the canonical Codex plugin manifest and must validate with the current Codex plugin schema. +- `.mcp.json` registers the installed `recall-mcp` executable; do not embed a checkout-specific executable path. +- `skills/` is generated by `scripts/build-codex-plugin.ts` from `agent-skills/`; do not hand-edit generated adapters. +- Host-specific invocation metadata belongs in each generated skill's `agents/` directory. Behavioral parity is verified per host; matching `SKILL.md` bytes are not proof. +- Codex lifecycle auto-capture is not implemented. Do not add plugin hooks until transcript format, trust, installed paths, and config ownership have verified contracts. + +## Work Guidance + +- Change canonical skill content in `agent-skills/`, then run `bun run build:codex-plugin`. +- Change Codex adaptation rules in `scripts/build-codex-plugin.ts`. + +## Verification + +- `bun test tests/plugins/codex-plugin.test.ts` +- `bun run test:e2e:codex-plugin` + +## Child DOX Index + +No child docs. diff --git a/plugins/recall/.codex-plugin/plugin.json b/plugins/recall/.codex-plugin/plugin.json new file mode 100644 index 0000000..df3152c --- /dev/null +++ b/plugins/recall/.codex-plugin/plugin.json @@ -0,0 +1,29 @@ +{ + "name": "recall", + "version": "0.9.4", + "description": "Persistent SQLite memory and retrieval tools for Codex", + "author": { + "name": "edheltzel", + "url": "https://github.com/edheltzel" + }, + "homepage": "https://github.com/edheltzel/Recall#readme", + "repository": "https://github.com/edheltzel/Recall", + "license": "MIT", + "keywords": ["memory", "sqlite", "mcp", "coding-agents"], + "skills": "./skills/", + "interface": { + "displayName": "Recall", + "shortDescription": "Persistent memory for Codex sessions", + "longDescription": "Search, add, recall, and explicitly dump durable coding-agent memory through Recall's nine MCP tools and workflow skills.", + "developerName": "edheltzel", + "category": "Productivity", + "capabilities": ["Read", "Write"], + "websiteURL": "https://github.com/edheltzel/Recall", + "defaultPrompt": [ + "Search Recall before asking me to repeat project context.", + "Save this decision to Recall with its reasoning." + ], + "brandColor": "#6B5CE7" + }, + "mcpServers": "./.mcp.json" +} diff --git a/plugins/recall/.mcp.json b/plugins/recall/.mcp.json new file mode 100644 index 0000000..3398744 --- /dev/null +++ b/plugins/recall/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "recall-memory": { + "command": "recall-mcp", + "args": [] + } + } +} diff --git a/plugins/recall/skills/recall-add/SKILL.md b/plugins/recall/skills/recall-add/SKILL.md new file mode 100644 index 0000000..4ed83be --- /dev/null +++ b/plugins/recall/skills/recall-add/SKILL.md @@ -0,0 +1,58 @@ +--- +name: "recall-add" +description: "Add a structured memory record to Recall — breadcrumb, decision, or learning" +--- + + + +## Codex routing + +Prefer the `memory_add` MCP tool. Use the CLI examples below only when the MCP server is unavailable. + +The canonical workflow below remains authoritative, but equivalent behavior is not assumed across hosts. + +Manually add a structured record to the Recall memory database. Three record types are available: breadcrumbs (context notes), decisions (architectural choices), and learnings (problem/solution pairs). Use the user's request text as the record content. + +## Usage + +```bash +# Breadcrumb — quick context note +recall add breadcrumb "" + +# Decision — architectural or process decision +recall add decision "" --why "reasoning" + +# Learning — problem and solution pair +recall add learning "" "" +``` + +**Breadcrumb options:** +- `-p ` — Project name +- `-c ` — Category: context, note, todo, reference +- `-i ` — Importance 1-10 (default: 5) + +**Decision options:** +- `-p ` — Project name +- `-w, --why ` — Why this decision was made +- `-a, --alternatives ` — Alternatives considered + +**Learning options:** +- `-p ` — Project name +- `-t, --tags ` — Comma-separated tags +- `--prevention ` — How to prevent in future + +## Examples + +```bash +# Breadcrumb with high importance +recall add breadcrumb "User prefers dark mode in all UIs" -p myproject -i 8 + +# Decision with reasoning +recall add decision "Use TypeScript over Python" --why "Type safety, team preference" -p myproject + +# Learning with prevention +recall add learning "Port conflict on 4000" "Kill process or change port" --prevention "Use dynamic port allocation" + +# Tagged learning +recall add learning "bun:sqlite uses \$param syntax" "Not :param like better-sqlite3" -t "bun,sqlite,gotcha" +``` diff --git a/plugins/recall/skills/recall-doctor/SKILL.md b/plugins/recall/skills/recall-doctor/SKILL.md new file mode 100644 index 0000000..242d72a --- /dev/null +++ b/plugins/recall/skills/recall-doctor/SKILL.md @@ -0,0 +1,24 @@ +--- +name: "recall-doctor" +description: "Run health checks on all Recall memory subsystems — database, MCP, hooks, embeddings" +--- + + + +## Codex routing + +This remains a local CLI diagnostic. Run `recall doctor`; the Codex plugin does not rewrite Codex-owned configuration. + +The canonical workflow below remains authoritative, but equivalent behavior is not assumed across hosts. + +Run comprehensive health checks on all Recall subsystems. Checks database connectivity, MCP server registration, hook configuration, extraction tracker state, CLI paths, and Ollama embedding service. + +Start here when troubleshooting any Recall issue. + +## Usage + +```bash +recall doctor +``` + +No arguments or options. Runs all checks and reports status for each subsystem. diff --git a/plugins/recall/skills/recall-dump/SKILL.md b/plugins/recall/skills/recall-dump/SKILL.md new file mode 100644 index 0000000..432cccd --- /dev/null +++ b/plugins/recall/skills/recall-dump/SKILL.md @@ -0,0 +1,47 @@ +--- +name: "recall-dump" +description: "Flush current session to Recall database and capture a Library of Alexandria entry" +--- + + + +## Codex routing + +Use the `memory_dump` MCP tool with `source: "codex"` and explicit visible `messages`. MCP does not expose lifecycle auto-capture or a supported Codex transcript path. + +The canonical workflow below remains authoritative, but equivalent behavior is not assumed across hosts. + +Dump the current conversation session into the Recall SQLite database, making all messages immediately searchable. Also creates a curated LoA (Library of Alexandria) entry with extracted wisdom. + +Run this at the end of every session or when you want to persist the current conversation mid-session. + +## Usage + +```bash +recall dump "" +``` + +**Arguments:** +- `<title>` (required): Descriptive title for this session (e.g., "Auth refactor planning", "Fixed rate limiter bug"). Use the user's argument text; if none was given, derive a short title from the session. + +**Options:** +- `-p <project>` — Tag with a project name +- `-t <tags>` — Comma-separated tags +- `-c <id>` — Continue from a previous LoA entry (creates a chain) +- `--skip-fabric` — Skip Fabric extraction (faster, import only) + +## Examples + +```bash +# Basic dump +recall dump "Debugging the webhook handler" + +# With project tag +recall dump "API redesign session" -p my-api + +# Continue a previous conversation thread +recall dump "Auth refactor part 2" -c 15 + +# Quick import without Fabric extraction +recall dump "Quick fix session" --skip-fabric +``` diff --git a/plugins/recall/skills/recall-dump/agents/openai.yaml b/plugins/recall/skills/recall-dump/agents/openai.yaml new file mode 100644 index 0000000..418b822 --- /dev/null +++ b/plugins/recall/skills/recall-dump/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Recall Dump" + short_description: "Explicitly save visible conversation messages" +policy: + allow_implicit_invocation: false diff --git a/plugins/recall/skills/recall-loa/SKILL.md b/plugins/recall/skills/recall-loa/SKILL.md new file mode 100644 index 0000000..85dd130 --- /dev/null +++ b/plugins/recall/skills/recall-loa/SKILL.md @@ -0,0 +1,47 @@ +--- +name: "recall-loa" +description: "Browse and search the Library of Alexandria — curated knowledge entries with extracted wisdom" +--- + +<!-- Generated by scripts/build-codex-plugin.ts; do not edit. --> + +## Codex routing + +Prefer `loa_show` for a known entry and `memory_recall` to browse recent entries. Use CLI-only quote/list variants when needed. + +The canonical workflow below remains authoritative, but equivalent behavior is not assumed across hosts. + +List, view, and search Library of Alexandria (LoA) entries. LoA entries are curated knowledge captures with Fabric-extracted insights, message lineage, and project context. + +## Usage + +```bash +# List recent entries +recall loa list + +# Show full entry with Fabric extract +recall loa show <id> + +# View raw source messages +recall loa quote <id> +``` + +**List options:** + +- `-l <n>` — Max entries (default: 10) + +## Examples + +```bash +# Browse recent LoA entries +recall loa list + +# Show last 20 entries +recall loa list -l 20 + +# View full Fabric-extracted wisdom for entry #5 +recall loa show 5 + +# See the raw conversation messages behind entry #12 +recall loa quote 12 +``` diff --git a/plugins/recall/skills/recall-recent/SKILL.md b/plugins/recall/skills/recall-recent/SKILL.md new file mode 100644 index 0000000..169394e --- /dev/null +++ b/plugins/recall/skills/recall-recent/SKILL.md @@ -0,0 +1,43 @@ +--- +name: "recall-recent" +description: "Show recent Recall memory records across all tables or a specific table" +--- + +<!-- Generated by scripts/build-codex-plugin.ts; do not edit. --> + +## Codex routing + +Prefer `memory_recall` for recent cross-table context. Use the CLI when an exact table-specific recent listing is required. + +The canonical workflow below remains authoritative, but equivalent behavior is not assumed across hosts. + +Display the most recent records from Recall memory. Shows entries across all tables by default, or filter to a specific table. + +## Usage + +```bash +recall recent [table] +``` + +**Arguments:** +- `[table]` (optional): Table to filter — messages, decisions, learnings, breadcrumbs, or all (default: all) + +**Options:** +- `-p <project>` — Filter by project name +- `-l <n>` — Max results (default: 10) + +## Examples + +```bash +# Recent records across all tables +recall recent + +# Recent decisions only +recall recent decisions + +# Recent learnings for a specific project +recall recent learnings -p my-api + +# Last 20 breadcrumbs +recall recent breadcrumbs -l 20 +``` diff --git a/plugins/recall/skills/recall-scout/SKILL.md b/plugins/recall/skills/recall-scout/SKILL.md new file mode 100644 index 0000000..000cb6b --- /dev/null +++ b/plugins/recall/skills/recall-scout/SKILL.md @@ -0,0 +1,80 @@ +--- +name: "recall-scout" +description: "Scout an unfamiliar codebase — memory-first repo map, key paths, tests, risks, and next steps, with a strict sensitive-data boundary" +allowed-tools: Bash(git log -10 --oneline), Bash(git status -s | head), Bash(codegraph status --json) +--- + +<!-- Generated by scripts/build-codex-plugin.ts; do not edit. --> + +## Codex routing + +Use the plugin MCP tools `memory_search`, `memory_hybrid_search`, and `context_for_agent` for the memory-first steps; keep the workflow sensitive-data boundary intact. + +The canonical workflow below remains authoritative, but equivalent behavior is not assumed across hosts. + +Produce a **scout report** for the current repository: a fast, structured orientation for whoever is about to work in this codebase. An optional focus argument narrows the scout to a subsystem, path, or question (e.g. `auth`, `the extraction pipeline`, `src/db`). With no focus, scout the repo as a whole. + +This is the canonical scouting workflow; platform guides reference it and only remap tool names. Do not hand-copy these steps into `FOR_CLAUDE.md`, `FOR_PI.md`, `FOR_OPENCODE.md`, or `opencode/recall-memory.md` — those files point here and supply only their own tool-name mapping. + +## Live context + +Three bounded orienters, each well under 1 KB of output. On Claude Code the `!` prefix inlines their output here at prompt load; on hosts without the `!` mechanic, run the same three commands manually as your first step. + +- Recent commits: !`git log -10 --oneline` +- Working tree: !`git status -s | head` +- CodeGraph probe: !`codegraph status --json` + +## Workflow + +1. **Memory first.** Before reading git history or source files, search Recall for what is already known about this repo and the focus. Use keyword search for names and hybrid search for natural-language questions, scoped to **this project only**. Recall's structured decisions, learnings, and session summaries are richer than commit messages — start here, not with `git log`. +2. **CodeGraph capability ladder.** Read the probe (`codegraph status --json` — it exits 0 even on an unindexed repo) and branch on `initialized`: + - **Indexed** (`initialized: true`): check freshness — if `pendingChanges` is non-zero or `reindexRecommended` is true, run `codegraph sync -q` (cheap, safe) before querying. + - **CLI installed, repo unindexed** (`initialized: false`): **offer** indexing, don't run it — "this repo isn't indexed; `codegraph init` takes ~2 s per 200 files, writes only a self-gitignored `.codegraph/`, and `codegraph uninit` reverses it — want it?" Run `codegraph init` only on an explicit user yes; indexing is the user's decision, never auto-run it. Without a yes, continue on the backstop. + - **No CLI** (the probe errors with command-not-found): skip CodeGraph entirely and produce the full report via the grep/tree-walk backstop. + - CodeGraph is an enhancement, never a hard dependency: every report section below must be producible with zero CodeGraph. (Hosts with the CodeGraph MCP server may use its single tool, `codegraph_explore`, wherever `codegraph explore` appears — identical output; everything else on this page is CLI-only.) +3. **Repo map.** Build a high-level map: languages, entry points, top-level layout, build/test commands. With a live index, start from the **orientation bundle** — `codegraph status --json` (languages, symbol-kind profile, file/node counts; already inlined above) plus `codegraph files --max-depth 2 --no-metadata` (the repo tree; add `--filter <dir>` when the focus names an area). The bundle costs ~600 tokens and replaces a blind tree walk. Then read the declared ground truth directly — these files are small and never in the index: README, `AGENTS.md`/`CLAUDE.md`, the dependency manifest (`package.json` including its `scripts`, or `pyproject.toml` / `Cargo.toml` / `go.mod`), the compiler/tooling config (`tsconfig.json` or equivalent), and CI workflow names. Without CodeGraph, the same declared reads plus a bounded tree walk produce the map. +4. **Tech stack.** From manifests only: languages, runtime, frameworks and major libraries with their **declared** versions, build tooling, test framework, and package manager — read from the manifest's declared ranges (`package.json`, `pyproject.toml`, `Cargo.toml`, `go.mod`). Lockfiles stay behind the sensitive-data boundary: map their existence, never their contents, so version ground truth is the declared range, not the resolved lockfile pin. CodeGraph contributes the languages list and symbol-kind profile from `status --json`; it does not know dependencies or versions. +5. **Key paths.** Identify the handful of files that matter most for the focus — entry points, core modules, config, and the seams where changes usually land. Cite them as `path:line` where useful. With a live index, surface them with at most two narrow `codegraph explore` calls (see the query discipline below) rather than guessing from the tree: one explore answers "what calls this seam", "what does it depend on", and "trace a path between two symbols" in a single query. Fall back to grep when CodeGraph isn't available. +6. **Decisions & learnings tied to the key files.** For each key path, search Recall (keyword + hybrid, scoped to **this** project) for the decisions and learnings that reference the file or the symbols it defines — *why* a hot file is the way it is. This focuses the memory-first search from step 1 on each key path. +7. **Conventions.** How the house writes code, from three legs: **declared** — the rules files already read in step 3 (`AGENTS.md`/`CLAUDE.md`/`CONTRIBUTING.md`) plus lint/format configs (`eslint`/`prettier`/`.editorconfig`, `tsconfig` strictness); **observed** — one CodeGraph style probe over a representative module (`codegraph explore` of that module, or cheaper, `codegraph node --file <path> --symbols-only`) for naming patterns, module seams, and test-file conventions — an explore used here counts against the two-explore cap; **learned** — Recall learnings, which often encode "wrong → right" convention corrections. +8. **Tests.** Locate the test suite, the runner, and how to invoke it. Note coverage gaps relevant to the focus. +9. **Risks.** Surface what would bite someone working here — fragile areas, undocumented invariants, conventions that are easy to violate (pull these from memory on the hot files and from project rules files). +10. **Next steps.** Recommend the concrete first moves for the focus, ordered, each tied to a path or command. + +## CodeGraph query discipline + +CodeGraph replaces the expensive grep-and-read loop, but `explore` pastes verbatim source and has no output cap — discipline keeps the scout token-frugal: + +- **Narrow, named queries only.** Point `explore` at a symbol, a file, or a seam — never a vague topic. Off-target matches cost the same tokens as on-target ones. +- **Soft cap: at most two `explore` calls per scout run**, including the Conventions style probe. +- **Never re-paste explore output into the report.** The report wants a one-line "why it matters" per key path and `path:line` cites — not quoted source blocks. +- **Prefer cappable forms for edge questions:** `codegraph callers <symbol> --limit 5`, `codegraph callees <symbol> --limit 5`, `codegraph query <term> --limit 3 --kind <kind>`, `codegraph node --file <path> --symbols-only`. +- **Backstop means fallback, not second pass:** if CodeGraph answered a question, skip grep and the tree walk for that same question. +- **Symbol misses exit 0.** `query`/`node`/`callers` print "not found" and still exit 0 — check the output text for misses, don't exit-check. + +## Report format + +Output the scout report **in chat** with these sections, in order: + +- **Memory summary** — what Recall already knew (decisions, learnings, breadcrumbs, prior sessions). Say so explicitly if memory was empty. +- **Repo map** — languages, layout, build/test commands. +- **Tech stack** — languages/runtime, frameworks and major libraries with declared versions, build/test tooling, package manager (from manifests; lockfiles excluded). +- **Key paths** — the files that matter, with one line each on why (grounded in the code graph where available). +- **Decisions & learnings** — per key file, the memory tied to its code (from Recall search on the file and its symbols). Omit the section if nothing was found. +- **Conventions** — the house style: declared rules, observed patterns, and Recall-learned corrections. +- **Tests** — suite location, runner, how to run, notable gaps. +- **Risks** — what would bite someone working here. +- **Next steps** — ordered, concrete, path- or command-anchored. + +## Sensitive-data boundary (MANDATORY) + +Never read, echo, summarize, or write any of the following — if the focus would require crossing this boundary, stop and say so instead: + +- **Secrets / credentials** — `.env*`, `*.key`, `*.pem`, tokens, API keys, anything matching a credential pattern. Do not print their values even if asked to "summarize config." +- **Generated / vendored files** — `node_modules/`, `dist/`, `build/`, `.git/` internals, lockfile contents. Map their existence, never their contents. +- **The live database** — never open, query, or dump `~/.agents/Recall/recall.db` (or any production DB) directly. Use the Recall CLI and search tools, which read the database for you, scoped to this project — never raw SQL against the file. +- **Cross-project memory** — scope every Recall search to **this** repository's project. Do not surface, quote, or leak another project's memory into this report. + +## Artifacts (opt-in) + +The scout report is **chat-only by default — write nothing to disk.** Persist an artifact only when the repo endorses it: a `.agents/atlas/artifacts/` directory already exists, or the user explicitly asks for a saved report. When endorsed, write **only** to `.agents/atlas/artifacts/` as `YYYY-MM-DD-scout-<focus>.md`. Never write scout artifacts to `.agents/atlas/handoffs/` (reserved for handoffs) or anywhere else. diff --git a/plugins/recall/skills/recall-search/SKILL.md b/plugins/recall/skills/recall-search/SKILL.md new file mode 100644 index 0000000..2775688 --- /dev/null +++ b/plugins/recall/skills/recall-search/SKILL.md @@ -0,0 +1,54 @@ +--- +name: "recall-search" +description: "Search Recall memory using FTS5 full-text search across all tables" +--- + +<!-- Generated by scripts/build-codex-plugin.ts; do not edit. --> + +## Codex routing + +Prefer `memory_search`; use `memory_hybrid_search` for natural-language semantic retrieval. The CLI remains an explicit fallback. + +The canonical workflow below remains authoritative, but equivalent behavior is not assumed across hosts. + +Search across all Recall memory tables (messages, LoA entries, decisions, learnings, breadcrumbs) using SQLite FTS5 full-text search. Supports AND, OR, NOT operators, prefix matching, exact phrases, hard table filtering, and soft table biasing. + +## Usage + +```bash +recall search "<query>" +``` + +**Arguments:** +- `<query>` (required): Search query — use the user's argument text + +**Options:** +- `-p <project>` — Filter by project name +- `-t <table>` — Hard-filter to one table: messages, loa, decisions, learnings, breadcrumbs +- `--bias-type <table>` — Softly boost one table without filtering other matches. Same values as `-t`. +- `-l <n>` — Max results (default: 20) +- `--show-provenance` — Show Record Provenance for every result (by default only unknown provenance is flagged) + +## Examples + +```bash +# Search all memory +recall search "kubernetes deployment" + +# Search decisions only (hard filter) +recall search "database choice" -t decisions + +# Prefer decisions first, but still show matching learnings/messages/etc. +recall search "database choice" --bias-type decisions + +# Filter by project +recall search "auth middleware" -p my-api + +# Exact phrase with limit +recall search '"rate limiting strategy"' -l 5 + +# FTS5 operators +recall search "webpack OR vite" -t learnings + +# Rule of thumb: use -t when you want ONLY that table; use --bias-type when you want that table first. +``` diff --git a/plugins/recall/skills/recall-stats/SKILL.md b/plugins/recall/skills/recall-stats/SKILL.md new file mode 100644 index 0000000..06cc08b --- /dev/null +++ b/plugins/recall/skills/recall-stats/SKILL.md @@ -0,0 +1,22 @@ +--- +name: "recall-stats" +description: "Show Recall database statistics — record counts, database size, and table breakdown" +--- + +<!-- Generated by scripts/build-codex-plugin.ts; do not edit. --> + +## Codex routing + +Prefer the `memory_stats` MCP tool. The CLI is an explicit fallback. + +The canonical workflow below remains authoritative, but equivalent behavior is not assumed across hosts. + +Display database statistics for the Recall memory system, including record counts per table, database file size, and embedding status. + +## Usage + +```bash +recall stats +``` + +No arguments or options. Shows a complete overview of the database state. diff --git a/plugins/recall/skills/recall-update/SKILL.md b/plugins/recall/skills/recall-update/SKILL.md new file mode 100644 index 0000000..8905367 --- /dev/null +++ b/plugins/recall/skills/recall-update/SKILL.md @@ -0,0 +1,98 @@ +--- +name: "recall-update" +description: "Check for the latest Recall release on GitHub and print the exact update command (check-only — does not run update.sh)." +--- + +<!-- Generated by scripts/build-codex-plugin.ts; do not edit. --> + +## Codex routing + +This remains check-only and CLI-backed. Never apply an update from an attached Codex session. + +The canonical workflow below remains authoritative, but equivalent behavior is not assumed across hosts. + +Check whether Recall has a newer release on GitHub. This skill is +read-only — it prints the recommended next step but NEVER runs +`update.sh` inline. Running the update while a coding agent session is +attached can corrupt in-flight hook invocations (the `recall` binary +lives in the same process tree via `bun link`). + +## What this does + +1. Reads the currently installed version via `recall --version`. +2. Fetches the latest release tag from the GitHub Releases API + (`/repos/edheltzel/Recall/releases/latest` — unauthenticated, 60 + req/hr per IP). +3. If current == latest: reports "up to date" and exits. +4. If current is behind: prints current, latest, a short excerpt of the + release notes, and the exact recipe to run the update manually: + + ``` + cd <path-to-Recall> && ./update.sh + ``` + +## Why not auto-run? + +`update.sh` pulls, rebuilds, migrates the DB, and re-registers hooks. +Rebuilding the `recall` binary mid-session can leave the running hook +scripts in a half-updated state. The safe sequence is: exit the coding +agent → `./update.sh` → restart it. + +## Rate-limit fallback + +GitHub's unauthenticated rate limit is 60 requests/hour per IP. If you +hit it, the command prints a graceful fallback pointing at the releases +page: <https://github.com/edheltzel/Recall/releases>. + +## Steps for you + +Run these steps to perform the check and produce the recipe. Prefer +running the Recall-shipped helper (`./update.sh --check`) if the source +directory is locatable — it implements all of the logic below. + +1. **Locate the source directory.** Resolve the symlink target of the + `recall` binary and strip `/dist/index.js` to get the Recall checkout: + + ```bash + readlink -f "$(which recall)" | sed 's|/dist/index.js$||' + ``` + + Call this `RECALL_SRC`. + +2. **Run the check helper.** If `$RECALL_SRC/update.sh` exists, delegate + to it — it prints the exact recipe on its own: + + ```bash + cd "$RECALL_SRC" && ./update.sh --check + ``` + + Its output already includes the "cd ... && ./update.sh" line. Relay + it to the user and stop. + +3. **Manual path** (if `update.sh` is not present on older installs): + - Capture the current version: + ```bash + recall --version + ``` + - Fetch the latest release tag + body: + ```bash + curl -sf https://api.github.com/repos/edheltzel/Recall/releases/latest + ``` + Parse JSON with `jq -r .tag_name` and `jq -r .body` (or with `bun -e` + if `jq` is not available). + - Compare versions (strip leading `v`). Present the result as: + + ``` + Current: vX.Y.Z + Latest: vA.B.C + + Release notes (excerpt): + <first ~10 lines of body> + + To apply: + cd <RECALL_SRC> && ./update.sh + ``` + +4. **Do not** run `./update.sh` on the user's behalf. Stop at printing + the recipe. The user runs it themselves after exiting the coding + agent. diff --git a/scripts/build-codex-plugin.ts b/scripts/build-codex-plugin.ts new file mode 100644 index 0000000..446443d --- /dev/null +++ b/scripts/build-codex-plugin.ts @@ -0,0 +1,66 @@ +#!/usr/bin/env bun + +// Generate Codex-native skill adapters from the canonical agent-skills source. +// Generated files are checked in so Codex's installed plugin cache is complete; +// this script and the canonical skills remain the only authoring surfaces. + +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +export const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); +const sourceRoot = join(repoRoot, 'agent-skills'); +const defaultOutputRoot = join(repoRoot, 'plugins', 'recall', 'skills'); + +export const routes: Record<string, string> = { + 'recall-add': 'Prefer the `memory_add` MCP tool. Use the CLI examples below only when the MCP server is unavailable.', + 'recall-doctor': 'This remains a local CLI diagnostic. Run `recall doctor`; the Codex plugin does not rewrite Codex-owned configuration.', + 'recall-dump': 'Use the `memory_dump` MCP tool with `source: "codex"` and explicit visible `messages`. MCP does not expose lifecycle auto-capture or a supported Codex transcript path.', + 'recall-loa': 'Prefer `loa_show` for a known entry and `memory_recall` to browse recent entries. Use CLI-only quote/list variants when needed.', + 'recall-recent': 'Prefer `memory_recall` for recent cross-table context. Use the CLI when an exact table-specific recent listing is required.', + 'recall-scout': 'Use the plugin MCP tools `memory_search`, `memory_hybrid_search`, and `context_for_agent` for the memory-first steps; keep the workflow sensitive-data boundary intact.', + 'recall-search': 'Prefer `memory_search`; use `memory_hybrid_search` for natural-language semantic retrieval. The CLI remains an explicit fallback.', + 'recall-stats': 'Prefer the `memory_stats` MCP tool. The CLI is an explicit fallback.', + 'recall-update': 'This remains check-only and CLI-backed. Never apply an update from an attached Codex session.', +}; + +export function addCodexRouting(source: string, route: string): string { + source = source.replace(/^disable-model-invocation:\s*true\s*\n/m, ''); + const frontmatterEnd = source.indexOf('\n---', 4); + if (!source.startsWith('---\n') || frontmatterEnd < 0) { + throw new Error('canonical skill is missing YAML frontmatter'); + } + const insertAt = frontmatterEnd + 4; + const adapter = `\n\n<!-- Generated by scripts/build-codex-plugin.ts; do not edit. -->\n\n## Codex routing\n\n${route}\n\nThe canonical workflow below remains authoritative, but equivalent behavior is not assumed across hosts.\n`; + return source.slice(0, insertAt) + adapter + source.slice(insertAt).replace(/^\n+/, '\n'); +} + +export function generateCodexPluginSkills(outputRoot: string = defaultOutputRoot): string[] { + mkdirSync(outputRoot, { recursive: true }); + const skillNames = readdirSync(sourceRoot).filter(name => existsSync(join(sourceRoot, name, 'SKILL.md'))).sort(); + for (const name of skillNames) { + const route = routes[name]; + if (!route) throw new Error(`missing Codex route for ${name}`); + const source = readFileSync(join(sourceRoot, name, 'SKILL.md'), 'utf-8'); + const outputDir = join(outputRoot, name); + mkdirSync(outputDir, { recursive: true }); + writeFileSync(join(outputDir, 'SKILL.md'), addCodexRouting(source, route)); + if (name === 'recall-dump') { + const metadataDir = join(outputDir, 'agents'); + mkdirSync(metadataDir, { recursive: true }); + writeFileSync( + join(metadataDir, 'openai.yaml'), + 'interface:\n display_name: "Recall Dump"\n short_description: "Explicitly save visible conversation messages"\npolicy:\n allow_implicit_invocation: false\n', + ); + } + } + const generated = readdirSync(outputRoot).filter(name => existsSync(join(outputRoot, name, 'SKILL.md'))).sort(); + if (generated.join('\n') !== skillNames.join('\n')) { + throw new Error('generated Codex skill set contains stale or missing entries'); + } + return skillNames; +} + +if (import.meta.main) { + console.log(`Generated ${generateCodexPluginSkills().length} Codex skill adapters.`); +} diff --git a/scripts/e2e-codex-plugin.ts b/scripts/e2e-codex-plugin.ts new file mode 100644 index 0000000..faf65fc --- /dev/null +++ b/scripts/e2e-codex-plugin.ts @@ -0,0 +1,204 @@ +#!/usr/bin/env bun + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'fs'; +import { homedir, tmpdir } from 'os'; +import { dirname, join } from 'path'; +import { spawnSync } from 'child_process'; +import { fileURLToPath } from 'url'; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); +const productionDb = join(homedir(), '.agents', 'Recall', 'recall.db'); +const tempRoot = mkdtempSync(join(tmpdir(), 'recall-codex-plugin-e2e-')); +const testDb = join(tempRoot, 'recall-test.db'); +const testRecallHome = join(tempRoot, 'recall-home'); +const testCodexHome = join(tempRoot, 'codex-home'); +const testHome = join(tempRoot, 'home'); +const testBin = join(tempRoot, 'bin'); + +interface FileMetadata { + exists: boolean; + size?: number; + mtimeMs?: number; + ino?: number; +} + +function metadata(path: string): FileMetadata { + if (!existsSync(path)) return { exists: false }; + const stat = statSync(path); + return { exists: true, size: stat.size, mtimeMs: stat.mtimeMs, ino: stat.ino }; +} + +function stringEnv(env: NodeJS.ProcessEnv): Record<string, string> { + return Object.fromEntries(Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined)); +} + +function runCodex(args: string[], env: Record<string, string>): string { + const result = spawnSync('codex', args, { cwd: repoRoot, env, encoding: 'utf-8' }); + if (result.status !== 0) { + throw new Error(`codex ${args.join(' ')} failed (${result.status})\n${result.stdout}\n${result.stderr}`); + } + return result.stdout; +} + +function resultText(result: Awaited<ReturnType<Client['callTool']>>): string { + return result.content + .filter((item): item is { type: 'text'; text: string } => item.type === 'text') + .map(item => item.text) + .join('\n'); +} + +async function main(): Promise<void> { + const productionBefore = metadata(productionDb); + mkdirSync(testRecallHome, { recursive: true }); + mkdirSync(testCodexHome, { recursive: true }); + mkdirSync(testHome, { recursive: true }); + mkdirSync(testBin, { recursive: true }); + + if (testDb === productionDb || testDb.startsWith(dirname(productionDb) + '/')) { + throw new Error(`unsafe test database path: ${testDb}`); + } + + const env = stringEnv({ + ...process.env, + HOME: testHome, + CODEX_HOME: testCodexHome, + RECALL_HOME: testRecallHome, + RECALL_DB_PATH: testDb, + RECALL_SKIP_LEGACY_DATA_MIGRATIONS: '1', + PATH: `${testBin}:${process.env.PATH || ''}`, + }); + + console.log(`isolation.test_db=${testDb}`); + console.log(`isolation.production_db=${productionDb}`); + console.log('isolation.production_db_opened=false'); + + const init = spawnSync('bun', ['run', 'src/index.ts', 'init'], { cwd: repoRoot, env, encoding: 'utf-8' }); + if (init.status !== 0) throw new Error(`test DB init failed\n${init.stdout}\n${init.stderr}`); + if (!existsSync(testDb)) throw new Error('test DB was not created'); + + writeFileSync( + join(testBin, 'recall-mcp'), + `#!/bin/sh\nexec bun ${JSON.stringify(join(repoRoot, 'dist', 'mcp-server.js'))} "$@"\n`, + { mode: 0o755 }, + ); + + console.log(`codex.version=${runCodex(['--version'], env).trim()}`); + runCodex(['plugin', 'marketplace', 'add', repoRoot], env); + const catalog = JSON.parse(runCodex(['plugin', 'list', '--available', '--json'], env)) as { + available?: Array<{ name?: string; marketplaceName?: string }>; + }; + if (!catalog.available?.some(plugin => plugin.name === 'recall' && plugin.marketplaceName === 'recall-marketplace')) { + throw new Error(`Recall plugin not listed by Codex: ${JSON.stringify(catalog)}`); + } + runCodex(['plugin', 'add', 'recall@recall-marketplace', '--json'], env); + const installed = JSON.parse(runCodex(['plugin', 'list', '--json'], env)) as { + installed?: Array<{ pluginId?: string; enabled?: boolean }>; + }; + if (!installed.installed?.some(plugin => plugin.pluginId === 'recall@recall-marketplace' && plugin.enabled)) { + throw new Error(`Recall plugin not installed and enabled by Codex: ${JSON.stringify(installed)}`); + } + const configuredMcp = JSON.parse(runCodex(['mcp', 'list', '--json'], env)) as Array<{ + name?: string; + enabled?: boolean; + transport?: { type?: string; command?: string }; + }>; + if (!configuredMcp.some(server => + server.name === 'recall-memory' + && server.enabled + && server.transport?.type === 'stdio' + && server.transport.command === 'recall-mcp' + )) { + throw new Error(`Recall MCP registration not loaded from the installed plugin: ${JSON.stringify(configuredMcp)}`); + } + console.log('codex.plugin_installed=true'); + console.log('codex.plugin_mcp_loaded=true'); + + const client = new Client({ name: 'recall-codex-e2e', version: '1.0.0' }); + const transport = new StdioClientTransport({ command: 'recall-mcp', env }); + await client.connect(transport); + try { + const expectedTools = [ + 'context_for_agent', + 'decision_update', + 'loa_show', + 'memory_add', + 'memory_dump', + 'memory_hybrid_search', + 'memory_recall', + 'memory_search', + 'memory_stats', + ]; + const listed = (await client.listTools()).tools.map(tool => tool.name).sort(); + if (JSON.stringify(listed) !== JSON.stringify(expectedTools)) { + throw new Error(`unexpected MCP tools: ${JSON.stringify(listed)}`); + } + + const add = await client.callTool({ + name: 'memory_add', + arguments: { + type: 'decision', + content: 'Package Recall on the native Codex plugin primitive', + detail: 'MCP is the primary cross-host operation seam', + project: 'Recall-e2e', + }, + }); + if (add.isError) throw new Error(resultText(add)); + const decisionId = Number(resultText(add).match(/#(\d+)/)?.[1]); + if (!decisionId) throw new Error(`decision ID missing: ${resultText(add)}`); + + const update = await client.callTool({ + name: 'decision_update', + arguments: { id: decisionId, action: 'revert' }, + }); + if (update.isError) throw new Error(resultText(update)); + + const dump = await client.callTool({ + name: 'memory_dump', + arguments: { + title: 'Codex portable dump e2e', + project: 'Recall-e2e', + session_id: 'codex-e2e-session', + source: 'codex', + skip_fabric: true, + messages: [ + { role: 'user', content: 'Verify the native Codex plugin against an isolated database.' }, + { role: 'assistant', content: 'The MCP dump receives explicit visible messages; lifecycle auto-capture remains separate.' }, + ], + }, + }); + if (dump.isError) throw new Error(resultText(dump)); + const loaId = Number(resultText(dump).match(/LoA Entry:\*\* #(\d+)/)?.[1]); + if (!loaId) throw new Error(`LoA ID missing: ${resultText(dump)}`); + + const calls: Array<[string, Record<string, unknown>]> = [ + ['loa_show', { id: loaId }], + ['memory_search', { query: 'native Codex plugin', project: 'Recall-e2e' }], + ['memory_hybrid_search', { query: 'portable plugin memory', project: 'Recall-e2e' }], + ['memory_recall', { project: 'Recall-e2e', limit: 10 }], + ['memory_stats', {}], + ['context_for_agent', { agent_task: 'continue Codex plugin verification', project: 'Recall-e2e' }], + ]; + for (const [name, args] of calls) { + const result = await client.callTool({ name, arguments: args }); + if (result.isError) throw new Error(`${name}: ${resultText(result)}`); + } + console.log(`mcp.tools_verified=${expectedTools.length}`); + } finally { + await client.close(); + } + + const productionAfter = metadata(productionDb); + if (JSON.stringify(productionAfter) !== JSON.stringify(productionBefore)) { + throw new Error(`production DB metadata changed: before=${JSON.stringify(productionBefore)} after=${JSON.stringify(productionAfter)}`); + } + console.log('isolation.production_db_unchanged=true'); + console.log('e2e.status=PASS'); +} + +try { + await main(); +} finally { + rmSync(tempRoot, { recursive: true, force: true }); +} diff --git a/src/AGENTS.md b/src/AGENTS.md index c3ff5be..17eca26 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -11,7 +11,7 @@ TypeScript source for the `recall` CLI (Commander), the `recall-mcp` MCP server, - `index.ts` — CLI entry (Commander) - `mcp-server.ts` — MCP server entry (`@modelcontextprotocol/sdk`) - `version.ts` — version sourced from `package.json` -- `commands/` — CLI subcommands · `db/` — connection + schema/FTS5 · `lib/` — core memory, embeddings, import, project utils, lifecycle delegation (`lifecycle.ts` → install.sh/update.sh/uninstall.sh) · `types/` — shared types +- `commands/` — CLI subcommands · `db/` — connection + schema/FTS5 · `lib/` — host-neutral core memory, embeddings, import, project utils, lifecycle delegation (`lifecycle.ts` → install.sh/update.sh/uninstall.sh) · `hosts/` — native host paths, config schemas, transcript adapters, and native command discovery · `providers/` — external model-provider adapters · `types/` — shared types Not owned here: lifecycle hooks (`hooks/` — standalone, must NOT import from `src/`) and build output (`dist/`, generated). @@ -21,6 +21,9 @@ Not owned here: lifecycle hooks (`hooks/` — standalone, must NOT import from ` - Build is tsup → ESM with `--external bun:sqlite`; the build step rewrites the `node` shebang to `bun`. - DB lives at `~/.agents/Recall/recall.db` (override `RECALL_DB_PATH`; legacy `MEM_DB_PATH` accepted). WAL mode. FTS5 with sync triggers — keep table defs and triggers in `db/schema.ts` aligned. - DB-path resolution is shared with hooks via `hooks/lib/db-path.ts` so CLI and hooks agree — import that resolver, never fork the logic. +- Recall-owned mutable state and logs resolve through `lib/runtime-paths.ts` (`RECALL_HOME`, default `~/.agents/Recall`) — never place generic runtime state under a native host's config directory. +- Native host paths, transcript formats, config ownership, command lookup, and authentication assumptions belong under `hosts/`; host-neutral commands and MCP handlers depend on their interfaces rather than branching on host details. +- External text-generation commands belong behind `providers/text-generation.ts`; callers must not shell a native model CLI directly. - All project-path handling goes through `lib/project.ts` (`validateDirPath` injection guard) — never assemble project paths ad hoc. - Explicit add paths (`recall add` CLI + `memory_add` MCP) redact secrets at the choke point: `addDecision`/`addLearning`/`addBreadcrumb` in `lib/memory.ts` `scrub()` every free-text field before insert and report redacted kinds via the optional `redactionsOut` arg. A new explicit write path must route through these — never INSERT user free-text directly. diff --git a/src/commands/cluster.ts b/src/commands/cluster.ts index b82fe8b..b6b7697 100644 --- a/src/commands/cluster.ts +++ b/src/commands/cluster.ts @@ -2,7 +2,8 @@ import { getDb } from '../db/connection.js'; import { blobToEmbedding, cosineSimilarity } from '../lib/embeddings.js'; -import { execSync } from 'child_process'; +import { claudeCliTextGenerationProvider } from '../providers/claude-cli.js'; +import type { TextGenerationProvider } from '../providers/text-generation.js'; interface ClusterOptions { execute?: boolean; @@ -69,7 +70,10 @@ function findClusters(learnings: LearningWithEmbedding[], threshold: number): Cl return clusters; } -function synthesizeProcedure(cluster: Cluster): { title: string; steps: string; trigger: string } | null { +function synthesizeProcedure( + cluster: Cluster, + provider: TextGenerationProvider = claudeCliTextGenerationProvider, +): { title: string; steps: string; trigger: string } | null { const learningsText = cluster.members.map((l, i) => `Learning ${i + 1}: Problem: ${l.problem}\nSolution: ${l.solution || 'N/A'}` ).join('\n\n'); @@ -94,12 +98,8 @@ STEPS: ...`; try { - // Uses claude CLI with piped input — same pattern as RecallExtract.ts - // Input is generated from DB content, not user-supplied shell arguments - const result = execSync( - `echo ${JSON.stringify(prompt)} | claude -p --model claude-haiku-4-5-20251001 2>/dev/null`, - { encoding: 'utf-8', timeout: 30000 } - ).trim(); + const result = provider.generate(prompt); + if (!result) return null; const titleMatch = result.match(/TITLE:\s*(.+)/); const triggerMatch = result.match(/TRIGGER:\s*(.+)/); diff --git a/src/commands/consolidate.ts b/src/commands/consolidate.ts index e0dd711..d0d3460 100644 --- a/src/commands/consolidate.ts +++ b/src/commands/consolidate.ts @@ -26,6 +26,7 @@ import { type ConsolidateTable, } from '../lib/consolidate.js'; import { DEFAULT_AGE_CUTOFF_DAYS, DEFAULT_IMPORTANCE_THRESHOLD } from '../lib/aging.js'; +import { getRecallHome } from '../lib/runtime-paths.js'; export interface ConsolidateOptions { execute?: boolean; @@ -102,7 +103,7 @@ function resolveBunPath(): string { /** Default apply: spawn the installed hook-side engine and return its JSON. */ function spawnConsolidateChild(plan: ConsolidatePlan): Promise<ConsolidateApplyResult> { - const script = join(process.env.HOME || '', '.claude', 'hooks', 'lib', 'consolidate-core.ts'); + const script = join(getRecallHome(), 'shared', 'hooks', 'lib', 'consolidate-core.ts'); if (!existsSync(script)) { return Promise.resolve({ written: 0, demoted: 0, redactions: [], skipped: [], @@ -111,14 +112,12 @@ function spawnConsolidateChild(plan: ConsolidatePlan): Promise<ConsolidateApplyR } const payload = JSON.stringify({ clusters: plan.clusters }); try { - // CLAUDECODE='' so the child's nested `claude -p` cascade doesn't recursively - // fire Stop hooks (same guard as RecallClearExtract / RecallInSession). const out = execSync(`${resolveBunPath()} run ${script}`, { input: payload, encoding: 'utf-8', timeout: 180000, maxBuffer: 10 * 1024 * 1024, - env: { ...process.env, CLAUDECODE: '' }, + env: { ...process.env, RECALL_NESTED_EXTRACTION: '1' }, }); return Promise.resolve(JSON.parse(out) as ConsolidateApplyResult); } catch (err: any) { diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 07ababf..56414fd 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -8,6 +8,9 @@ import { homedir } from 'os'; import { getDb, getDbPath } from '../db/connection.js'; import { checkAllFts } from '../lib/repair.js'; import { VERSION } from '../version.js'; +import { claudeMcpConfigTargets, claudePaths, inspectClaudeCli } from '../hosts/claude.js'; +import type { McpConfigTarget } from '../hosts/types.js'; +import { getRecallHome } from '../lib/runtime-paths.js'; export interface DoctorOptions { fix?: boolean; @@ -176,7 +179,7 @@ export function checkFtsIndexes(): CheckResult { // ───────────────────────────────────────── function checkExtractionFiles(): CheckResult { const label = 'Extraction output files present'; - const memDir = join(homedir(), '.claude', 'MEMORY'); + const memDir = claudePaths(homedir()).memory; const files = [ 'DISTILLED.md', @@ -203,7 +206,7 @@ function checkExtractionFiles(): CheckResult { // ───────────────────────────────────────── function checkExtractLog(): CheckResult { const label = 'Extraction log status'; - const logPath = join(homedir(), '.claude', 'MEMORY', 'EXTRACT_LOG.txt'); + const logPath = join(claudePaths(homedir()).memory, 'EXTRACT_LOG.txt'); if (!existsSync(logPath)) { return { label, status: 'WARN', message: 'No extraction log found at MEMORY/EXTRACT_LOG.txt' }; @@ -241,7 +244,7 @@ function checkExtractLog(): CheckResult { // ───────────────────────────────────────── function checkTrackerLockouts(): CheckResult { const label = 'Extraction tracker lockouts'; - const trackerPath = join(homedir(), '.claude', 'MEMORY', '.extraction_tracker.json'); + const trackerPath = join(claudePaths(homedir()).memory, '.extraction_tracker.json'); if (!existsSync(trackerPath)) { return { label, status: 'PASS', message: 'No tracker file (no lockouts possible)' }; @@ -365,37 +368,17 @@ function checkMcpServer(): CheckResult { function checkClaudeCli(): CheckResult { const label = 'Claude CLI available (for extraction)'; - const warnings: string[] = []; - - // Check CLAUDECODE env var (blocks extraction when set) - if (process.env.CLAUDECODE) { - warnings.push('CLAUDECODE env var set (will block extraction)'); + const readiness = inspectClaudeCli(homedir()); + if (!readiness.path) { + return { label, status: 'WARN', message: readiness.warnings.join('; ') }; } - // Find claude binary - try { - const result = spawnSync('which', ['claude'], { encoding: 'utf-8', timeout: 3000 }); - const claudePath = (result.stdout ?? '').trim(); - - if (!claudePath) { - warnings.push('Claude CLI not found in PATH (extraction will not work)'); - return { - label, - status: 'WARN', - message: warnings.join('; '), - }; - } - - const base = `Claude CLI at ${claudePath}`; - if (warnings.length > 0) { - return { label, status: 'WARN', message: `${base} — ${warnings.join('; ')}` }; - } - - return { label, status: 'PASS', message: base }; - } catch { - warnings.push('Could not run `which claude`'); - return { label, status: 'WARN', message: warnings.join('; ') }; + const base = `Claude CLI at ${readiness.path}`; + if (readiness.warnings.length > 0) { + return { label, status: 'WARN', message: `${base} — ${readiness.warnings.join('; ')}` }; } + + return { label, status: 'PASS', message: base }; } // ───────────────────────────────────────── @@ -403,7 +386,7 @@ function checkClaudeCli(): CheckResult { // ───────────────────────────────────────── function checkGrowth(): CheckResult { const label = 'Memory growth report'; - const memDir = join(homedir(), '.claude', 'MEMORY'); + const memDir = claudePaths(homedir()).memory; const warnings: string[] = []; const info: string[] = []; @@ -490,7 +473,8 @@ function listSkillCanonicalFiles(root: string): { name: string; file: string }[] function buildSymlinkProbes(): SymlinkProbe[] { const home = homedir(); - const root = join(home, '.agents', 'Recall'); + const root = getRecallHome(); + const claude = claudePaths(home); const probes: SymlinkProbe[] = []; // Hooks — managed by install-lib.sh as per-file symlinks. Use the new @@ -499,18 +483,18 @@ function buildSymlinkProbes(): SymlinkProbe[] { for (const h of hooks) { probes.push({ label: `hook: ${h}.ts`, - target: join(home, '.claude', 'hooks', `${h}.ts`), + target: join(claude.hooks, `${h}.ts`), canonical: join(root, 'shared', 'hooks', `${h}.ts`), }); } probes.push({ label: 'extract_prompt.md', - target: join(home, '.claude', 'MEMORY', 'extract_prompt.md'), + target: join(claude.memory, 'extract_prompt.md'), canonical: join(root, 'shared', 'extract_prompt.md'), }); probes.push({ label: 'Recall_GUIDE.md (Claude)', - target: join(home, '.claude', 'Recall_GUIDE.md'), + target: claude.guide, canonical: join(root, 'claude', 'Recall_GUIDE.md'), }); @@ -523,7 +507,7 @@ function buildSymlinkProbes(): SymlinkProbe[] { for (const { name, file } of listSkillCanonicalFiles(root)) { probes.push({ label: `agent skill: ${name}/${file}`, - target: join(home, '.claude', 'skills', name, file), + target: join(claude.skills, name, file), canonical: join(root, 'shared', 'skills', name, file), }); } @@ -693,7 +677,7 @@ export function probeSymlink(probe: SymlinkProbe): ProbeCheck { // key. Command/args rewrites stay owned by the installer; doctor only repairs // the env block, which is the surgical fix issue #28 calls for. export interface McpEnvProbe { - configPaths: string[]; // candidate config files, in resolution order + targets: McpConfigTarget[]; // native-host config owners, in resolution order resolvedDbPath: string; // getDbPath() — the value the env block should carry } @@ -702,13 +686,8 @@ interface McpEntry { [key: string]: unknown; } -interface ClaudeConfig { - mcpServers?: Record<string, McpEntry>; - [key: string]: unknown; -} - interface McpRegistration { - configPath: string; + target: McpConfigTarget; envDbPath: string | undefined; // value of env.RECALL_DB_PATH, if present } @@ -724,23 +703,43 @@ interface McpScan { // Mirrors the file selection in _recall_ensure_mcp_entry (exists + contains the // registration). Malformed/unreadable JSON is never fatal, but it is recorded // (not dropped) so the caller can distinguish it from a missing registration. -function findMcpRegistrations(configPaths: string[]): McpScan { +function parseHostConfig(target: McpConfigTarget): Record<string, unknown> { + const raw = readFileSync(target.path, 'utf-8'); + const json = target.format === 'jsonc' + ? raw.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '') + : raw; + return JSON.parse(json) as Record<string, unknown>; +} + +function mcpEntryAt(config: Record<string, unknown>, target: McpConfigTarget): McpEntry | null { + let node: unknown = config; + for (const segment of target.envPath.slice(0, -1)) { + if (!node || typeof node !== 'object' || Array.isArray(node)) return null; + node = (node as Record<string, unknown>)[segment]; + } + return node && typeof node === 'object' && !Array.isArray(node) ? node as McpEntry : null; +} + +function findMcpRegistrations(targets: McpConfigTarget[]): McpScan { const owners: McpRegistration[] = []; const unparseable: string[] = []; - for (const configPath of configPaths) { - if (!existsSync(configPath)) continue; - let cfg: ClaudeConfig; + for (const target of targets) { + if (!existsSync(target.path)) continue; + let cfg: Record<string, unknown>; try { - cfg = JSON.parse(readFileSync(configPath, 'utf-8')) as ClaudeConfig; + cfg = parseHostConfig(target); } catch { - unparseable.push(configPath); + unparseable.push(target.path); continue; } - const entry = cfg.mcpServers?.['recall-memory']; - if (!entry || typeof entry !== 'object') continue; - const env = entry.env; - const raw = env && typeof env === 'object' && !Array.isArray(env) ? env.RECALL_DB_PATH : undefined; - owners.push({ configPath, envDbPath: typeof raw === 'string' ? raw : undefined }); + const entry = mcpEntryAt(cfg, target); + if (!entry) continue; + const envKey = target.envPath[target.envPath.length - 1]; + const env = entry[envKey]; + const raw = env && typeof env === 'object' && !Array.isArray(env) + ? (env as Record<string, unknown>).RECALL_DB_PATH + : undefined; + owners.push({ target, envDbPath: typeof raw === 'string' ? raw : undefined }); } return { owners, unparseable }; } @@ -749,9 +748,9 @@ function findMcpRegistrations(configPaths: string[]): McpScan { // resolved DB path); the repair closure patches the owning config file(s). export function probeMcpEnv(probe: McpEnvProbe): ProbeCheck { const label = 'MCP env carries RECALL_DB_PATH'; - const { configPaths, resolvedDbPath } = probe; + const { targets, resolvedDbPath } = probe; - const { owners, unparseable } = findMcpRegistrations(configPaths); + const { owners, unparseable } = findMcpRegistrations(targets); // A config that exists but can't be parsed is never silently dropped: doctor // names it and leaves it untouched. When there are no valid owners this is the @@ -794,7 +793,7 @@ export function probeMcpEnv(probe: McpEnvProbe): ProbeCheck { // At least one owner has a missing/empty env or a divergent value — the // next-Claude-restart hazard from issue #28. const detail = stale - .map(o => `${o.configPath} (${o.envDbPath === undefined ? 'env.RECALL_DB_PATH missing' : `has ${o.envDbPath}`})`) + .map(o => `${o.target.path} (${o.envDbPath === undefined ? 'env.RECALL_DB_PATH missing' : `has ${o.envDbPath}`})`) .join('; '); return { result: { @@ -812,31 +811,33 @@ export function probeMcpEnv(probe: McpEnvProbe): ProbeCheck { // read-only settings.json) must not abort the others or escape the loop. let tmpPath: string | undefined; try { - const cfg = JSON.parse(readFileSync(owner.configPath, 'utf-8')) as ClaudeConfig; - const entry = cfg.mcpServers?.['recall-memory']; + const cfg = parseHostConfig(owner.target); + const entry = mcpEntryAt(cfg, owner.target); if (!entry) continue; // registration vanished between probe and repair — nothing to back up or patch - if (!entry.env || typeof entry.env !== 'object' || Array.isArray(entry.env)) entry.env = {}; - entry.env.RECALL_DB_PATH = resolvedDbPath; - delete entry.env.MEM_DB_PATH; + const envKey = owner.target.envPath[owner.target.envPath.length - 1]; + if (!entry[envKey] || typeof entry[envKey] !== 'object' || Array.isArray(entry[envKey])) entry[envKey] = {}; + const env = entry[envKey] as Record<string, unknown>; + env.RECALL_DB_PATH = resolvedDbPath; + delete env.MEM_DB_PATH; // Resolve through any symlink so the write goes THROUGH the link rather // than replacing it with a regular file (which would orphan the // canonical target and break stow/chezmoi-style dotfiles). Safe here: // the file was just read successfully. - const target = realpathSync(owner.configPath); + const target = realpathSync(owner.target.path); tmpPath = target + '.tmp'; // Back up only once the write is confirmed to happen (after the re-read // + entry guard), so a vanished registration leaves no orphan backup. mkdirSync(backupDir, { recursive: true }); - copyFileSync(target, join(backupDir, owner.configPath.replace(/[/\\]/g, '_'))); + copyFileSync(target, join(backupDir, owner.target.path.replace(/[/\\]/g, '_'))); // Atomic write: stage to a temp sibling then rename over the resolved // target, so an interrupt mid-write can't truncate the user's config. writeFileSync(tmpPath, JSON.stringify(cfg, null, 2)); renameSync(tmpPath, target); - patched.push(owner.configPath); + patched.push(owner.target.path); } catch (err) { if (tmpPath) { try { if (existsSync(tmpPath)) unlinkSync(tmpPath); } catch { /* best-effort temp cleanup */ } } // Capture the cause so a real EACCES/disk-full is diagnosable. - failed.push(`${owner.configPath} (${err instanceof Error ? err.message : String(err)})`); + failed.push(`${owner.target.path} (${err instanceof Error ? err.message : String(err)})`); } } // Every stale owner's registration vanished before we could patch it — @@ -922,14 +923,14 @@ export async function runDoctor(opts: DoctorOptions = {}): Promise<void> { // Skill command surface floor (#235): the per-file probes above go silent // when zero skill canonicals exist, so add an explicit WARN for a blanked // command surface — the sole command surface since #228. - results.push(probeSkillSurface(join(homedir(), '.agents', 'Recall'))); + results.push(probeSkillSurface(getRecallHome())); // MCP env health (issue #28): the recall-memory registration must carry // env.RECALL_DB_PATH matching the resolved DB path, or the MCP server can // diverge from the CLI after the next Claude restart. Same repair contract as // the symlink probes — with --fix we patch the owning config file(s). const mcpEnvCheck = probeMcpEnv({ - configPaths: [join(homedir(), '.claude.json'), join(homedir(), '.claude', 'settings.json')], + targets: claudeMcpConfigTargets(homedir()), resolvedDbPath: getDbPath(), }); results.push(resolveProbeResult(mcpEnvCheck, !!opts.fix)); @@ -937,7 +938,7 @@ export async function runDoctor(opts: DoctorOptions = {}): Promise<void> { // Completion sentinel (#27): a leftover .install-incomplete marker means a // prior install/update was interrupted before its self-check ran. Warn-only — // re-running ./install.sh converges; doctor does not auto-repair it. - results.push(probeInstallSentinel(join(homedir(), '.agents', 'Recall', '.install-incomplete'))); + results.push(probeInstallSentinel(join(getRecallHome(), '.install-incomplete'))); // Print all results for (const r of results) { diff --git a/src/commands/dump.ts b/src/commands/dump.ts index dae54bb..b1d4bdf 100644 --- a/src/commands/dump.ts +++ b/src/commands/dump.ts @@ -1,33 +1,16 @@ // recall dump command - Flush current session to DB + capture LoA // Core functions are exported for use by the MCP server's memory_dump tool. -import { readdirSync, readFileSync, existsSync, statSync } from 'fs'; -import { join, basename, dirname } from 'path'; -import { homedir } from 'os'; import { getDb } from '../db/connection.js'; import { createSession, sessionExists, addMessagesBatch, createLoaEntry } from '../lib/memory.js'; -import { extractProjectFromPath } from '../lib/project.js'; import { chunked } from '../lib/chunk.js'; import { embed, embeddingToBlob, checkEmbeddingService } from '../lib/embeddings.js'; import { formatMessagesForExtraction, generateBasicSummary, runFabricExtract } from '../lib/extraction.js'; -import type { Message } from '../types/index.js'; +import { discoverCurrentSession } from '../hosts/session-sources.js'; +import type { ParsedSession, SessionSource } from '../hosts/session-source.js'; -// ============ Constants ============ - -const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects'); -const OPENCODE_DROP_DIR = join(homedir(), '.claude', 'MEMORY', 'opencode-sessions'); -const PI_DROP_DIR = join(homedir(), '.claude', 'MEMORY', 'pi-sessions'); -// ============ Types ============ - -export type SessionSource = 'claude' | 'opencode' | 'pi'; - -export interface ParsedSession { - source: SessionSource; - sessionId: string; - project: string; - messages: Omit<Message, 'id'>[]; - filePath: string; -} +export type { ParsedSession, SessionSource } from '../hosts/session-source.js'; +export { parseMarkdownDrop } from '../hosts/markdown-session-source.js'; interface DumpOptions { project?: string; @@ -35,232 +18,8 @@ interface DumpOptions { tags?: string; limit?: number; skipFabric?: boolean; -} - -// ============ Session Finding ============ - -/** - * Find the most recently modified JSONL file (Claude Code sessions) - */ -function findCurrentSessionFile(): string | null { - if (!existsSync(CLAUDE_PROJECTS_DIR)) return null; - - let mostRecentFile: string | null = null; - let mostRecentTime = 0; - - const projectDirs = readdirSync(CLAUDE_PROJECTS_DIR, { withFileTypes: true }) - .filter(d => d.isDirectory()) - .map(d => d.name); - - for (const projectDir of projectDirs) { - const projectPath = join(CLAUDE_PROJECTS_DIR, projectDir); - const jsonlFiles = readdirSync(projectPath, { withFileTypes: true }) - .filter(f => f.isFile() && f.name.endsWith('.jsonl')) - .map(f => join(projectPath, f.name)); - - for (const file of jsonlFiles) { - const stat = statSync(file); - if (stat.mtimeMs > mostRecentTime) { - mostRecentTime = stat.mtimeMs; - mostRecentFile = file; - } - } - } - - return mostRecentFile; -} - -/** - * Find the most recently modified markdown drop file in a directory - */ -function findLatestDropFile(dropDir: string): string | null { - if (!existsSync(dropDir)) return null; - - let latest: string | null = null; - let latestTime = 0; - - for (const f of readdirSync(dropDir)) { - if (!f.endsWith('.md') || f.startsWith('.')) continue; - const full = join(dropDir, f); - const mt = statSync(full).mtimeMs; - if (mt > latestTime) { - latestTime = mt; - latest = full; - } - } - - return latest; -} - -// ============ Session Parsing ============ - -/** - * Parse a Claude Code JSONL session file - */ -function parseSessionFile(filePath: string): { sessionId: string; project: string; messages: Omit<Message, 'id'>[] } | null { - const content = readFileSync(filePath, 'utf-8'); - const lines = content.split('\n').filter(l => l.trim()); - if (lines.length === 0) return null; - - const messages: Omit<Message, 'id'>[] = []; - let sessionId: string | null = null; - let project: string | null = null; - - for (const line of lines) { - try { - const parsed = JSON.parse(line); - if (parsed.type !== 'user' && parsed.type !== 'assistant') continue; - if (!sessionId && parsed.sessionId) sessionId = parsed.sessionId; - - if (parsed.message?.content) { - let msgContent: string; - if (typeof parsed.message.content === 'string') { - msgContent = parsed.message.content; - } else if (Array.isArray(parsed.message.content)) { - msgContent = parsed.message.content - .filter((block: any) => block.type === 'text' && block.text) - .map((block: any) => block.text) - .join('\n'); - } else { - continue; - } - - if (msgContent.trim()) { - messages.push({ - session_id: parsed.sessionId || sessionId || 'unknown', - timestamp: parsed.timestamp || new Date().toISOString(), - role: parsed.type as 'user' | 'assistant', - content: msgContent, - project: project || undefined - }); - } - } - } catch { - continue; - } - } - - const projectDir = basename(dirname(filePath)); - project = extractProjectFromPath(projectDir); - for (const msg of messages) { - msg.project = project; - } - - if (!sessionId) sessionId = basename(filePath, '.jsonl'); - - return { sessionId, project, messages }; -} - -/** - * Parse a markdown drop file (OpenCode or Pi format) into messages. - * Supports [ROLE]: content and ## Role heading formats. - */ -export function parseMarkdownDrop(filePath: string): { sessionId: string; messages: Omit<Message, 'id'>[] } | null { - const content = readFileSync(filePath, 'utf-8'); - if (!content.trim()) return null; - - const sessionId = basename(filePath, '.md'); - const messages: Omit<Message, 'id'>[] = []; - - // Split on role markers: [USER]: or [ASSISTANT]:, optionally with timestamps - const rolePattern = /\[(USER|ASSISTANT)(?:\s+[\d:]+)?\]:\s*/gi; - const parts: Array<{ role: 'user' | 'assistant'; startIdx: number }> = []; - - let match: RegExpExecArray | null; - while ((match = rolePattern.exec(content)) !== null) { - parts.push({ - role: match[1].toLowerCase() as 'user' | 'assistant', - startIdx: match.index + match[0].length - }); - } - - // Fallback: try markdown heading format (## User / ## Assistant) - if (parts.length === 0) { - const headingPattern = /^##?\s*(User|Assistant|Human)\s*$/gim; - while ((match = headingPattern.exec(content)) !== null) { - const rawRole = match[1].toLowerCase(); - parts.push({ - role: (rawRole === 'human' ? 'user' : rawRole) as 'user' | 'assistant', - startIdx: match.index + match[0].length - }); - } - } - - if (parts.length === 0) return null; - - const now = new Date().toISOString(); - for (let i = 0; i < parts.length; i++) { - const start = parts[i].startIdx; - // End at next role marker (search backward a bit to exclude the marker prefix) - const end = i + 1 < parts.length - ? content.lastIndexOf('\n', content.indexOf('[', parts[i + 1].startIdx - 80)) - : content.length; - const text = content.slice(start, end > start ? end : content.length).trim(); - - if (text.length > 10) { - messages.push({ - session_id: sessionId, - timestamp: now, - role: parts[i].role, - content: text - }); - } - } - - return messages.length > 0 ? { sessionId, messages } : null; -} - -/** - * Find the current session across all supported agent types. - * Priority: Claude Code JSONL > OpenCode drops > Pi drops - */ -export function findCurrentSessionAcrossAgents(): ParsedSession | null { - // Try Claude Code first (most common) - const claudeFile = findCurrentSessionFile(); - if (claudeFile) { - const parsed = parseSessionFile(claudeFile); - if (parsed && parsed.messages.length > 0) { - return { - source: 'claude', - sessionId: parsed.sessionId, - project: parsed.project, - messages: parsed.messages, - filePath: claudeFile - }; - } - } - - // Try OpenCode drops - const openCodeFile = findLatestDropFile(OPENCODE_DROP_DIR); - if (openCodeFile) { - const parsed = parseMarkdownDrop(openCodeFile); - if (parsed && parsed.messages.length > 0) { - return { - source: 'opencode', - sessionId: parsed.sessionId, - project: 'opencode', - messages: parsed.messages.map(m => ({ ...m, project: 'opencode' })), - filePath: openCodeFile - }; - } - } - - // Try Pi drops - const piFile = findLatestDropFile(PI_DROP_DIR); - if (piFile) { - const parsed = parseMarkdownDrop(piFile); - if (parsed && parsed.messages.length > 0) { - return { - source: 'pi', - sessionId: parsed.sessionId, - project: 'pi', - messages: parsed.messages.map(m => ({ ...m, project: 'pi' })), - filePath: piFile - }; - } - } - - return null; + /** Test/internal seam; normal CLI and MCP behavior still attempts LoA embedding. */ + skipEmbed?: boolean; } // ============ Internal Helpers ============ @@ -360,10 +119,10 @@ export async function coreDump(title: string, options: DumpOptions & { session?: source: SessionSource; error?: string; }> { - const session = options.session || findCurrentSessionAcrossAgents(); + const session = options.session || discoverCurrentSession(); if (!session) { - return { success: false, sessionId: '', messageCount: 0, source: 'claude', error: 'No session files found' }; + return { success: false, sessionId: '', messageCount: 0, source: 'mcp', error: 'No session input or supported host session files found' }; } if (options.project) { @@ -384,7 +143,8 @@ export async function coreDump(title: string, options: DumpOptions & { session?: started_at: timestamps[0], ended_at: timestamps[timestamps.length - 1], project: options.project || session.project, - summary: `Dumped: ${title}` + summary: `Dumped: ${title}`, + source: session.source, }); // Raw conversation capture is verbatim (ADR-0001). @@ -436,7 +196,7 @@ export async function coreDump(title: string, options: DumpOptions & { session?: provenance: 'extracted' }); - await autoEmbedLoaEntry(loaId, title, fabricExtract); + if (!options.skipEmbed) await autoEmbedLoaEntry(loaId, title, fabricExtract); return { success: true, diff --git a/src/commands/import-docs.ts b/src/commands/import-docs.ts index 8d8230d..0e95f3b 100644 --- a/src/commands/import-docs.ts +++ b/src/commands/import-docs.ts @@ -5,8 +5,9 @@ import { existsSync, readFileSync, statSync, readdirSync } from 'fs'; import { join, basename, dirname } from 'path'; import { homedir } from 'os'; import { getDb } from '../db/connection.js'; +import { claudePaths } from '../hosts/claude.js'; -const RECALL_BASE_DIR = process.env.RECALL_BASE_DIR || join(homedir(), '.claude'); +const RECALL_BASE_DIR = process.env.RECALL_BASE_DIR || claudePaths(homedir()).root; interface DocFile { path: string; diff --git a/src/commands/import-legacy.ts b/src/commands/import-legacy.ts index 35c1c5a..de193d5 100644 --- a/src/commands/import-legacy.ts +++ b/src/commands/import-legacy.ts @@ -7,8 +7,9 @@ import { getDb } from '../db/connection.js'; import { createLoaEntry } from '../lib/memory.js'; import { scrub } from '../lib/write-safety.js'; import { detectThreats, summarizeThreats } from '../lib/threat-detect.js'; +import { claudePaths } from '../hosts/claude.js'; -const DEFAULT_MEMORY_DIR = join(homedir(), '.claude', 'MEMORY'); +const DEFAULT_MEMORY_DIR = claudePaths(homedir()).memory; interface LegacyExtract { date: string; diff --git a/src/commands/import-telos.ts b/src/commands/import-telos.ts index 389e289..d9caa93 100644 --- a/src/commands/import-telos.ts +++ b/src/commands/import-telos.ts @@ -4,9 +4,11 @@ import { readFileSync, existsSync, readdirSync, statSync, writeFileSync, mkdirSy import { join, basename } from 'path'; import { homedir } from 'os'; import { getDb } from '../db/connection.js'; +import { claudePaths } from '../hosts/claude.js'; -const TELOS_DIR = join(homedir(), '.claude', 'skills', 'PAI', 'USER', 'TELOS'); -const TELOS_MTIME_PATH = join(homedir(), '.claude', 'MEMORY', '.telos_last_import'); +const CLAUDE_PATHS = claudePaths(homedir()); +const TELOS_DIR = join(CLAUDE_PATHS.skills, 'PAI', 'USER', 'TELOS'); +const TELOS_MTIME_PATH = join(CLAUDE_PATHS.memory, '.telos_last_import'); // Map filenames to telos types (schema CHECK constraint) const TYPE_MAP: Record<string, string> = { @@ -97,7 +99,7 @@ function getLastImportTime(): number { * Save the current import timestamp */ function saveImportTime(): void { - mkdirSync(join(homedir(), '.claude', 'MEMORY'), { recursive: true }); + mkdirSync(CLAUDE_PATHS.memory, { recursive: true }); writeFileSync(TELOS_MTIME_PATH, Date.now().toString()); } diff --git a/src/commands/loa.ts b/src/commands/loa.ts index 334438c..2c9c043 100644 --- a/src/commands/loa.ts +++ b/src/commands/loa.ts @@ -1,10 +1,10 @@ // recall loa command - Library of Alexandria capture -import { execSync } from 'child_process'; import { createLoaEntry, getMessagesSinceLastLoa, getLastLoaEntry, getLoaEntry, getLoaMessages } from '../lib/memory.js'; import { detectProject } from '../lib/project.js'; import { embed, embeddingToBlob, checkEmbeddingService } from '../lib/embeddings.js'; import { getDb } from '../db/connection.js'; +import { formatMessagesForExtraction, runFabricExtract } from '../lib/extraction.js'; interface LoaOptions { continues?: number; @@ -14,9 +14,6 @@ interface LoaOptions { since?: string; // ISO timestamp to start from } -// Maximum input size for Fabric (50MB) - larger sessions should be split -const MAX_FABRIC_INPUT_BYTES = 50 * 1024 * 1024; - /** * Auto-embed a new LoA entry for semantic search (Phase 3) * Runs asynchronously after LoA creation - non-blocking @@ -46,44 +43,6 @@ async function autoEmbedLoaEntry(id: number, title: string, fabricExtract: strin } } -/** - * Run Fabric extract_wisdom pattern on content - * MANDATORY - no LoA entry without Fabric processing - * FIX #4: Increased buffer to 50MB, added input size validation - */ -function runFabricExtract(content: string): string { - // Check input size before attempting - const inputBytes = Buffer.byteLength(content, 'utf-8'); - if (inputBytes > MAX_FABRIC_INPUT_BYTES) { - throw new Error(`Input too large for Fabric (${(inputBytes / 1024 / 1024).toFixed(1)}MB > 50MB limit). Use --limit to reduce message count.`); - } - - try { - // Pass content to fabric via stdin - // Use streaming for faster response, haiku for speed - const result = execSync('fabric --pattern extract_wisdom --stream -m claude-haiku-4-5', { - input: content, - encoding: 'utf-8', - maxBuffer: MAX_FABRIC_INPUT_BYTES, // 50MB buffer - timeout: 600000 // 10 minute timeout for large sessions - }); - return result.trim(); - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - throw new Error(`Fabric extract_wisdom failed: ${error}`); - } -} - -/** - * Format messages for Fabric input - */ -function formatMessagesForFabric(messages: Array<{ role: string; content: string; timestamp: string }>): string { - return messages.map(m => { - const time = m.timestamp.split('T')[1]?.slice(0, 5) || ''; - return `[${m.role.toUpperCase()} ${time}]\n${m.content}`; - }).join('\n\n---\n\n'); -} - export async function runLoa(title: string, options: LoaOptions): Promise<void> { const project = options.project || detectProject(); @@ -103,7 +62,7 @@ export async function runLoa(title: string, options: LoaOptions): Promise<void> console.log(`Processing ${messages.length} messages through Fabric extract_wisdom...`); // Format messages for Fabric - const fabricInput = formatMessagesForFabric(messages); + const fabricInput = formatMessagesForExtraction(messages); // Run Fabric (MANDATORY) let fabricExtract: string; diff --git a/src/commands/migrate.ts b/src/commands/migrate.ts index 9294b99..79130dc 100644 --- a/src/commands/migrate.ts +++ b/src/commands/migrate.ts @@ -23,6 +23,7 @@ import { existsSync, mkdirSync, statSync, copyFileSync, renameSync, readFileSync import { dirname, join, resolve } from 'path'; import { homedir } from 'os'; import { execFileSync } from 'child_process'; +import { configurableHosts, type McpConfigTarget } from '../hosts/index.js'; export interface MigrateOptions { to: string; @@ -49,25 +50,12 @@ function isSidecar(suffix: string): suffix is '-wal' | '-shm' { return suffix === '-wal' || suffix === '-shm'; } -interface ConfigTarget { - path: string; - // Path within the parsed JSON to the env object that needs RECALL_DB_PATH. - // Strings are property keys, all-or-nothing — if any segment is missing - // (or the recall-memory entry is absent), the writer skips silently. - envPath: string[]; -} - -function detectConfigs(): ConfigTarget[] { +function detectConfigs(): McpConfigTarget[] { const home = homedir(); - return [ - { path: join(home, '.claude.json'), envPath: ['mcpServers', 'recall-memory', 'env'] }, - { path: join(home, '.claude', 'settings.json'), envPath: ['mcpServers', 'recall-memory', 'env'] }, - { path: join(home, '.config', 'opencode', 'opencode.json'), envPath: ['mcp', 'recall-memory', 'environment'] }, - { path: join(home, '.pi', 'agent', 'mcp.json'), envPath: ['mcpServers', 'recall-memory', 'environment'] }, - ]; + return configurableHosts.flatMap(host => host.mcpConfigTargets(home)); } -function patchConfigEnv(target: ConfigTarget, newDbPath: string, dryRun: boolean): { changed: boolean; reason?: string } { +function patchConfigEnv(target: McpConfigTarget, newDbPath: string, dryRun: boolean): { changed: boolean; reason?: string } { if (!existsSync(target.path)) return { changed: false, reason: 'not present' }; let raw: string; try { @@ -75,10 +63,9 @@ function patchConfigEnv(target: ConfigTarget, newDbPath: string, dryRun: boolean } catch (e) { return { changed: false, reason: `read error: ${(e as Error).message}` }; } - // opencode.json may contain JSONC — strip comments before parsing. - const stripped = raw - .replace(/\/\/.*$/gm, '') - .replace(/\/\*[\s\S]*?\*\//g, ''); + const stripped = target.format === 'jsonc' + ? raw.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '') + : raw; let cfg: any; try { cfg = JSON.parse(stripped); @@ -219,7 +206,10 @@ export function runMigrate(opts: MigrateOptions): void { console.log(''); console.log('Migration complete.'); - console.log('Restart Claude Code / OpenCode / Pi so their MCP servers reload with the new path.'); + const configuredHosts = [...new Set(targets.filter(t => existsSync(t.path)).map(t => t.host))]; + if (configuredHosts.length > 0) { + console.log(`Restart ${configuredHosts.join(' / ')} so their MCP servers reload with the new path.`); + } // Hint: if MEM_DB_PATH is still set in the shell, warn the user that it'd // be ignored on next session since RECALL_DB_PATH takes precedence. diff --git a/src/commands/onboard.ts b/src/commands/onboard.ts index ba9495a..5cc0539 100644 --- a/src/commands/onboard.ts +++ b/src/commands/onboard.ts @@ -20,6 +20,7 @@ import { existsSync, mkdirSync, writeFileSync, copyFileSync, renameSync, statSync } from 'fs'; import { join, dirname } from 'path'; import { homedir } from 'os'; +import { claudePaths } from '../hosts/claude.js'; import { createInterface, type Interface } from 'readline'; import { detectProject } from '../lib/project.js'; @@ -168,7 +169,7 @@ export function resolveOutputPath( const envPath = env.RECALL_IDENTITY_PATH; if (envPath && envPath.trim()) return envPath.trim(); if (options.project) return join(process.cwd(), '.atlas-recall', 'identity.md'); - return join(homedir(), '.claude', 'MEMORY', 'identity.md'); + return join(claudePaths(homedir()).memory, 'identity.md'); } // ─────────────────────────────────────────────────────────────────────── diff --git a/src/commands/path.ts b/src/commands/path.ts index a4854f2..b66ed9d 100644 --- a/src/commands/path.ts +++ b/src/commands/path.ts @@ -12,6 +12,10 @@ import { getDbPath } from '../db/connection.js'; import { existsSync, lstatSync, readlinkSync, statSync } from 'fs'; import { homedir } from 'os'; import { join } from 'path'; +import { claudePaths } from '../hosts/claude.js'; +import { openCodePaths } from '../hosts/opencode.js'; +import { piPaths } from '../hosts/pi.js'; +import { getRecallHome } from '../lib/runtime-paths.js'; export interface PathOptions { json?: boolean; @@ -64,7 +68,10 @@ function dbSizeMb(path: string): number | null { export function runPath(opts: PathOptions): void { const home = homedir(); - const installRoot = join(home, '.agents', 'Recall'); + const installRoot = getRecallHome(); + const claude = claudePaths(home); + const openCode = openCodePaths(home); + const pi = piPaths(home); const dbPath = getDbPath(); const dbSize = dbSizeMb(dbPath); const envVar = activeDbEnvVar(); @@ -73,11 +80,11 @@ export function runPath(opts: PathOptions): void { // (target path, canonical path) pair so we can report drift. const symlinks: Array<{ name: string; from: string; to: string; info: SymlinkInfo }> = []; const candidates: Array<[string, string, string]> = [ - ['claude_guide', join(home, '.claude', 'Recall_GUIDE.md'), join(installRoot, 'claude', 'Recall_GUIDE.md')], - ['claude_extract_prompt', join(home, '.claude', 'MEMORY', 'extract_prompt.md'), join(installRoot, 'shared', 'extract_prompt.md')], - ['claude_identity', join(home, '.claude', 'MEMORY', 'identity.md'), join(installRoot, 'MEMORY', 'identity.md')], - ['opencode_guide', join(home, '.config', 'opencode', 'Recall_GUIDE.md'), join(installRoot, 'opencode', 'Recall_GUIDE.md')], - ['pi_guide', join(home, '.pi', 'agent', 'Recall_GUIDE.md'), join(installRoot, 'pi', 'Recall_GUIDE.md')], + ['claude_guide', claude.guide, join(installRoot, 'claude', 'Recall_GUIDE.md')], + ['claude_extract_prompt', join(claude.memory, 'extract_prompt.md'), join(installRoot, 'shared', 'extract_prompt.md')], + ['claude_identity', join(claude.memory, 'identity.md'), join(installRoot, 'MEMORY', 'identity.md')], + ['opencode_guide', openCode.guide, join(installRoot, 'opencode', 'Recall_GUIDE.md')], + ['pi_guide', pi.guide, join(installRoot, 'pi', 'Recall_GUIDE.md')], ]; for (const [name, from, to] of candidates) { symlinks.push({ name, from, to, info: inspect(from) }); diff --git a/src/commands/scrub-archive.ts b/src/commands/scrub-archive.ts index db68a24..9874d0e 100644 --- a/src/commands/scrub-archive.ts +++ b/src/commands/scrub-archive.ts @@ -23,6 +23,7 @@ import { join, dirname } from 'path'; import { homedir } from 'os'; import { scrub } from '../lib/write-safety.js'; import { defaultBackupDir, timestampSlug, writeNonClobbering } from '../lib/export.js'; +import { claudePaths } from '../hosts/claude.js'; export interface ScrubArchiveOptions { dryRun?: boolean; @@ -183,8 +184,9 @@ function transcriptSurfaces(sessionsDir: string): Surface[] { } export function runScrubArchive(options: ScrubArchiveOptions = {}): void { - const memoryDir = options.memoryDir ?? join(homedir(), '.claude', 'MEMORY'); - const sessionsDir = options.sessionsDir ?? join(homedir(), '.claude', 'sessions'); + const nativePaths = claudePaths(homedir()); + const memoryDir = options.memoryDir ?? nativePaths.memory; + const sessionsDir = options.sessionsDir ?? nativePaths.sessions; const backupDir = options.backupDir ?? defaultBackupDir(); const now = options.now ?? new Date(); diff --git a/src/db/migrations.ts b/src/db/migrations.ts index d54b72e..0c18233 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -12,6 +12,7 @@ import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; import { FTS_SCHEMA } from './schema'; +import { claudePaths } from '../hosts/claude.js'; export type Migration = (db: Database) => void; @@ -67,7 +68,7 @@ export const MIGRATIONS: Migration[] = [ (db) => { if (skipLegacyDataMigrations()) return; - const memoryDir = join(homedir(), '.claude', 'MEMORY'); + const memoryDir = claudePaths(homedir()).memory; const trackerPath = join(memoryDir, '.extraction_tracker.json'); const sessionPath = join(memoryDir, 'SESSION_INDEX.json'); diff --git a/src/db/schema.ts b/src/db/schema.ts index ed969d8..0362e9f 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -13,7 +13,7 @@ CREATE TABLE IF NOT EXISTS sessions ( cwd TEXT, git_branch TEXT, model TEXT, - source TEXT DEFAULT 'claude-code' + source TEXT DEFAULT 'unknown' ); -- Messages table: conversation turns (user/assistant) diff --git a/src/hosts/claude-session-source.ts b/src/hosts/claude-session-source.ts new file mode 100644 index 0000000..871fb48 --- /dev/null +++ b/src/hosts/claude-session-source.ts @@ -0,0 +1,95 @@ +import { existsSync, readFileSync, readdirSync, statSync } from 'fs'; +import { basename, dirname, join } from 'path'; +import { homedir } from 'os'; +import type { Message } from '../types/index.js'; +import { claudePaths } from './claude.js'; +import type { ParsedSession, SessionSourceAdapter } from './session-source.js'; + +export function listClaudeSessionFiles(projectsDir: string = claudePaths(homedir()).projects): string[] { + if (!existsSync(projectsDir)) return []; + const files: string[] = []; + for (const projectDir of readdirSync(projectsDir, { withFileTypes: true }).filter(entry => entry.isDirectory())) { + const projectPath = join(projectsDir, projectDir.name); + for (const file of readdirSync(projectPath, { withFileTypes: true })) { + if (!file.isFile() || !file.name.endsWith('.jsonl')) continue; + files.push(join(projectPath, file.name)); + } + } + return files; +} + +/** Decode Claude Code's filesystem-safe project-directory convention. */ +export function extractClaudeProjectFromPath(path: string): string { + const parts = path.split('-'); + const projectsIdx = parts.findIndex(part => part.toLowerCase() === 'projects'); + if (projectsIdx !== -1 && projectsIdx < parts.length - 1) { + return parts.slice(projectsIdx + 1).join('-'); + } + return parts[parts.length - 1] || path; +} + +function findCurrentSessionFile(projectsDir: string): string | null { + let mostRecentFile: string | null = null; + let mostRecentTime = 0; + for (const filePath of listClaudeSessionFiles(projectsDir)) { + const modified = statSync(filePath).mtimeMs; + if (modified > mostRecentTime) { + mostRecentTime = modified; + mostRecentFile = filePath; + } + } + return mostRecentFile; +} + +export function parseClaudeSessionFile(filePath: string): Omit<ParsedSession, 'source' | 'filePath'> | null { + const lines = readFileSync(filePath, 'utf-8').split('\n').filter(line => line.trim()); + if (lines.length === 0) return null; + const messages: Omit<Message, 'id'>[] = []; + let sessionId: string | null = null; + for (const line of lines) { + try { + const parsed = JSON.parse(line) as { + type?: string; + sessionId?: string; + timestamp?: string; + message?: { content?: string | Array<{ type?: string; text?: string }> }; + }; + if (parsed.type !== 'user' && parsed.type !== 'assistant') continue; + if (!sessionId && parsed.sessionId) sessionId = parsed.sessionId; + const content = parsed.message?.content; + const text = typeof content === 'string' + ? content + : Array.isArray(content) + ? content.filter(block => block.type === 'text' && block.text).map(block => block.text).join('\n') + : ''; + if (!text.trim()) continue; + messages.push({ + session_id: parsed.sessionId || sessionId || 'unknown', + timestamp: parsed.timestamp || new Date().toISOString(), + role: parsed.type, + content: text, + }); + } catch { + // Ignore partial/corrupt transcript lines. + } + } + const project = extractClaudeProjectFromPath(basename(dirname(filePath))); + for (const message of messages) message.project = project; + return { + sessionId: sessionId || basename(filePath, '.jsonl'), + project, + messages, + }; +} + +export const claudeSessionSource: SessionSourceAdapter = { + id: 'claude', + discover() { + const filePath = findCurrentSessionFile(claudePaths(homedir()).projects); + if (!filePath) return null; + const parsed = parseClaudeSessionFile(filePath); + return parsed && parsed.messages.length > 0 + ? { source: 'claude', filePath, ...parsed } + : null; + }, +}; diff --git a/src/hosts/claude.ts b/src/hosts/claude.ts new file mode 100644 index 0000000..5bbeaf9 --- /dev/null +++ b/src/hosts/claude.ts @@ -0,0 +1,86 @@ +import { existsSync } from 'fs'; +import { execFileSync } from 'child_process'; +import { join } from 'path'; +import type { McpConfigTarget, NativeHostAdapter } from './types.js'; + +export interface ClaudePaths { + root: string; + memory: string; + projects: string; + sessions: string; + hooks: string; + skills: string; + guide: string; + settings: string; + legacySettings: string; +} + +export function claudePaths(home: string): ClaudePaths { + const root = join(home, '.claude'); + return { + root, + memory: join(root, 'MEMORY'), + projects: join(root, 'projects'), + sessions: join(root, 'sessions'), + hooks: join(root, 'hooks'), + skills: join(root, 'skills'), + guide: join(root, 'Recall_GUIDE.md'), + settings: join(root, 'settings.json'), + legacySettings: join(home, '.claude.json'), + }; +} + +export function claudeMcpConfigTargets(home: string): McpConfigTarget[] { + const paths = claudePaths(home); + const envPath = ['mcpServers', 'recall-memory', 'env']; + return [ + { host: 'claude', path: paths.legacySettings, envPath, format: 'json' }, + { host: 'claude', path: paths.settings, envPath, format: 'json' }, + ]; +} + +/** Locate Claude Code without embedding that native-host rule in generic diagnostics. */ +export function findClaudeCli(home: string, pathEnv: string | undefined = process.env.PATH): string | null { + const candidates = [ + '/usr/local/bin/claude', + '/usr/bin/claude', + join(home, '.npm-global', 'bin', 'claude'), + join(home, '.local', 'bin', 'claude'), + join(home, '.bun', 'bin', 'claude'), + ]; + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate; + } + try { + const resolved = execFileSync('which', ['claude'], { + encoding: 'utf-8', + timeout: 3000, + env: { ...process.env, PATH: pathEnv }, + }).trim(); + return resolved || null; + } catch { + return null; + } +} + +export interface ClaudeCliReadiness { + path: string | null; + warnings: string[]; +} + +export function inspectClaudeCli( + home: string, + env: NodeJS.ProcessEnv = process.env, +): ClaudeCliReadiness { + const warnings: string[] = []; + if (env.CLAUDECODE) warnings.push('CLAUDECODE env var set (will block extraction)'); + const path = findClaudeCli(home, env.PATH); + if (!path) warnings.push('Claude CLI not found in PATH (Claude lifecycle extraction will not work)'); + return { path, warnings }; +} + +export const claudeHost: NativeHostAdapter = { + id: 'claude', + displayName: 'Claude Code', + mcpConfigTargets: claudeMcpConfigTargets, +}; diff --git a/src/hosts/index.ts b/src/hosts/index.ts new file mode 100644 index 0000000..0816342 --- /dev/null +++ b/src/hosts/index.ts @@ -0,0 +1,9 @@ +import { claudeHost } from './claude.js'; +import { openCodeHost } from './opencode.js'; +import { piHost } from './pi.js'; + +export type { McpConfigTarget, NativeHostAdapter, NativeHostId } from './types.js'; +export { claudeHost, openCodeHost, piHost }; + +/** Native config adapters with a currently verified JSON ownership contract. */ +export const configurableHosts = [claudeHost, openCodeHost, piHost] as const; diff --git a/src/hosts/markdown-session-source.ts b/src/hosts/markdown-session-source.ts new file mode 100644 index 0000000..7087472 --- /dev/null +++ b/src/hosts/markdown-session-source.ts @@ -0,0 +1,66 @@ +import { existsSync, readFileSync, readdirSync, statSync } from 'fs'; +import { basename, join } from 'path'; +import type { Message } from '../types/index.js'; + +export function findLatestMarkdownDrop(dropDir: string): string | null { + if (!existsSync(dropDir)) return null; + let latest: string | null = null; + let latestTime = 0; + for (const file of readdirSync(dropDir)) { + if (!file.endsWith('.md') || file.startsWith('.')) continue; + const full = join(dropDir, file); + const modified = statSync(full).mtimeMs; + if (modified > latestTime) { + latestTime = modified; + latest = full; + } + } + return latest; +} + +/** Parse the explicit markdown-drop contract used by OpenCode and Pi adapters. */ +export function parseMarkdownDrop(filePath: string): { sessionId: string; messages: Omit<Message, 'id'>[] } | null { + const content = readFileSync(filePath, 'utf-8'); + if (!content.trim()) return null; + + const sessionId = basename(filePath, '.md'); + const messages: Omit<Message, 'id'>[] = []; + const rolePattern = /\[(USER|ASSISTANT)(?:\s+[\d:]+)?\]:\s*/gi; + const parts: Array<{ role: 'user' | 'assistant'; markerIdx: number; startIdx: number }> = []; + let match: RegExpExecArray | null; + while ((match = rolePattern.exec(content)) !== null) { + parts.push({ + role: match[1].toLowerCase() as 'user' | 'assistant', + markerIdx: match.index, + startIdx: match.index + match[0].length, + }); + } + if (parts.length === 0) { + const headingPattern = /^##?\s*(User|Assistant|Human)\s*$/gim; + while ((match = headingPattern.exec(content)) !== null) { + const rawRole = match[1].toLowerCase(); + parts.push({ + role: (rawRole === 'human' ? 'user' : rawRole) as 'user' | 'assistant', + markerIdx: match.index, + startIdx: match.index + match[0].length, + }); + } + } + if (parts.length === 0) return null; + + const now = new Date().toISOString(); + for (let index = 0; index < parts.length; index++) { + const start = parts[index].startIdx; + const end = index + 1 < parts.length ? parts[index + 1].markerIdx : content.length; + const text = content.slice(start, end > start ? end : content.length).trim(); + if (text.length > 10) { + messages.push({ + session_id: sessionId, + timestamp: now, + role: parts[index].role, + content: text, + }); + } + } + return messages.length > 0 ? { sessionId, messages } : null; +} diff --git a/src/hosts/opencode-session-source.ts b/src/hosts/opencode-session-source.ts new file mode 100644 index 0000000..ec11107 --- /dev/null +++ b/src/hosts/opencode-session-source.ts @@ -0,0 +1,20 @@ +import { join } from 'path'; +import { getRecallHome } from '../lib/runtime-paths.js'; +import { findLatestMarkdownDrop, parseMarkdownDrop } from './markdown-session-source.js'; +import type { SessionSourceAdapter } from './session-source.js'; + +export const openCodeSessionSource: SessionSourceAdapter = { + id: 'opencode', + discover() { + const filePath = findLatestMarkdownDrop(join(getRecallHome(), 'MEMORY', 'opencode-sessions')); + if (!filePath) return null; + const parsed = parseMarkdownDrop(filePath); + return parsed && parsed.messages.length > 0 ? { + source: 'opencode', + sessionId: parsed.sessionId, + project: 'opencode', + messages: parsed.messages.map(message => ({ ...message, project: 'opencode' })), + filePath, + } : null; + }, +}; diff --git a/src/hosts/opencode.ts b/src/hosts/opencode.ts new file mode 100644 index 0000000..2e87ec5 --- /dev/null +++ b/src/hosts/opencode.ts @@ -0,0 +1,31 @@ +import { join } from 'path'; +import type { NativeHostAdapter } from './types.js'; + +export interface OpenCodePaths { + root: string; + guide: string; + settings: string; +} + +export function openCodePaths(home: string): OpenCodePaths { + const root = join(home, '.config', 'opencode'); + return { + root, + guide: join(root, 'Recall_GUIDE.md'), + settings: join(root, 'opencode.json'), + }; +} + +export const openCodeHost: NativeHostAdapter = { + id: 'opencode', + displayName: 'OpenCode', + mcpConfigTargets(home) { + const paths = openCodePaths(home); + return [{ + host: 'opencode', + path: paths.settings, + envPath: ['mcp', 'recall-memory', 'environment'], + format: 'jsonc', + }]; + }, +}; diff --git a/src/hosts/pi-session-source.ts b/src/hosts/pi-session-source.ts new file mode 100644 index 0000000..c8e6b2d --- /dev/null +++ b/src/hosts/pi-session-source.ts @@ -0,0 +1,20 @@ +import { join } from 'path'; +import { getRecallHome } from '../lib/runtime-paths.js'; +import { findLatestMarkdownDrop, parseMarkdownDrop } from './markdown-session-source.js'; +import type { SessionSourceAdapter } from './session-source.js'; + +export const piSessionSource: SessionSourceAdapter = { + id: 'pi', + discover() { + const filePath = findLatestMarkdownDrop(join(getRecallHome(), 'MEMORY', 'pi-sessions')); + if (!filePath) return null; + const parsed = parseMarkdownDrop(filePath); + return parsed && parsed.messages.length > 0 ? { + source: 'pi', + sessionId: parsed.sessionId, + project: 'pi', + messages: parsed.messages.map(message => ({ ...message, project: 'pi' })), + filePath, + } : null; + }, +}; diff --git a/src/hosts/pi.ts b/src/hosts/pi.ts new file mode 100644 index 0000000..e1ecb2c --- /dev/null +++ b/src/hosts/pi.ts @@ -0,0 +1,31 @@ +import { join } from 'path'; +import type { NativeHostAdapter } from './types.js'; + +export interface PiPaths { + root: string; + guide: string; + mcpSettings: string; +} + +export function piPaths(home: string): PiPaths { + const root = join(home, '.pi', 'agent'); + return { + root, + guide: join(root, 'Recall_GUIDE.md'), + mcpSettings: join(root, 'mcp.json'), + }; +} + +export const piHost: NativeHostAdapter = { + id: 'pi', + displayName: 'Pi', + mcpConfigTargets(home) { + const paths = piPaths(home); + return [{ + host: 'pi', + path: paths.mcpSettings, + envPath: ['mcpServers', 'recall-memory', 'environment'], + format: 'json', + }]; + }, +}; diff --git a/src/hosts/session-source.ts b/src/hosts/session-source.ts new file mode 100644 index 0000000..924df25 --- /dev/null +++ b/src/hosts/session-source.ts @@ -0,0 +1,16 @@ +import type { Message } from '../types/index.js'; + +export type SessionSource = 'claude' | 'opencode' | 'pi' | 'codex' | 'mcp'; + +export interface ParsedSession { + source: SessionSource; + sessionId: string; + project: string; + messages: Omit<Message, 'id'>[]; + filePath: string; +} + +export interface SessionSourceAdapter { + id: SessionSource; + discover(): ParsedSession | null; +} diff --git a/src/hosts/session-sources.ts b/src/hosts/session-sources.ts new file mode 100644 index 0000000..243e2a6 --- /dev/null +++ b/src/hosts/session-sources.ts @@ -0,0 +1,20 @@ +import { claudeSessionSource } from './claude-session-source.js'; +import { openCodeSessionSource } from './opencode-session-source.js'; +import { piSessionSource } from './pi-session-source.js'; +import type { ParsedSession, SessionSourceAdapter } from './session-source.js'; + +export const nativeSessionSources: readonly SessionSourceAdapter[] = [ + claudeSessionSource, + openCodeSessionSource, + piSessionSource, +]; + +export function discoverCurrentSession( + adapters: readonly SessionSourceAdapter[] = nativeSessionSources, +): ParsedSession | null { + for (const adapter of adapters) { + const session = adapter.discover(); + if (session) return session; + } + return null; +} diff --git a/src/hosts/types.ts b/src/hosts/types.ts new file mode 100644 index 0000000..a51fdf8 --- /dev/null +++ b/src/hosts/types.ts @@ -0,0 +1,16 @@ +export type NativeHostId = 'claude' | 'opencode' | 'pi'; + +export interface McpConfigTarget { + host: NativeHostId; + path: string; + /** Path from the JSON root to the recall-memory environment object. */ + envPath: string[]; + /** OpenCode permits comments; the other current targets are strict JSON. */ + format: 'json' | 'jsonc'; +} + +export interface NativeHostAdapter { + id: NativeHostId; + displayName: string; + mcpConfigTargets(home: string): McpConfigTarget[]; +} diff --git a/src/lib/extraction.ts b/src/lib/extraction.ts index f42e804..c14f4c1 100644 --- a/src/lib/extraction.ts +++ b/src/lib/extraction.ts @@ -1,9 +1,9 @@ // Shared extraction helpers used by CLI commands. -import { execSync } from 'child_process'; import type { Message } from '../types/index.js'; +import { extractWisdomWithFabric, MAX_FABRIC_INPUT_BYTES } from '../providers/fabric.js'; -export const MAX_FABRIC_INPUT_BYTES = 50 * 1024 * 1024; +export { MAX_FABRIC_INPUT_BYTES }; /** * Generate a basic extraction-shaped summary when Haiku/Fabric is unavailable. @@ -45,16 +45,5 @@ export function formatMessagesForExtraction(messages: Array<Pick<Message, 'role' * Run Fabric's extract_wisdom pattern with the configured Haiku model. */ export function runFabricExtract(content: string): string { - const inputBytes = Buffer.byteLength(content, 'utf-8'); - if (inputBytes > MAX_FABRIC_INPUT_BYTES) { - throw new Error(`Input too large for Fabric (${(inputBytes / 1024 / 1024).toFixed(1)}MB > 50MB limit). Use --limit to reduce message count.`); - } - - const result = execSync('fabric --pattern extract_wisdom --stream -m claude-haiku-4-5', { - input: content, - encoding: 'utf-8', - maxBuffer: MAX_FABRIC_INPUT_BYTES, - timeout: 600000 - }); - return result.trim(); + return extractWisdomWithFabric(content); } diff --git a/src/lib/import.ts b/src/lib/import.ts index dd7c930..119da49 100644 --- a/src/lib/import.ts +++ b/src/lib/import.ts @@ -1,14 +1,9 @@ -// Import conversations from Claude Code JSONL files +// Claude Code session import orchestration. +// Native path discovery and transcript parsing live in the Claude host adapter. -import { readdirSync, readFileSync, existsSync } from 'fs'; -import { join, basename, dirname } from 'path'; -import { homedir } from 'os'; -import { getDb } from '../db/connection.js'; +import { basename } from 'path'; import { createSession, sessionExists, addMessagesBatch } from './memory.js'; -import { extractProjectFromPath } from './project.js'; -import type { ClaudeSessionLine, Message } from '../types/index.js'; - -const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects'); +import { listClaudeSessionFiles, parseClaudeSessionFile } from '../hosts/claude-session-source.js'; interface ImportResult { sessionsImported: number; @@ -17,152 +12,32 @@ interface ImportResult { errors: string[]; } -/** - * Find all session JSONL files (excluding subagent files) - */ export function findSessionFiles(): string[] { - if (!existsSync(CLAUDE_PROJECTS_DIR)) { - return []; - } - - const files: string[] = []; - - const projectDirs = readdirSync(CLAUDE_PROJECTS_DIR, { withFileTypes: true }) - .filter(d => d.isDirectory()) - .map(d => d.name); - - for (const projectDir of projectDirs) { - const projectPath = join(CLAUDE_PROJECTS_DIR, projectDir); - - const jsonlFiles = readdirSync(projectPath, { withFileTypes: true }) - .filter(f => f.isFile() && f.name.endsWith('.jsonl')) - .map(f => join(projectPath, f.name)); - - files.push(...jsonlFiles); - } - - return files; + return listClaudeSessionFiles(); } -/** - * Parse a single JSONL session file - */ -function parseSessionFile(filePath: string): { sessionId: string; project: string; messages: Omit<Message, 'id'>[] } | null { - const content = readFileSync(filePath, 'utf-8'); - const lines = content.split('\n').filter(l => l.trim()); - - if (lines.length === 0) return null; - - const messages: Omit<Message, 'id'>[] = []; - let sessionId: string | null = null; - let project: string | null = null; - let cwd: string | null = null; - - for (const line of lines) { - try { - const parsed = JSON.parse(line) as ClaudeSessionLine; - - // Skip non-message types - if (parsed.type !== 'user' && parsed.type !== 'assistant') { - continue; - } - - // Extract session metadata from first valid message - if (!sessionId && parsed.sessionId) { - sessionId = parsed.sessionId; - } - if (!cwd && parsed.cwd) { - cwd = parsed.cwd; - } - - // Extract message content - if (parsed.message?.content) { - let content: string; - - if (typeof parsed.message.content === 'string') { - content = parsed.message.content; - } else if (Array.isArray(parsed.message.content)) { - // Handle array content (with text blocks) - content = parsed.message.content - .filter(block => block.type === 'text' && block.text) - .map(block => block.text) - .join('\n'); - } else { - continue; - } - - if (content.trim()) { - messages.push({ - session_id: parsed.sessionId || sessionId || 'unknown', - timestamp: parsed.timestamp || new Date().toISOString(), - role: parsed.type as 'user' | 'assistant', - content: content, - project: project || undefined - }); - } - } - } catch { - // Skip malformed lines - continue; - } - } - - // Derive project from path - const projectDir = basename(dirname(filePath)); - project = extractProjectFromPath(projectDir); - - // Update project in all messages - for (const msg of messages) { - msg.project = project; - } - - if (!sessionId) { - // Use filename as session ID - sessionId = basename(filePath, '.jsonl'); - } - - return { - sessionId, - project, - messages - }; -} - -/** - * Import all sessions from Claude Code projects directory - */ export function importAllSessions(options?: { dryRun?: boolean; verbose?: boolean }): ImportResult { const result: ImportResult = { sessionsImported: 0, sessionsSkipped: 0, messagesImported: 0, - errors: [] + errors: [], }; - const files = findSessionFiles(); - - if (options?.verbose) { - console.log(`Found ${files.length} session files`); - } + if (options?.verbose) console.log(`Found ${files.length} session files`); for (const file of files) { try { - const parsed = parseSessionFile(file); - + const parsed = parseClaudeSessionFile(file); if (!parsed || parsed.messages.length === 0) { result.sessionsSkipped++; continue; } - - // Check if session already exists if (sessionExists(parsed.sessionId)) { - if (options?.verbose) { - console.log(`Skipping existing session: ${parsed.sessionId}`); - } + if (options?.verbose) console.log(`Skipping existing session: ${parsed.sessionId}`); result.sessionsSkipped++; continue; } - if (options?.dryRun) { console.log(`[DRY RUN] Would import session ${parsed.sessionId} (${parsed.messages.length} messages)`); result.sessionsImported++; @@ -170,61 +45,38 @@ export function importAllSessions(options?: { dryRun?: boolean; verbose?: boolea continue; } - // Get timestamps from messages - const timestamps = parsed.messages.map(m => m.timestamp).sort(); - const startedAt = timestamps[0]; - const endedAt = timestamps[timestamps.length - 1]; - - // Create session + const timestamps = parsed.messages.map(message => message.timestamp).sort(); createSession({ session_id: parsed.sessionId, - started_at: startedAt, - ended_at: endedAt, + started_at: timestamps[0], + ended_at: timestamps[timestamps.length - 1], project: parsed.project, - summary: `Imported from ${basename(file)}` + summary: `Imported from ${basename(file)}`, + source: 'claude', }); - - // Insert messages in batch — raw conversation capture is verbatim (ADR-0001) - const count = addMessagesBatch(parsed.messages.map(m => ({ ...m, provenance: 'verbatim' as const }))); - + const count = addMessagesBatch(parsed.messages.map(message => ({ + ...message, + provenance: 'verbatim' as const, + }))); result.sessionsImported++; result.messagesImported += count; - - if (options?.verbose) { - console.log(`Imported session ${parsed.sessionId}: ${count} messages`); - } - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - result.errors.push(`${file}: ${error}`); + if (options?.verbose) console.log(`Imported session ${parsed.sessionId}: ${count} messages`); + } catch (error) { + result.errors.push(`${file}: ${error instanceof Error ? error.message : String(error)}`); } } - return result; } -/** - * Get import preview without making changes - */ export function previewImport(): { total: number; existing: number; new: number; files: string[] } { const files = findSessionFiles(); let existing = 0; let newSessions = 0; - for (const file of files) { - const parsed = parseSessionFile(file); + const parsed = parseClaudeSessionFile(file); if (!parsed) continue; - - if (sessionExists(parsed.sessionId)) { - existing++; - } else { - newSessions++; - } + if (sessionExists(parsed.sessionId)) existing++; + else newSessions++; } - - return { - total: files.length, - existing, - new: newSessions, - files - }; + return { total: files.length, existing, new: newSessions, files }; } diff --git a/src/lib/memory.ts b/src/lib/memory.ts index 49c9314..85168d5 100644 --- a/src/lib/memory.ts +++ b/src/lib/memory.ts @@ -50,7 +50,7 @@ export function createSession(session: Omit<Session, 'id'>): number { $cwd: session.cwd || null, $git_branch: session.git_branch || null, $model: session.model || null, - $source: session.source || 'claude-code' + $source: session.source || 'unknown' }); return result.lastInsertRowid as number; } diff --git a/src/lib/project.ts b/src/lib/project.ts index d9e0609..9cbbc30 100644 --- a/src/lib/project.ts +++ b/src/lib/project.ts @@ -66,20 +66,3 @@ export function detectProject(cwd?: string): string | undefined { // Fall back to PWD basename return basename(dir); } - -/** - * Extract project name from an encoded path like "-home-user-Projects-my-app" - */ -export function extractProjectFromPath(path: string): string { - // Handle Claude Code's path format: -home-pi-Projects-foo-bar - const parts = path.split('-'); - - // Find "Projects" and take everything after - const projectsIdx = parts.findIndex(p => p.toLowerCase() === 'projects'); - if (projectsIdx !== -1 && projectsIdx < parts.length - 1) { - return parts.slice(projectsIdx + 1).join('-'); - } - - // Otherwise just use the last part - return parts[parts.length - 1] || path; -} diff --git a/src/lib/runtime-paths.ts b/src/lib/runtime-paths.ts new file mode 100644 index 0000000..e1c5697 --- /dev/null +++ b/src/lib/runtime-paths.ts @@ -0,0 +1,11 @@ +import { homedir } from 'os'; +import { join } from 'path'; + +/** Recall-owned mutable state. Host adapters must not redirect generic runtime state. */ +export function getRecallHome(env: NodeJS.ProcessEnv = process.env): string { + return env.RECALL_HOME || join(homedir(), '.agents', 'Recall'); +} + +export function getRecallLogDir(env: NodeJS.ProcessEnv = process.env): string { + return join(getRecallHome(env), 'logs'); +} diff --git a/src/mcp-server.ts b/src/mcp-server.ts index cefcfe4..f63051f 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -1,19 +1,20 @@ #!/usr/bin/env node // RECALL - MCP Server -// Exposes memory as first-class tools for Claude Code +// Exposes memory as first-class tools to any MCP-compatible host. import { appendFileSync, existsSync as fsExistsSync, mkdirSync } from "fs"; +import { randomUUID } from "crypto"; import { join } from "path"; -import { homedir } from "os"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { VERSION } from "./version.js"; +import { getRecallLogDir } from "./lib/runtime-paths.js"; /** * Memory usage logging for metrics and enforcement tracking * Logs: timestamp, tool, query, results_count, project */ -const LOG_DIR = join(homedir(), ".claude", "logs"); +const LOG_DIR = getRecallLogDir(); const MEMORY_LOG = join(LOG_DIR, "memory-usage.jsonl"); function logMemoryUsage( @@ -896,7 +897,7 @@ server.tool( // Tool: memory_dump - Dump current session to SQLite server.tool( "memory_dump", - "Dump the current conversation session into SQLite for immediate searchability. Works across Claude Code, OpenCode, and Pi. Use when the user says /dump or you need to persist the current session mid-conversation. Messages are immediately searchable via memory_search after dumping.", + "Dump a conversation session into SQLite for immediate searchability. Pass messages for host-portable capture. If messages are omitted, Recall attempts legacy native-session discovery for Claude Code, OpenCode, or Pi; MCP does not provide lifecycle auto-capture by itself.", { title: z .string() @@ -906,6 +907,23 @@ server.tool( .string() .optional() .describe("Override project name"), + session_id: z + .string() + .optional() + .describe("Stable caller session ID. Generated when messages are supplied and this is omitted."), + source: z + .enum(["claude", "opencode", "pi", "codex", "mcp"]) + .default("mcp") + .describe("Host/source label for caller-supplied messages."), + messages: z + .array(z.object({ + role: z.enum(["user", "assistant", "system"]), + content: z.string().min(1), + timestamp: z.string().optional(), + })) + .min(1) + .optional() + .describe("Explicit visible conversation messages. Required for portable dump on hosts without a supported transcript adapter."), skip_fabric: z .boolean() .default(true) @@ -913,13 +931,29 @@ server.tool( "Skip Fabric extract_wisdom (faster, uses basic summary). Default: true for speed.", ), }, - async ({ title, project, skip_fabric }) => { + async ({ title, project, session_id, source, messages, skip_fabric }) => { try { const { coreDump } = await import("./commands/dump.js"); + const now = new Date().toISOString(); + const sessionId = session_id || `mcp-${randomUUID()}`; + const session = messages ? { + source, + sessionId, + project: project || source, + filePath: `mcp://${source}/${sessionId}`, + messages: messages.map(message => ({ + session_id: sessionId, + timestamp: message.timestamp || now, + role: message.role, + content: message.content, + project: project || source, + })), + } : undefined; const result = await coreDump(title, { project, skipFabric: skip_fabric, + session, }); // Log memory usage diff --git a/src/providers/claude-cli.ts b/src/providers/claude-cli.ts new file mode 100644 index 0000000..833d91a --- /dev/null +++ b/src/providers/claude-cli.ts @@ -0,0 +1,22 @@ +import { execFileSync } from 'child_process'; +import { homedir } from 'os'; +import { findClaudeCli } from '../hosts/claude.js'; +import type { TextGenerationProvider } from './text-generation.js'; + +export const claudeCliTextGenerationProvider: TextGenerationProvider = { + id: 'claude-cli', + generate(prompt) { + const executable = findClaudeCli(homedir()); + if (!executable) return null; + try { + return execFileSync(executable, ['-p', '--model', 'claude-haiku-4-5-20251001'], { + input: prompt, + encoding: 'utf-8', + timeout: 30000, + env: { ...process.env, CLAUDECODE: '' }, + }).trim() || null; + } catch { + return null; + } + }, +}; diff --git a/src/providers/fabric.ts b/src/providers/fabric.ts new file mode 100644 index 0000000..e5fa7aa --- /dev/null +++ b/src/providers/fabric.ts @@ -0,0 +1,25 @@ +import { execFileSync } from 'child_process'; + +export const MAX_FABRIC_INPUT_BYTES = 50 * 1024 * 1024; + +/** Fabric owns its command, provider/model selection, and authentication. */ +export function extractWisdomWithFabric( + content: string, + model: string = process.env.RECALL_FABRIC_MODEL || 'claude-haiku-4-5', +): string { + const inputBytes = Buffer.byteLength(content, 'utf-8'); + if (inputBytes > MAX_FABRIC_INPUT_BYTES) { + throw new Error(`Input too large for Fabric (${(inputBytes / 1024 / 1024).toFixed(1)}MB > 50MB limit). Use --limit to reduce message count.`); + } + + try { + return execFileSync('fabric', ['--pattern', 'extract_wisdom', '--stream', '-m', model], { + input: content, + encoding: 'utf-8', + maxBuffer: MAX_FABRIC_INPUT_BYTES, + timeout: 600000, + }).trim(); + } catch (error) { + throw new Error(`Fabric extract_wisdom failed: ${error instanceof Error ? error.message : String(error)}`); + } +} diff --git a/src/providers/text-generation.ts b/src/providers/text-generation.ts new file mode 100644 index 0000000..ab7798e --- /dev/null +++ b/src/providers/text-generation.ts @@ -0,0 +1,4 @@ +export interface TextGenerationProvider { + id: string; + generate(prompt: string): string | null; +} diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 7c37565..c2c431a 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -8,7 +8,7 @@ Automated coverage for the CLI, MCP server, hooks, data layer, libraries, instal ## Ownership -`tests/**` mirroring the source areas — `commands/`, `db/`, `hooks/`, `lib/`, `integration/`, `benchmarks/`, `install/` — plus `fixtures/` (static fixtures, incl. extraction samples) and the shared harness `helpers/setup.ts`. Also `mcp-server.test.ts`, `version.test.ts`. +`tests/**` mirroring the source areas — `commands/`, `db/`, `hooks/`, `hosts/`, `lib/`, `plugins/`, `integration/`, `benchmarks/`, `install/` — plus `fixtures/` (static fixtures, incl. extraction samples) and the shared harness `helpers/setup.ts`. Also `mcp-server.test.ts`, `version.test.ts`. ## Local Contracts @@ -16,10 +16,12 @@ Automated coverage for the CLI, MCP server, hooks, data layer, libraries, instal - Test files are `tests/**/*.test.ts`. - Reuse `fixtures/` rather than inlining large fixtures; use `helpers/setup.ts` for harness setup. - `install/` tests exercise `install.sh` / `update.sh` / `uninstall.sh` — keep them current when those scripts change. +- Any end-to-end test that can open SQLite must set its own disposable `RECALL_DB_PATH` and `RECALL_HOME`, state those paths before writes, and prove the production database was not changed. ## Work Guidance - Mirror the source layout: a test for `src/commands/foo.ts` goes in `tests/commands/`; a hook test in `tests/hooks/`. +- `scripts/e2e-codex-plugin.ts` owns the isolated current-CLI verification for the native Codex marketplace, plugin install, and all nine MCP tools. ## Verification diff --git a/tests/commands/doctor-install-sentinel.test.ts b/tests/commands/doctor-install-sentinel.test.ts index 9b636a5..4b5591c 100644 --- a/tests/commands/doctor-install-sentinel.test.ts +++ b/tests/commands/doctor-install-sentinel.test.ts @@ -76,6 +76,7 @@ describe('recall doctor exit code with a stale marker present', () => { env: { ...process.env, HOME: home, + RECALL_HOME: join(home, '.agents', 'Recall'), RECALL_DB_PATH: join(home, '.agents', 'Recall', 'recall.db'), }, }); diff --git a/tests/commands/doctor-mcp-env.test.ts b/tests/commands/doctor-mcp-env.test.ts index 81379a8..c73db42 100644 --- a/tests/commands/doctor-mcp-env.test.ts +++ b/tests/commands/doctor-mcp-env.test.ts @@ -15,6 +15,7 @@ import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, import { homedir, tmpdir } from 'os'; import { join } from 'path'; import { probeMcpEnv, resolveProbeResult } from '../../src/commands/doctor'; +import type { McpConfigTarget } from '../../src/hosts/types'; const RESOLVED = '/custom/recall/recall.db'; @@ -52,8 +53,17 @@ function readEntry(): Record<string, unknown> { return cfg.mcpServers['recall-memory']; } +function target(path: string): McpConfigTarget { + return { + host: 'claude', + path, + envPath: ['mcpServers', 'recall-memory', 'env'], + format: 'json', + }; +} + function probe() { - return probeMcpEnv({ configPaths: [configPath], resolvedDbPath: RESOLVED }); + return probeMcpEnv({ targets: [target(configPath)], resolvedDbPath: RESOLVED }); } // repair() backs files up under the real home (Bun's homedir() ignores $HOME), @@ -152,7 +162,7 @@ describe('probeMcpEnv', () => { test('no config files exist → INFO (no crash)', () => { const { result, repair } = probeMcpEnv({ - configPaths: [join(tempDir, 'nope.json'), join(tempDir, 'also-nope.json')], + targets: [target(join(tempDir, 'nope.json')), target(join(tempDir, 'also-nope.json'))], resolvedDbPath: RESOLVED, }); expect(result.status).toBe('INFO'); @@ -204,7 +214,7 @@ describe('probeMcpEnv', () => { chmodSync(roDir, 0o500); // creating the temp sibling inside this dir fails (EACCES) try { - const { result, repair } = probeMcpEnv({ configPaths: [okPath, roPath], resolvedDbPath: RESOLVED }); + const { result, repair } = probeMcpEnv({ targets: [target(okPath), target(roPath)], resolvedDbPath: RESOLVED }); expect(result.status).toBe('WARN'); const fixed = repair!(); // must not throw despite the read-only owner expect(fixed.status).toBe('FAIL'); @@ -248,7 +258,7 @@ describe('probeMcpEnv', () => { { mcpServers: { 'recall-memory': { command: 'bun', args: ['run', 'recall-mcp'], env: {} } } }, null, 2)); symlinkSync(realPath, linkPath); - const fixed = probeMcpEnv({ configPaths: [linkPath], resolvedDbPath: RESOLVED }).repair!(); + const fixed = probeMcpEnv({ targets: [target(linkPath)], resolvedDbPath: RESOLVED }).repair!(); expect(fixed.status).toBe('PASS'); expect(lstatSync(linkPath).isSymbolicLink()).toBe(true); // link not clobbered into a regular file expect(JSON.parse(readFileSync(realPath, 'utf-8')).mcpServers['recall-memory'].env.RECALL_DB_PATH).toBe(RESOLVED); @@ -263,7 +273,7 @@ describe('probeMcpEnv', () => { { mcpServers: { 'recall-memory': { command: 'bun', args: ['run', 'recall-mcp'], env: {} } } }, null, 2)); writeFileSync(badPath, '{ not valid json '); - const { result } = probeMcpEnv({ configPaths: [validPath, badPath], resolvedDbPath: RESOLVED }); + const { result } = probeMcpEnv({ targets: [target(validPath), target(badPath)], resolvedDbPath: RESOLVED }); expect(result.status).toBe('WARN'); // stale valid owner expect(result.message).toContain(badPath); // malformed sibling surfaced, not dropped }); diff --git a/tests/commands/dump-portable.test.ts b/tests/commands/dump-portable.test.ts new file mode 100644 index 0000000..162178d --- /dev/null +++ b/tests/commands/dump-portable.test.ts @@ -0,0 +1,48 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { coreDump } from '../../src/commands/dump'; +import { getDb } from '../../src/db/connection'; +import { setupTestDb, teardownTestDb } from '../helpers/setup'; + +beforeAll(() => setupTestDb()); +afterAll(() => teardownTestDb()); + +describe('portable explicit session dump', () => { + test('stores caller-supplied Codex messages without transcript discovery', async () => { + const sessionId = 'codex-explicit-session'; + const result = await coreDump('Codex native plugin', { + skipFabric: true, + skipEmbed: true, + session: { + source: 'codex', + sessionId, + project: 'recall-test', + filePath: `mcp://codex/${sessionId}`, + messages: [ + { + session_id: sessionId, + timestamp: '2026-07-22T12:00:00.000Z', + role: 'user', + content: 'Package Recall on the native Codex plugin primitive.', + project: 'recall-test', + }, + { + session_id: sessionId, + timestamp: '2026-07-22T12:00:01.000Z', + role: 'assistant', + content: 'Use MCP for portable memory operations and keep lifecycle capture separate.', + project: 'recall-test', + }, + ], + }, + }); + + expect(result.success).toBe(true); + expect(result.source).toBe('codex'); + expect(result.messageCount).toBe(2); + const session = getDb().prepare('SELECT source, project FROM sessions WHERE session_id = ?').get(sessionId) as { + source: string; + project: string; + }; + expect(session).toEqual({ source: 'codex', project: 'recall-test' }); + }); +}); diff --git a/tests/commands/export.test.ts b/tests/commands/export.test.ts index 799c1f7..7e337ba 100644 --- a/tests/commands/export.test.ts +++ b/tests/commands/export.test.ts @@ -180,9 +180,11 @@ describe('SQL dump', () => { runExport({ format: 'sql', output: file, now: NOW }); const sql = readFileSync(file, 'utf-8'); - // The dump must never contain the display literal 'unknown' — the schema - // CHECK constraint would reject it on restore. NULL is the on-disk truth. - expect(sql).not.toContain("'unknown'"); + // Provenance-bearing INSERTs must never contain the display literal + // 'unknown' — their CHECK constraints reject it. A session source may + // legitimately be 'unknown' when no host adapter supplied one. + const provenanceInserts = sql.split('\n').filter(line => line.startsWith('INSERT') && line.includes('"provenance"')); + expect(provenanceInserts.every(line => !line.includes("'unknown'"))).toBe(true); const restored = new Database(':memory:'); try { diff --git a/tests/commands/path.test.ts b/tests/commands/path.test.ts index 00408db..8a6bb71 100644 --- a/tests/commands/path.test.ts +++ b/tests/commands/path.test.ts @@ -4,6 +4,8 @@ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; import { setupTestDb, teardownTestDb } from '../helpers/setup'; import { runPath } from '../../src/commands/path'; +import { homedir } from 'os'; +import { join } from 'path'; let dbPath: string; let originalLog: typeof console.log; @@ -42,7 +44,7 @@ describe('recall path', () => { symlinks: Array<{ name: string }>; }; expect(parsed.db.path).toBe(dbPath); - expect(parsed.install_root).toMatch(/\.agents\/Recall$/); + expect(parsed.install_root).toBe(process.env.RECALL_HOME || join(homedir(), '.agents', 'Recall')); expect(Array.isArray(parsed.symlinks)).toBe(true); expect(parsed.symlinks.length).toBeGreaterThan(0); }); diff --git a/tests/hooks/RecallClearExtract.test.ts b/tests/hooks/RecallClearExtract.test.ts index e02a9d7..228c14e 100644 --- a/tests/hooks/RecallClearExtract.test.ts +++ b/tests/hooks/RecallClearExtract.test.ts @@ -20,7 +20,7 @@ import { import { tmpdir } from 'os'; import { join } from 'path'; import { spawnSync } from 'child_process'; -import { encodeProjectDir } from '../../hooks/lib/path-encoding'; +import { encodeProjectDir } from '../../hooks/lib/hosts/claude/path-encoding'; const REPO_ROOT = join(import.meta.dir, '..', '..'); const HOOK_SOURCE = join(REPO_ROOT, 'hooks', 'RecallClearExtract.ts'); diff --git a/tests/hooks/extract-model-provider.test.ts b/tests/hooks/extract-model-provider.test.ts new file mode 100644 index 0000000..f74c826 --- /dev/null +++ b/tests/hooks/extract-model-provider.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from 'bun:test'; +import { runExtractionCascade } from '../../hooks/lib/extract-model'; +import type { ExtractionProvider } from '../../hooks/lib/extraction-provider'; + +describe('host-neutral extraction cascade', () => { + test('tries injected providers in order without knowing their commands or auth', async () => { + const calls: string[] = []; + const providers: ExtractionProvider[] = [ + { id: 'first-host', extract: () => { calls.push('first-host'); return null; } }, + { id: 'second-host', extract: () => { calls.push('second-host'); return 'portable extraction'; } }, + { id: 'unused', extract: () => { calls.push('unused'); return 'wrong'; } }, + ]; + + expect(await runExtractionCascade('session text', providers)).toBe('portable extraction'); + expect(calls).toEqual(['first-host', 'second-host']); + }); +}); diff --git a/tests/hooks/insession.test.ts b/tests/hooks/insession.test.ts index 99a88e4..6175573 100644 --- a/tests/hooks/insession.test.ts +++ b/tests/hooks/insession.test.ts @@ -11,12 +11,12 @@ import { readInSessionConfig, shouldRun, sliceTranscript, - eventFromHookInput, decideInSession, runInSessionExtraction, sessionIdFromArgs, type InSessionConfig, } from '../../hooks/lib/insession'; +import { eventFromClaudeHookInput } from '../../hooks/lib/hosts/claude/lifecycle'; import { ensureSession, getSessionProgress, @@ -129,20 +129,20 @@ describe('shouldRun — cadence / floor / budget', () => { }); }); -// ─── eventFromHookInput ────────────────────────────────────────────────────── +// ─── eventFromClaudeHookInput ──────────────────────────────────────────────── -describe('eventFromHookInput', () => { +describe('eventFromClaudeHookInput', () => { test('maps the two registered events to the right counter', () => { // Mutation — swap the turn/tool mapping → RED. - expect(eventFromHookInput({ hook_event_name: 'UserPromptSubmit' })).toBe('turn'); - expect(eventFromHookInput({ hook_event_name: 'PostToolUse' })).toBe('tool'); + expect(eventFromClaudeHookInput({ hook_event_name: 'UserPromptSubmit' })).toBe('turn'); + expect(eventFromClaudeHookInput({ hook_event_name: 'PostToolUse' })).toBe('tool'); }); test('falls back to payload shape, and rejects unknown events', () => { - expect(eventFromHookInput({ prompt: 'hi' })).toBe('turn'); - expect(eventFromHookInput({ tool_name: 'Bash' })).toBe('tool'); - expect(eventFromHookInput({ hook_event_name: 'Stop' })).toBeNull(); - expect(eventFromHookInput({})).toBeNull(); + expect(eventFromClaudeHookInput({ prompt: 'hi' })).toBe('turn'); + expect(eventFromClaudeHookInput({ tool_name: 'Bash' })).toBe('tool'); + expect(eventFromClaudeHookInput({ hook_event_name: 'Stop' })).toBeNull(); + expect(eventFromClaudeHookInput({})).toBeNull(); }); }); diff --git a/tests/hooks/path-encoding.test.ts b/tests/hooks/path-encoding.test.ts index 55930a8..05979cf 100644 --- a/tests/hooks/path-encoding.test.ts +++ b/tests/hooks/path-encoding.test.ts @@ -4,7 +4,7 @@ // ".config/..."), and iCloud paths with spaces/tildes/unicode. import { describe, test, expect } from 'bun:test'; -import { encodeProjectDir } from '../../hooks/lib/path-encoding'; +import { encodeProjectDir } from '../../hooks/lib/hosts/claude/path-encoding'; describe('encodeProjectDir', () => { test('plain path', () => { diff --git a/tests/hooks/pick-previous-jsonl.test.ts b/tests/hooks/pick-previous-jsonl.test.ts index 7106422..d7769ed 100644 --- a/tests/hooks/pick-previous-jsonl.test.ts +++ b/tests/hooks/pick-previous-jsonl.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, utimesSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { pickPreviousJsonl, DEFAULT_MTIME_GUARD_SECONDS } from '../../hooks/lib/pick-previous-jsonl'; -import { encodeProjectDir } from '../../hooks/lib/path-encoding'; +import { encodeProjectDir } from '../../hooks/lib/hosts/claude/path-encoding'; let tmp: string; let projectsDir: string; diff --git a/tests/hosts/native-hosts.test.ts b/tests/hosts/native-hosts.test.ts new file mode 100644 index 0000000..8ddc5f6 --- /dev/null +++ b/tests/hosts/native-hosts.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from 'bun:test'; +import { mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { configurableHosts } from '../../src/hosts'; +import { claudePaths } from '../../src/hosts/claude'; +import { extractClaudeProjectFromPath } from '../../src/hosts/claude-session-source'; +import { parseMarkdownDrop } from '../../src/hosts/markdown-session-source'; +import { discoverCurrentSession } from '../../src/hosts/session-sources'; + +describe('native host boundaries', () => { + test('each verified host owns its MCP config shape', () => { + const targets = configurableHosts.flatMap(host => host.mcpConfigTargets('/test-home')); + expect(targets.map(target => target.host)).toEqual(['claude', 'claude', 'opencode', 'pi']); + expect(targets.find(target => target.host === 'opencode')?.envPath).toEqual([ + 'mcp', 'recall-memory', 'environment', + ]); + expect(claudePaths('/test-home').projects).toBe('/test-home/.claude/projects'); + }); + + test('session discovery is adapter ordered and stops at the first supported transcript', () => { + const calls: string[] = []; + const session = discoverCurrentSession([ + { id: 'claude', discover: () => { calls.push('claude'); return null; } }, + { + id: 'codex', + discover: () => { + calls.push('codex'); + return { + source: 'codex', + sessionId: 'fixture', + project: 'Recall', + filePath: 'fixture.jsonl', + messages: [], + }; + }, + }, + { id: 'pi', discover: () => { calls.push('pi'); return null; } }, + ]); + expect(session?.source).toBe('codex'); + expect(calls).toEqual(['claude', 'codex']); + }); + + test('Claude transcript paths are decoded inside the Claude adapter', () => { + expect(extractClaudeProjectFromPath('-home-user-Projects-my-app')).toBe('my-app'); + expect(extractClaudeProjectFromPath('-home-user-work-my-app')).toBe('app'); + expect(extractClaudeProjectFromPath('myproject')).toBe('myproject'); + }); + + test('markdown host adapters keep heading-delimited messages separate', () => { + const root = mkdtempSync(join(tmpdir(), 'recall-markdown-host-')); + const file = join(root, 'session.md'); + try { + writeFileSync(file, '# User\nFirst portable message.\n\n## Assistant\nSecond portable response.\n'); + const parsed = parseMarkdownDrop(file); + expect(parsed?.messages.map(message => message.content)).toEqual([ + 'First portable message.', + 'Second portable response.', + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/install/configure-hooks.test.ts b/tests/install/configure-hooks.test.ts index 3fcf2c1..6872995 100644 --- a/tests/install/configure-hooks.test.ts +++ b/tests/install/configure-hooks.test.ts @@ -62,6 +62,8 @@ describe('install.sh configure_hooks()', () => { // install.sh does `cp hooks/lib/*.ts` which would fail if the glob expands // to nothing. Mirror the real repo layout by dropping one stub shared lib. writeFileSync(join(fakeRepo, 'hooks', 'lib', 'stub.ts'), '// stub'); + mkdirSync(join(fakeRepo, 'hooks', 'lib', 'hosts', 'claude'), { recursive: true }); + writeFileSync(join(fakeRepo, 'hooks', 'lib', 'hosts', 'claude', 'provider.ts'), '// nested stub'); }); afterEach(() => { @@ -234,5 +236,10 @@ describe('install.sh configure_hooks()', () => { // Resolve tempRoot too: macOS canonicalizes /var -> /private/var in realpath. const resolved = realpathSync(join(claudeDir, 'hooks', 'RecallExtract.ts')); expect(resolved.startsWith(realpathSync(tempRoot))).toBe(true); + + const nestedCanonical = join(canonicalDir, 'lib', 'hosts', 'claude', 'provider.ts'); + expect(readFileSync(nestedCanonical, 'utf-8')).toBe('// nested stub'); + expect(realpathSync(join(claudeDir, 'hooks', 'lib', 'hosts', 'claude', 'provider.ts'))) + .toBe(realpathSync(nestedCanonical)); }); }); diff --git a/tests/lib/memory.test.ts b/tests/lib/memory.test.ts index d02a3b3..2f8f148 100644 --- a/tests/lib/memory.test.ts +++ b/tests/lib/memory.test.ts @@ -62,6 +62,7 @@ describe('Sessions', () => { expect(session!.session_id).toBe(sessionId); expect(session!.project).toBe('test-project'); expect(session!.model).toBe('claude-opus-4-6'); + expect(session!.source).toBe('unknown'); }); test('sessionExists returns true for existing session, false for non-existing', () => { diff --git a/tests/lib/project.test.ts b/tests/lib/project.test.ts index f4bb279..c9eec26 100644 --- a/tests/lib/project.test.ts +++ b/tests/lib/project.test.ts @@ -2,25 +2,7 @@ import { describe, test, expect } from 'bun:test'; import { mkdtempSync, rmdirSync } from 'fs'; import { tmpdir } from 'os'; import { basename } from 'path'; -import { detectProject, extractProjectFromPath } from '../../src/lib/project'; - -describe('extractProjectFromPath', () => { - test('returns last segment after "Projects" in encoded path', () => { - expect(extractProjectFromPath('-home-user-Projects-my-app')).toBe('my-app'); - }); - - test('returns hyphenated name after "Projects" when project name contains hyphens', () => { - expect(extractProjectFromPath('-home-user-Projects-foo-bar')).toBe('foo-bar'); - }); - - test('returns last part when no "Projects" segment exists', () => { - expect(extractProjectFromPath('-home-user-work-my-app')).toBe('app'); - }); - - test('returns the segment itself when given a single segment with no hyphens', () => { - expect(extractProjectFromPath('myproject')).toBe('myproject'); - }); -}); +import { detectProject } from '../../src/lib/project'; describe('detectProject', () => { test('returns a string when called with the current repo directory', () => { diff --git a/tests/opencode-integration.test.ts b/tests/opencode-integration.test.ts index 8b5d5b8..b30a025 100644 --- a/tests/opencode-integration.test.ts +++ b/tests/opencode-integration.test.ts @@ -72,7 +72,7 @@ describe('schema v3 migration', () => { // Insert with default source db.prepare('INSERT INTO sessions (session_id, project) VALUES (?, ?)').run('fresh-1', 'test'); const row = db.prepare('SELECT source FROM sessions WHERE session_id = ?').get('fresh-1') as any; - expect(row.source).toBe('claude-code'); + expect(row.source).toBe('unknown'); // Insert with opencode source db.prepare('INSERT INTO sessions (session_id, project, source) VALUES (?, ?, ?)').run('fresh-2', 'test', 'opencode'); diff --git a/tests/plugins/codex-plugin.test.ts b/tests/plugins/codex-plugin.test.ts new file mode 100644 index 0000000..5ea28ae --- /dev/null +++ b/tests/plugins/codex-plugin.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { generateCodexPluginSkills, repoRoot } from '../../scripts/build-codex-plugin'; + +let tempDir = ''; + +afterEach(() => { + if (tempDir && existsSync(tempDir)) rmSync(tempDir, { recursive: true, force: true }); +}); + +describe('Codex native plugin package', () => { + test('manifest, MCP config, and marketplace use one lowercase plugin identity', () => { + const manifest = JSON.parse(readFileSync(join(repoRoot, 'plugins/recall/.codex-plugin/plugin.json'), 'utf-8')); + const mcp = JSON.parse(readFileSync(join(repoRoot, 'plugins/recall/.mcp.json'), 'utf-8')); + const marketplace = JSON.parse(readFileSync(join(repoRoot, '.agents/plugins/marketplace.json'), 'utf-8')); + const packageJson = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf-8')); + + expect(manifest.name).toBe('recall'); + expect(manifest.version).toBe(packageJson.version); + expect(manifest.skills).toBe('./skills/'); + expect(manifest.mcpServers).toBe('./.mcp.json'); + expect(mcp.mcpServers['recall-memory']).toEqual({ command: 'recall-mcp', args: [] }); + expect(marketplace.name).toBe('recall-marketplace'); + expect(marketplace.plugins).toHaveLength(1); + expect(marketplace.plugins[0].name).toBe('recall'); + expect(marketplace.plugins[0].source.path).toBe('./plugins/recall'); + }); + + test('checked-in Codex skill adapters exactly match generated output', () => { + tempDir = mkdtempSync(join(tmpdir(), 'recall-codex-skills-')); + const names = generateCodexPluginSkills(tempDir); + expect(names).toHaveLength(9); + for (const name of names) { + const expected = readFileSync(join(tempDir, name, 'SKILL.md'), 'utf-8'); + const actual = readFileSync(join(repoRoot, 'plugins/recall/skills', name, 'SKILL.md'), 'utf-8'); + expect(actual).toBe(expected); + expect(actual).toContain('equivalent behavior is not assumed across hosts'); + } + expect(readFileSync(join(repoRoot, 'plugins/recall/skills/recall-dump/agents/openai.yaml'), 'utf-8')) + .toContain('allow_implicit_invocation: false'); + expect(readFileSync(join(repoRoot, 'plugins/recall/skills/recall-dump/SKILL.md'), 'utf-8')) + .not.toContain('disable-model-invocation'); + }); +}); diff --git a/uninstall.sh b/uninstall.sh index b1f7be8..57008b4 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -89,15 +89,30 @@ RECALL_HOOK_FILES=( ) RECALL_HOOK_LIB_FILES=( + "$CLAUDE_DIR/hooks/lib/consolidate-core.ts" + "$CLAUDE_DIR/hooks/lib/correction-detector.ts" + "$CLAUDE_DIR/hooks/lib/events.ts" + "$CLAUDE_DIR/hooks/lib/extract-core.ts" + "$CLAUDE_DIR/hooks/lib/extract-model.ts" "$CLAUDE_DIR/hooks/lib/extraction-lock.ts" "$CLAUDE_DIR/hooks/lib/extraction-migration.ts" "$CLAUDE_DIR/hooks/lib/extraction-quality.ts" + "$CLAUDE_DIR/hooks/lib/extraction-provider.ts" "$CLAUDE_DIR/hooks/lib/extraction-semaphore.ts" "$CLAUDE_DIR/hooks/lib/extraction-tracker.ts" + "$CLAUDE_DIR/hooks/lib/insession.ts" "$CLAUDE_DIR/hooks/lib/pid-utils.ts" "$CLAUDE_DIR/hooks/lib/db-path.ts" "$CLAUDE_DIR/hooks/lib/extraction-parsers.ts" "$CLAUDE_DIR/hooks/lib/sqlite-writers.ts" + "$CLAUDE_DIR/hooks/lib/session-progress.ts" + "$CLAUDE_DIR/hooks/lib/threat-detect.ts" + "$CLAUDE_DIR/hooks/lib/write-safety.ts" + "$CLAUDE_DIR/hooks/lib/hosts/index.ts" + "$CLAUDE_DIR/hooks/lib/hosts/claude/extraction-provider.ts" + "$CLAUDE_DIR/hooks/lib/hosts/claude/lifecycle.ts" + "$CLAUDE_DIR/hooks/lib/hosts/claude/path-encoding.ts" + # Legacy pre-host-boundary install path. "$CLAUDE_DIR/hooks/lib/path-encoding.ts" "$CLAUDE_DIR/hooks/lib/pick-previous-jsonl.ts" ) @@ -320,6 +335,11 @@ remove_hook_files() { done log_success "Removed $removed Recall hook file(s)/symlink(s) (surgical — non-Recall hooks/lib untouched)" + for f in "$CLAUDE_DIR/hooks/lib/hosts/claude" "$CLAUDE_DIR/hooks/lib/hosts"; do + if [[ -d "$f" ]] && [[ -z "$(ls -A "$f" 2>/dev/null)" ]]; then + run rmdir "$f" + fi + done if [[ -d "$CLAUDE_DIR/hooks/lib" ]] && [[ -z "$(ls -A "$CLAUDE_DIR/hooks/lib" 2>/dev/null)" ]]; then run rmdir "$CLAUDE_DIR/hooks/lib" fi