| name | recall |
| description | Pure read-only context resume โ summarizes unsummarized notes and surfaces last-session context. Use `/check-items` to triage open items. Use when: (1) /recall command, (2) /recall <project-name>, (3) resuming work on a project and wanting prior context. |
| metadata | {"version":"1.7.0"} |
Recall โ Load Project Context from Obsidian Vault
Searches the Obsidian vault for session notes and insights matching the current project, upgrades any unsummarized notes with AI summaries, and presents a concise context brief.
Tools needed: Bash, Grep, Read, Write
Procedure
Follow these steps exactly. Do not skip steps or reorder them.
Step 1 โ Load config and derive project
Run a single call that loads config and derives the project name (saves one parent round):
cd "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
python3 -c '
import sys, os
import glob; sys.path.insert(0, max(glob.glob(os.path.expanduser("~/.claude/plugins/cache/*/obsidian-brain/*/hooks")), default="hooks"))
from obsidian_utils import load_config
c = load_config()
if not c.get("vault_path"):
print("ERROR: vault_path not configured", file=sys.stderr)
sys.exit(1)
project = os.path.basename(os.getcwd()).lower().replace(" ", "-")
print("VAULT=" + c["vault_path"])
print("SESS=" + c.get("sessions_folder", "claude-sessions"))
print("INS=" + c.get("insights_folder", "claude-insights"))
print("PROJECT=" + project)
print("PIPELINE=" + c.get("summary_pipeline", "auto"))
'
Parse each output line as KEY=VALUE, splitting on the first =. Also capture PIPELINE (defaults to "auto").
If the user passed a project name argument (e.g. /recall my-project), override PROJECT with that value.
If the output is empty or errors, tell the user:
Config not found. Run /obsidian-setup first to configure your Obsidian vault.
Stop here if config is missing.
Create the task manifest for the full /recall flow:
TaskCreate: subject="Find unsummarized notes", activeForm="Searching for unsummarized notes"
TaskCreate: subject="Summarize unsummarized notes", activeForm="Summarizing notes"
TaskCreate: subject="Present read-only context brief", activeForm="Building and presenting context brief"
Track the returned task IDs โ you will update them as each step completes. Immediately set task #1 to in_progress via TaskUpdate.
Step 2 โ Summarize unsummarized notes (deferred summarization, truncation-aware)
โ ๏ธ THIS STEP IS MANDATORY. DO NOT SKIP IT.
If Grep finds any file matching both status: auto-logged AND project: $PROJECT, you must produce an upgraded summary for every such file before proceeding to Step 3. "Skipping to save context" or "the other session covers it" is a bug, not an optimization โ the user ran /recall specifically to get current-session context, and stale unsummarized notes are exactly what they asked you to fix.
Visibility requirement: Before Step 3, emit a one-line status: Step 2: processing N unsummarized note(s) for $PROJECT (or Step 2: no unsummarized notes for $PROJECT if the intersection is empty). This makes the decision auditable in the tool trace.
Find unsummarized notes for this project in a single Python call (replaces multiple Grep rounds):
cd "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
python3 -c '
import sys, os
import glob; sys.path.insert(0, max(glob.glob(os.path.expanduser("~/.claude/plugins/cache/*/obsidian-brain/*/hooks")), default="hooks"))
from obsidian_utils import find_unsummarized_notes
print(find_unsummarized_notes(sys.argv[1], sys.argv[2], sys.argv[3]))
' "$VAULT_PATH" "$SESSIONS_FOLDER" "$PROJECT"
Optional flags (#168 aged-note deferral):
- If the user passed
--include-aged, call with include_aged=True to include aged-out deferred notes:
find_unsummarized_notes(vault, sessions_folder, project, include_aged=True)
- If the user passed
--max-age-days N, call with aged_threshold_days=N to override the config threshold:
find_unsummarized_notes(vault, sessions_folder, project, aged_threshold_days=N)
Parse the JSON output: {"unsummarized": ["/path/to/note1.md", ...], "auto_fixed": N, "skipped_aged": [...]}.
The function handles project filtering, defense-in-depth (skips notes with real ## Summary but stale auto-logged status, auto-fixes them), and returns only genuinely unsummarized note paths.
If auto_fixed > 0, report: Auto-fixed N note(s) with stale status.
If skipped_aged is non-empty, report: Skipped <len> aged-out unreferenced note(s) (>90d, no inbound links, not pinned). Run \/recall --include-aged` to summarize them anyway.(Use the actual configured threshold fromaged_summarize_threshold_days`, default 90d.) This is the #168 deferral.
Store the length of unsummarized as N.
Update task #1 to completed. Update task #2 subject to Summarize N unsummarized note(s) and set to in_progress.
Path A: N=0 (no unsummarized notes)
Update task #2 subject to No unsummarized notes found and set to completed. Skip to Step 3.
Path B: N>=1 (parallel Haiku pipelines with sub-agent fallback)
Config escape hatch (#84): If PIPELINE=subagent, SKIP Phase 1 (the upgrade_batch Haiku claude -p pipeline) entirely and treat ALL N notes as the Phase 2 fallback list โ route every note directly to the sub-agent path in Phase 2. This is for machines where claude -p cold-start latency exceeds the timeout budget (the Haiku pipeline would waste ~2-4 min/note on doomed timeouts). When PIPELINE=auto (default), proceed with Phase 1 as written below.
Task management threshold: If N <= 5, create a sub-task per note. If N > 5, skip per-note sub-tasks โ use a single progress update on task #2 instead. This saves ~15-20s of parent round-trip overhead at large N.
Phase 1 โ Parallel Haiku upgrades (single batch call)
If N <= 5, create a sub-task for each note (subject "Upgrade: <basename>", activeForm "Upgrading <basename> via Haiku").
Single Bash tool call โ upgrade_batch() fans out N Haiku invocations in parallel inside one Python process via concurrent.futures.ThreadPoolExecutor. This sidesteps the Claude Code harness's serialization of parallel Bash tool calls for subprocess-blocking work:
cd "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
printf '%s' "$UNSUMMARIZED_PATHS_JSON" | python3 -c '
import sys, os, json
from collections import Counter
import glob; sys.path.insert(0, max(glob.glob(os.path.expanduser("~/.claude/plugins/cache/*/obsidian-brain/*/hooks")), default="hooks"))
from obsidian_utils import upgrade_batch
import time
paths = json.loads(sys.stdin.read())
_t0 = time.monotonic()
try:
results = upgrade_batch(paths, sys.argv[1], sys.argv[2], sys.argv[3])
# results is list[dict] with keys: path, status, elapsed_s, model_used, fallback_reason
wall_s = round(time.monotonic() - _t0, 1)
model_counts = Counter()
for r in results:
tag = r["model_used"] or "fallback"
model_counts[tag] += 1
DASH = chr(45)
breakdown = " / ".join(f"{n} {m.split(DASH)[0]}" for m, n in model_counts.most_common())
print(json.dumps(results))
print(f"[obsidian-brain] Step 2: upgraded {len(results)} note(s) in {wall_s}s wall ({breakdown})", file=sys.stderr)
except Exception as exc:
print(json.dumps({"error": f"{type(exc).__name__}: {exc}", "results": []}))
print(f"[obsidian-brain] upgrade_batch failed: {exc}", file=sys.stderr)
sys.exit(1)
' "$VAULT_PATH" "$SESSIONS_FOLDER" "$PROJECT"
Parse the returned JSON. If the top-level object contains an "error" key, upgrade_batch itself failed โ treat all notes as failed and fall back to the Phase 2 sub-agent path for each. Otherwise, parse the array normally. Each result dict has: path, status, elapsed_s, model_used (haiku-4.5 on success, None on failure; sonnet-4.6 / opus-* reserved for Phase 3 #165), fallback_reason (haiku_timeout | empty_output | haiku_subprocess_error | None). Note: upgrade_batch now groups session notes into batches (default 3 per spawn, config key summary_batch_size; set to 1 to disable) and summarizes each group in a single claude -p spawn to amortize CLI startup cost (#166). Per-note parse failures (missing_section) and whole-spawn failures fall through to the per-note solo path automatically before any result reaches Phase 2. For each entry:
status starts with Upgraded โ mark as succeeded
- anything else (including
Failed: ..., empty, or unexpected prefix) โ add to the Phase 2 fallback list
The stderr line emits a per-model breakdown visible in the tool trace (e.g. Step 2: upgraded 7 note(s) in 2.8s wall (5 haiku / 2 fallback)).
If N <= 5: update each sub-task accordingly (succeeded or Failed: <basename>).
If N > 5: update task #2 subject to Upgrade N notes: M succeeded, F pending fallback.
Why a single Bash call, not N parallel calls? The Claude Code harness serializes parallel Bash tool calls through a limited shell pool when each subprocess blocks on I/O (e.g., claude -p --model haiku taking 5-30s). Dispatching 10 Bash calls in one message still executes them one at a time โ wall time โ ฮฃ per-call. Pushing fan-out into a single Python process with ThreadPoolExecutor gives true concurrency (the GIL releases during subprocess waits), so wall time โ max per-call. See claude-insights/2026-04-21-recall-parallel-bash-dispatch-runs-sequentially-fbee-error.md and GH #69.
Phase 2 โ Sub-agent fallback (only for failed notes)
If no failures, skip this phase entirely.
For each failed note, spawn a sub-agent. If multiple notes failed, spawn all sub-agents in a single message turn:
Agent({
description: "Summarize session note <basename>",
prompt: "Read the session note at <NOTE_PATH>. Produce a structured summary with these exact markdown sections:\n\n## Summary\n1-3 sentence overview of what was accomplished.\n\n## Key Decisions\n- Bullet list of important technical decisions. Write \"None noted.\" if none.\n\n## Changes Made\n- Bullet list of files modified/created with brief description. Write \"None noted.\" if none.\n\n## Errors Encountered\n- Bullet list of errors and how resolved. Write \"None.\" if none.\n\n## Open Questions / Next Steps\n- [ ] Checkbox list of unresolved items. Write \"None.\" if none.\n\nWrite the summary to ~/.claude/obsidian-brain/summary-<basename>.md using the Write tool. After the summary sections, add a final line:\nIMPORTANCE: N\nwhere N is 1-10. 1-3: trivial (config, interrupted). 4-6: standard work. 7-8: key decisions or error resolutions. 9-10: major releases or security audits.\n\nReturn ONLY the single line: WRITTEN:~/.claude/obsidian-brain/summary-<basename>.md"
})
When sub-agents return, for each:
-
If the sub-agent returned WRITTEN:<path>, extract the path after WRITTEN: and replace the leading ~ with $HOME to get an absolute path. Store this as SUMMARY_TEMP_PATH. Verify the file exists: test -f "$SUMMARY_TEMP_PATH" && echo "EXISTS" || echo "MISSING".
-
If EXISTS, apply it via Python:
cd "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
python3 -c '
import sys, os
import glob; sys.path.insert(0, max(glob.glob(os.path.expanduser("~/.claude/plugins/cache/*/obsidian-brain/*/hooks")), default="hooks"))
from obsidian_utils import upgrade_note_with_summary
with open(os.path.expanduser(sys.argv[6]), "r") as f:
summary = f.read()
status = upgrade_note_with_summary(sys.argv[1], summary, sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5])
print(status)
' "$NOTE_PATH" "$VAULT_PATH" "$SESSIONS_FOLDER" "$PROJECT" "sub-agent" "$SUMMARY_TEMP_PATH"
If the write-back status starts with Failed:, count this note as permanently failed โ do NOT count it as upgraded. If N <= 5, update the per-note sub-task to Permanently failed: <basename>.
If the write-back succeeds, and N <= 5, update the per-note sub-task to Fallback succeeded: <basename>.
-
If MISSING or sub-agent didn't return WRITTEN: โ note stays unsummarized for next /recall. If N <= 5, update the per-note sub-task to Permanently failed: <basename>.
Always clean up temp files from Phase 2 after all write-backs complete, regardless of outcome. Use the actual SUMMARY_TEMP_PATH values collected from each sub-agent's WRITTEN: response (not placeholder names):
rm -f "$SUMMARY_TEMP_PATH_1" "$SUMMARY_TEMP_PATH_2" ...
If N > 5: update task #2 subject to reflect final Phase 2 results (e.g. Upgrade N notes: M Haiku + F fallback succeeded, K failed).
Completion
Mark task #2 as completed. Report results:
- How many upgraded via Haiku pipeline (Phase 1 successes)
- How many upgraded via sub-agent fallback (Phase 2 write-back successes)
- How many permanently failed (notes where both Phase 1 Haiku AND Phase 2 sub-agent fallback failed or were skipped โ these stay unsummarized for next
/recall)
For failed notes: "Note <basename> could not be summarized. It will be retried on the next /recall."
Step 3 โ Build context brief (Python)
Update task #3 to in_progress.
Run a single Python call that reads all session and insight files and composes the brief โ no sub-agent needed:
cd "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
python3 -c '
import sys, os
import glob; sys.path.insert(0, max(glob.glob(os.path.expanduser("~/.claude/plugins/cache/*/obsidian-brain/*/hooks")), default="hooks"))
from obsidian_utils import build_context_brief, check_hook_status
hs = check_hook_status()
status_line = ("[OK] " if hs["ok"] else "[WARN] ") + hs["message"]
print(build_context_brief(sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4], hook_status_line=status_line))
' "$VAULT_PATH" "$SESSIONS_FOLDER" "$INSIGHTS_FOLDER" "$PROJECT"
The first line of the emitted CONTEXT_BRIEF is always the hook-status line. If it starts with [OK], omit it from the displayed output โ the user doesn't need to see "session logging active" every time. If it starts with [WARN], display it verbatim so the user knows to take action (e.g., run /obsidian-setup).
If the command fails (non-zero exit code), print the error and stop โ do not fall back to in-context reads.
Parse the output. Split on section labels:
- Extract
<<<OB_CONTEXT_BRIEF>>> โ everything between this delimiter and <<<OB_LOAD_MANIFEST>>>. This is the brief to display.
- Extract
<<<OB_LOAD_MANIFEST>>> โ parse full_session_title, full_session_date, full_session_path, summary_session_title, summary_session_date, insight_count, snapshot_count (optional), and all snapshot: lines (there may be zero or more, each followed by optional 2-space-indented key_context bullets).
- Extract
<<<OB_OPEN_ITEM_CANDIDATES>>> โ either NO_CANDIDATES, NO_ITEMS, or a JSON array. Count the number of - [ ] items across all scanned session notes. Store as open_items_total. When the payload is a JSON array, each element may carry two optional fields โ contradicted_by (a YYYY-MM-DD date) and contradicted_by_title (that session's title) โ meaning a STRICTLY NEWER session's own summary reports that item done. Collect every element that has a non-empty contradicted_by into a list of flagged items (text, contradicted_by) for Step 4. Elements without contradicted_by are not flagged โ ignore them (do not surface, do not count as done).
Present the brief immediately (same turn โ saves one parent round):
Here's what I found from your Obsidian vault for $PROJECT:
Then output the CONTEXT_BRIEF section. For the session history table, paraphrase each session's Title column into a concise one-line summary (under ~80 characters) that captures the key accomplishment. Keep all other columns (date, duration, branch) verbatim.
Snapshots appear in the brief as nested indented rows beneath their parent session (rows starting with โณ HH:MM:SS). Render them verbatim โ do not paraphrase snapshot titles (they're already one-line summaries). Display the snapshot: lines from LOAD_MANIFEST as bullet points under the most-recent session in the "Loaded into this conversation" output.
If unsummarized notes were upgraded in Step 2, also mention:
Upgraded N session note(s) with AI summaries.
Step 3b โ Recurring Themes (read-only)
Surface the project's top recurring themes (ranked by stored activation, kept fresh by /consolidate and /emerge). This is a fast, read-only DB read โ $PROJECT is the value already derived in Step 1.
cd "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
python3 -c '
import sys, os
import glob; sys.path.insert(0, max(glob.glob(os.path.expanduser("~/.claude/plugins/cache/*/obsidian-brain/*/hooks")), default="hooks"))
from obsidian_utils import recurring_themes_section
from vault_index import _default_db_path
print(recurring_themes_section(_default_db_path(), sys.argv[1] if len(sys.argv) > 1 else None))
' "$PROJECT"
If the output is non-empty, append it verbatim to the brief (between the context brief and the open-items footer). If it is empty, print nothing โ there are no themes yet.
Graceful degradation: the helper swallows its own exceptions (ImportError, empty/missing DB, no themes) and returns "", so a missing index or an un-consolidated vault simply prints nothing and /recall continues normally. Do not treat an empty result as an error.
Step 4 โ Show read-only context brief footer
For each flagged item collected in Step 3 (those carrying a non-empty contradicted_by), render one line, in the order returned:
โ "<item's text field, verbatim>" looks done per session <contradicted_by> โ run /check-items to confirm
If contradicted_by_title is present and non-empty, you may append it in parentheses after the date for extra context. Do not paraphrase the item text.
Then append to the brief:
N open items in this project โ run /check-items to triage.
Where N is the count of - [ ] items found while scanning sessions in Step 3 (the open_items_total value already computed by the Python block in Step 3; if not present, count by re-scanning the same notes) MINUS the number of flagged items already rendered above, so a flagged item is never double-counted in the plain footer.
This step remains strictly read-only: never check anything off, and never prompt the user to action an individual item beyond the single flagged-line nudge above. Do NOT independently compute candidate matches or cite session evidence of your own โ the flagged lines are rendered only from the contradicted_by field Python already computed in Step 3.
If N == 0 and there are no flagged items either, omit the footer/warning block entirely. If there are flagged items but N == 0, still render the flagged lines (omit only the plain _N open items... line).
Mark task #3 (the renamed final task) as completed and end.
Edge Cases
- No sessions found: Tell the user no session history was found for this project. Suggest they start a session and it will be logged automatically.
- No insights found: Omit the "Curated Insights" section. Mention: "No curated insights yet for this project."
- Very large vault (50+ sessions): Only grep, never glob the entire folder. Limit reads to the most recent 5 sessions + all insights.
- Config exists but vault path is invalid: Warn the user and suggest running
/obsidian-setup again.
- Open items exist: Do not attempt to check them off. Append the footer nudge pointing to
/check-items instead.