diff --git a/plugins/claude-code/hooks/pre-compact.sh b/plugins/claude-code/hooks/pre-compact.sh index 81f958d9..e768e5a4 100755 --- a/plugins/claude-code/hooks/pre-compact.sh +++ b/plugins/claude-code/hooks/pre-compact.sh @@ -25,7 +25,6 @@ BM="$(command -v basic-memory || command -v bm || true)" BM_HOOK_INPUT="$input" BM_BIN="$BM" python3 <<'PY' 2>/dev/null || exit 0 import json import os -import re import subprocess import sys from datetime import datetime @@ -97,13 +96,21 @@ def turns(path): obj = json.loads(line) except Exception: continue + # Skip injected/meta frames and tool results — only real human + # input and assistant prose count. Claude Code marks tool results + # with a `toolUseResult` field and injected/meta turns (command + # wrappers, system reminders, auto-continuations) with `isMeta`. + # Filtering on those flags — not a "<" content prefix — avoids both + # dropping legitimate messages that start with "<" and capturing + # tool-result noise. + if obj.get("isMeta") or obj.get("toolUseResult") is not None: + continue msg = obj.get("message") if isinstance(obj.get("message"), dict) else obj role = msg.get("role") or obj.get("type") if role not in ("user", "assistant"): continue text = text_of(msg.get("content")).strip() - # Skip tool-result noise and empty frames. - if not text or text.startswith("<"): + if not text: continue collected.append((role, text)) except Exception: @@ -113,10 +120,11 @@ def turns(path): conversation = turns(transcript_path) -# Trigger: nothing usable in the transcript. -# Why: an empty checkpoint is worse than none — it clutters the graph. +# Trigger: nothing usable in the transcript, or no real human turn in it. +# Why: an empty or human-less checkpoint is worse than none — it would write a +# note with a dangling title and no opening request. Require a user turn. # Outcome: silent no-op. -if not conversation: +if not conversation or not any(role == "user" for role, _ in conversation): sys.exit(0) user_msgs = [t for r, t in conversation if r == "user"] @@ -134,10 +142,11 @@ def clip(s, n): # find it with metadata filters. BM merges a leading frontmatter block from the # content into the note's frontmatter (verified empirically). now = datetime.now() -stamp = now.strftime("%Y-%m-%d-%H%M%S") iso = now.strftime("%Y-%m-%dT%H:%M") -slug = re.sub(r"[^a-z0-9]+", "-", clip(opening, 50).lower()).strip("-") or "session" -title = f"Session {now.strftime('%Y-%m-%d %H:%M')} — {clip(opening, 40)}" +# Second precision keeps the title — and therefore the note's permalink — unique +# across rapid compactions within the same minute (otherwise the second write +# would collide with the first and be dropped or overwrite it). +title = f"Session {now.strftime('%Y-%m-%d %H:%M:%S')} — {clip(opening, 40)}" frontmatter = [ "---", diff --git a/plugins/claude-code/hooks/session-start.sh b/plugins/claude-code/hooks/session-start.sh index 93cb001f..1c1ee10a 100755 --- a/plugins/claude-code/hooks/session-start.sh +++ b/plugins/claude-code/hooks/session-start.sh @@ -101,9 +101,13 @@ recall_prompt = cfg.get("recallPrompt") or default_prompt # also read for recall). Dedup, preserve order, cap. These are read only — the # capture hooks never write to them. shared_refs = [] -secondary = cfg.get("secondaryProjects") or [] -team = cfg.get("teamProjects") or {} -for ref in list(secondary) + (list(team.keys()) if isinstance(team, dict) else []): +# Guard the JSON types: a misconfigured string would otherwise be iterated +# character-by-character, firing a bogus per-character query for each one. +secondary = cfg.get("secondaryProjects") +secondary = secondary if isinstance(secondary, list) else [] +team = cfg.get("teamProjects") +team = team if isinstance(team, dict) else {} +for ref in list(secondary) + list(team.keys()): if isinstance(ref, str) and ref.strip() and ref.strip() != primary_project: if ref.strip() not in shared_refs: shared_refs.append(ref.strip()) @@ -129,6 +133,10 @@ def search(filters, project_ref=None, timeout=10): ACTIVE_TASKS = ["--type", "task", "--status", "active"] OPEN_DECISIONS = ["--type", "decision", "--status", "open"] +# Recent session checkpoints carry the resume cursor. This is the one query the +# `recallTimeframe` window applies to — tasks and decisions are status-scoped (an +# old open decision is still open), but "recent sessions" is inherently time-scoped. +RECENT_SESSIONS = ["--type", "session", "--after_date", recall_timeframe] # --- Run everything concurrently --- # Cloud reads cost a network round-trip each; parallelism keeps total wall-clock at @@ -136,9 +144,11 @@ OPEN_DECISIONS = ["--type", "decision", "--status", "open"] with ThreadPoolExecutor(max_workers=8) as pool: fut_tasks = pool.submit(search, ACTIVE_TASKS, primary_project or None) fut_decisions = pool.submit(search, OPEN_DECISIONS, primary_project or None) + fut_sessions = pool.submit(search, RECENT_SESSIONS, primary_project or None) fut_shared = {ref: pool.submit(search, OPEN_DECISIONS, ref) for ref in shared_refs} primary_tasks = fut_tasks.result() primary_decisions = fut_decisions.result() + primary_sessions = fut_sessions.result() shared_results = {ref: fut.result() for ref, fut in fut_shared.items()} # The first-run nudge — shown until setup writes a basicMemory config block. @@ -147,13 +157,21 @@ setup_nudge = ( "`/basic-memory:setup` (~2 min) to configure session briefings and checkpoints._" ) -# Trigger: the primary queries couldn't run at all (no default project, transient -# error). Why: a broken query must never error the session — but a genuine first-run -# user (no config, nothing to brief) should still be pointed at setup. -# Outcome: first-run → nudge only; already-configured → silent no-op. -if primary_tasks is None and primary_decisions is None: +# Trigger: every primary query failed (no default project, misnamed project, +# unreachable cloud, transient error). Why: a broken query must never error the +# session, but it must not silently look like "nothing tracked" either. +# Outcome: first-run → setup nudge; configured-but-broken → a one-line signal so +# the user can tell a typo'd/unreachable project from an empty one. +if primary_tasks is None and primary_decisions is None and primary_sessions is None: if not configured: print("# Basic Memory\n\n" + setup_nudge) + else: + proj = primary_project or "the default project" + print( + "# Basic Memory\n\n" + f"_Couldn't read from `{proj}` — it may be misnamed or unreachable. " + "Run `/basic-memory:status` to check._" + ) sys.exit(0) @@ -181,12 +199,19 @@ lines.append(header) task_rows = rows(primary_tasks) decision_rows = rows(primary_decisions) +session_rows = rows(primary_sessions) if task_rows: lines += ["", f"## Active tasks ({len(task_rows)})", *[label(r) for r in task_rows]] if decision_rows: lines += ["", f"## Open decisions ({len(decision_rows)})", *[label(r) for r in decision_rows]] -if not task_rows and not decision_rows: - lines += ["", "_No active tasks or open decisions tracked in this project._"] +if session_rows: + lines += [ + "", + f"## Recent sessions ({len(session_rows)}) — where you left off", + *[label(r) for r in session_rows], + ] +if not (task_rows or decision_rows or session_rows): + lines += ["", "_No active tasks, open decisions, or recent sessions in this project._"] # --- Shared/team context (read-only) --- shared_sections = [(ref, rows(shared_results.get(ref))) for ref in shared_refs] diff --git a/scripts/validate_skills.py b/scripts/validate_skills.py index e3d27b39..753e6088 100644 --- a/scripts/validate_skills.py +++ b/scripts/validate_skills.py @@ -8,6 +8,15 @@ from pathlib import Path def parse_frontmatter(path: Path) -> dict[str, str]: + """Extract top-level frontmatter keys from a Markdown file. + + A deliberately minimal parser (no PyYAML — this runs under bare `python3` in + CI). It only captures **top-level** `key: value` lines. Indented lines are + skipped, so nested blocks (a schema note's `schema:`/`settings:` children) can't + overwrite a top-level key like `type` or `entity` via last-write-wins. It does + not interpret block scalars or multi-line values; callers rely on single-line + top-level fields (name, description, type, entity). + """ lines = path.read_text().splitlines() if not lines or lines[0] != "---": raise SystemExit(f"{path}: missing YAML frontmatter") @@ -16,6 +25,8 @@ def parse_frontmatter(path: Path) -> dict[str, str]: for line in lines[1:]: if line == "---": break + if line[:1] in (" ", "\t"): # nested key — not a top-level field + continue if ":" not in line: continue key, value = line.split(":", 1)