diff --git a/BACKLOG.md b/BACKLOG.md index 00a966c..cde56f4 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -7,6 +7,17 @@ here so neither side drifts. ## Open +### From the 2026-09-03 long-session investigation + +- [x] **Long sessions run dozens of tool calls with no memory contact — the per-turn gate keys off continuation prompts** ([#296](https://github.com/CryptoJones/omind/issues/296)) — _feat (guard)_ — + continuation-aware preflight + retry carry + a per-turn action budget in the + harness-agnostic core (injected on Claude `PostToolUse`, demanded as a re-arm + elsewhere); `doctor` gains `gate_continuity`. +- [ ] **CI: Windows test jobs fail on every run since 2026-08-26** ([#297](https://github.com/CryptoJones/omind/issues/297)) — _fix (journal, ai_usage)_ — + seven journal-rollup `PermissionError`s (an open handle before the archive + move) and the off-`PATH` CLI resolver test; neither job is a required check, + so the Windows matrix currently carries no signal. + ### From the 2026-08-27 multi-agent review (code round — fixes in the working tree) _A nine-slice review (memory core, MCP surface, mesh, enforcement, retrieval, diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dd5a9b..248b0c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Consult continuity across long sessions (#296).** Measured on a live box, + ~40% of turns started with the consult gate auto-cleared and nothing + injected, because the preflight ranked notes against the prompt alone and a + long session's prompts are continuations (`retry`, `Yes please`, a task + notification). Two harness-agnostic controls in `guard`: (1) a + continuation-shaped prompt is retrieved against the prior turn's task plus the + agent's recent activity, and an identical `retry` inside two minutes (the + harness's own API auto-retry) carries the turn's gate state instead of + resetting it; (2) every allowed action counts against a per-turn budget + (`OMIND_GATE_ACTION_BUDGET`, default 25) after which the core surfaces a + relevant memory this session has not seen — injected via Claude Code's + `PostToolUse` `additionalContext` (`omind hook PostToolUse --harness claude`, + written by `omind setup`), or demanded as a re-arm at the next PreToolUse on + every other harness. Capped by `OMIND_GATE_MAX_REARM` (default 4) per turn. + `omind doctor` gains a `gate_continuity` check (7-day auto-clear rate, warns + at 40%); every decision is compliance-logged (`omi-gate-preflight`, + `omi-gate-carry`, `omi-gate-rearm`, `omi-gate-rearm-no-match`) and the whole + `omi-gate*` family is excluded from the fine-tune corpus and from the + OpenCode plugin's enforced denies. + +### Fixed +- **Tool error text survives mcp >= 2.1 (#294).** mcp 2.1.x hands the client a + bare `Error executing tool ` for any exception that is not a deliberate + `ToolError`, which hid every anticipated failure — a missing note, an unsafe + name, a bad graph argument, and the stale-version conflict whose message is + what tells an agent to re-read before writing. The server now re-raises those + domain failures (`NoteError`, `NoteConflictError`, `ValueError`) as `ToolError` + at the tool boundary; a real crash stays masked as the SDK intends. `uv.lock` + moves to mcp 2.1.1 so a local run sees what CI sees. + ### Fixed — from the 2026-08-27 multi-agent review (35 findings, all fixed; full report in `docs/reviews/2026-08-27-multi-agent-review.md`) diff --git a/README.md b/README.md index bf3ab4d..23e2e69 100644 --- a/README.md +++ b/README.md @@ -295,6 +295,24 @@ broken hook can never wedge the agent. a miss, so it still leaves the gate armed. Set `OMI_GATE_MISS_STRICT=1` to restore the old force-a-consult-on-every-miss behavior. Hard rule-specific prerequisites remain independent. +- **Consult continuity across a long session.** A continuation prompt + (`retry`, `go ahead`, a task notification — fewer than three meaningful + terms) carries no signal of its own, so it is retrieved against the **prior + turn's task plus the agent's recent activity**; the gate auto-clears only when + the vault has nothing for the work in progress. An identical `retry` re-sent + within two minutes is the harness's own API auto-retry and carries the + turn's gate state instead of resetting it. Inside a turn, every allowed + action counts against a budget (`OMIND_GATE_ACTION_BUDGET`, default 25); + at the budget the core retrieves against the turn's task and its action + trail, and if a relevant memory this session has not seen exists it is + surfaced — injected after the tool call where the harness can do that + (Claude Code `PostToolUse`), otherwise as a re-arm that demands exactly that + note before the next action (one `recall-note` clears it). No candidate → + the budget resets and the auto-clear is logged. At most + `OMIND_GATE_MAX_REARM` (default 4) mid-turn re-arms per turn, so a turn can + never be re-gated indefinitely. This lives in the harness-agnostic core, so + every adapter (Claude, Hermes, OpenCode, Codex, Gemini, DSH) inherits it. + `omind doctor` reports the 7-day auto-clear rate (`gate_continuity`). - **The verifier.** Clearing the gate by reading *any* note isn't enough, so a `PostToolUse` verifier judges whether the consult was actually **relevant** to the turn's task — a deterministic keyword-overlap prefilter decides the clear diff --git a/docs/manual-setup.md b/docs/manual-setup.md index c333bf9..e15d511 100644 --- a/docs/manual-setup.md +++ b/docs/manual-setup.md @@ -89,7 +89,7 @@ these three and leaves the rest of the file alone: "hooks": [ { "type": "command", - "command": "omind hook PostToolUse --vault \"$HOME/Documents/Obsidian Vault\" --folder \"OMI\"" + "command": "omind hook PostToolUse --vault \"$HOME/Documents/Obsidian Vault\" --folder \"OMI\" --harness claude" } ] } diff --git a/src/omind/cli.py b/src/omind/cli.py index 1720104..e30f9de 100644 --- a/src/omind/cli.py +++ b/src/omind/cli.py @@ -598,6 +598,12 @@ def build_parser() -> argparse.ArgumentParser: help="the hook event name (Claude Code: PostToolUse/Stop/SessionStart; " "Hermes Agent: pre_llm_call)", ) + hook.add_argument( + "--harness", + default="", + help="the calling harness (e.g. claude); enables the mid-turn recall " + "injection only where the harness's post-tool hook can inject context", + ) _add_vault_args(hook) loop = sub.add_parser( @@ -1573,7 +1579,8 @@ def _run_consolidate(args: argparse.Namespace) -> int: def _run_hook(args: argparse.Namespace) -> int: omi_dir = (args.vault / args.folder).expanduser() - return run_hook(args.event, omi_dir) # always 0; must never block the agent + # always 0; must never block the agent + return run_hook(args.event, omi_dir, harness=str(getattr(args, "harness", "") or "")) def _run_loop(args: argparse.Namespace) -> int: diff --git a/src/omind/compliance.py b/src/omind/compliance.py index c6ef69f..7367393 100644 --- a/src/omind/compliance.py +++ b/src/omind/compliance.py @@ -28,7 +28,7 @@ import os import re from collections import Counter -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path from typing import Any @@ -265,10 +265,59 @@ def recidivism_counts(events: list[dict[str, Any]] | None = None) -> Counter[str "repo-work-read-git-rules", "off-topic-consult", "omi-gate", + "omi-gate-rearm", "verify-reclose-floor", } ) +#: The consult-gate continuity decisions (#296), all logged at UserPromptSubmit +#: or by the mid-turn budget. ``turns`` counts every turn the preflight judged. +_GATE_INJECT_RULE = "omi-gate-preflight" +_GATE_AUTO_CLEAR_RULES = frozenset({"omi-gate-no-match", "omi-gate-weak-match"}) +_GATE_CARRY_RULE = "omi-gate-carry" +_GATE_REARM_RULE = "omi-gate-rearm" +_GATE_REARM_NO_MATCH_RULE = "omi-gate-rearm-no-match" + + +def gate_continuity(*, days: int = 7, now: datetime | None = None) -> dict[str, Any]: + """Rollup of how often a turn started with memory injected vs. auto-cleared, + and what the mid-turn budget did, over the last ``days`` (#296).""" + since = (now or datetime.now()) - timedelta(days=max(0, days)) + injected = auto_cleared = carried = denies = injects = no_match = 0 + for event in read_events(): + ts = str(event.get("ts") or "") + try: + if datetime.fromisoformat(ts) < since: + continue + except ValueError: + continue + rule_id = str(event.get("rule_id") or "") + outcome = str(event.get("outcome") or "") + if rule_id == _GATE_INJECT_RULE: + injected += 1 + elif rule_id in _GATE_AUTO_CLEAR_RULES: + auto_cleared += 1 + elif rule_id == _GATE_CARRY_RULE: + carried += 1 + elif rule_id == _GATE_REARM_RULE: + if outcome == "inject": + injects += 1 + else: + denies += 1 + elif rule_id == _GATE_REARM_NO_MATCH_RULE: + no_match += 1 + turns = injected + auto_cleared + return { + "turns": turns, + "injected": injected, + "auto_cleared": auto_cleared, + "carried": carried, + "auto_clear_pct": (100.0 * auto_cleared / turns) if turns else 0.0, + "rearm_denies": denies, + "rearm_injects": injects, + "rearm_no_match": no_match, + } + def summary() -> dict[str, Any]: """A compact rollup for ``omind doctor``: totals + the top recidivist rules.""" diff --git a/src/omind/corpus.py b/src/omind/corpus.py index c55afcc..63241bf 100644 --- a/src/omind/corpus.py +++ b/src/omind/corpus.py @@ -55,7 +55,9 @@ def corpus_examples() -> list[dict[str, Any]]: examples: list[dict[str, Any]] = [] for event in compliance.read_events(): rule_id = str(event.get("rule_id") or "") - if not rule_id or rule_id == "omi-gate": + # The consult-gate family (the gate itself, its preflight auto-clears, + # the mid-turn budget) is ceremony, not a violation to learn from. + if not rule_id or rule_id.startswith("omi-gate"): continue tool = str(event.get("tool") or "action") command = str(event.get("command") or "") diff --git a/src/omind/guard.py b/src/omind/guard.py index 818ce0f..017261e 100644 --- a/src/omind/guard.py +++ b/src/omind/guard.py @@ -71,6 +71,35 @@ #: Synthetic rule id for a preflight miss that auto-cleared the gate. GATE_NO_MATCH_RULE = "omi-gate-no-match" GATE_WEAK_MATCH_RULE = "omi-gate-weak-match" +#: Consult continuity across a long session (#296). The per-turn gate keyed off +#: the user prompt alone, and in a long session most prompts are continuations +#: ("retry", "go ahead", a task notification) that carry no signal — so the +#: preflight auto-cleared and the turn ran dozens of actions with no memory +#: contact. Two controls close that: a continuation prompt is retrieved against +#: the PRIOR task plus the agent's recent activity, and every allowed action +#: counts against a per-turn budget after which the core re-checks whether an +#: unseen relevant memory exists for the work in progress. +ACTION_BUDGET_ENV = "OMIND_GATE_ACTION_BUDGET" +_DEFAULT_ACTION_BUDGET = 25 +#: Per-turn ceiling on mid-turn re-arms/injections — an anti-wedge cap, like the +#: verifier's re-close cap: a turn can never be re-gated indefinitely. +MAX_REARM_ENV = "OMIND_GATE_MAX_REARM" +_DEFAULT_MAX_REARM = 4 +#: Synthetic rule ids for the continuity decisions (all soft; all ceremonies). +GATE_REARM_RULE = "omi-gate-rearm" +GATE_REARM_NO_MATCH_RULE = "omi-gate-rearm-no-match" +GATE_CARRY_RULE = "omi-gate-carry" +GATE_PREFLIGHT_RULE = "omi-gate-preflight" +#: A prompt with fewer meaningful terms than this is a continuation of the +#: prior turn's work, not a new task ("retry", "Yes please", "Delete it"). +CONTINUATION_MAX_TERMS = 3 +#: An identical prompt re-sent within this window is the harness's own API +#: auto-retry (or a human re-poking), not a new turn: the gate state carries. +RETRY_WINDOW_SECS = 120.0 +#: How many recent action texts the sentinel keeps as the turn's activity trail +#: (the harness-agnostic activity signal — every adapter reaches the core). +_TRAIL_LEN = 8 +_TRAIL_ITEM_CAP = 160 GIT_RULES_NOTE = "Operational Rules - Git Repos and Secrets" GIT_RULES_MESSAGE = ( "ACTION BLOCKED. Next call OMI MCP `recall-note` with " @@ -174,6 +203,7 @@ def begin_turn(session: str, task: str) -> None: verifier's anti-wedge cap and the transition signal are both measured per turn (the bash turn-start hook clears the same counter file).""" _clear_reclose(session) + _clear_rearm(session) _clear_pending(session) _clear_git_freshness(session) _clear_demanded(session) @@ -439,6 +469,7 @@ def mark_consulted(session: str) -> None: def _mark(data: dict[str, Any]) -> dict[str, Any]: data.setdefault("consults", []) + data["actions"] = 0 return data _mutate_sentinel(session, _mark) @@ -454,6 +485,7 @@ def _record(data: dict[str, Any]) -> dict[str, Any]: consult_list = existing if isinstance(existing, list) else [] consult_list.append({"kind": kind, "target": target, "relevant": relevant}) data["consults"] = consult_list + data["actions"] = 0 return data _mutate_sentinel(session, _record) @@ -494,6 +526,370 @@ def _reap_legacy_sentinels() -> None: path.unlink() +# -------------------------------------------------------------------------- +# Consult continuity (#296): action budget, activity trail, continuation turns +# -------------------------------------------------------------------------- + + +def _env_int(env: str, default: int) -> int: + raw = os.environ.get(env, "").strip() + if raw: + try: + return max(0, int(raw)) + except ValueError: + pass + return default + + +def action_budget() -> int: + """Allowed non-consult actions per turn before the core re-checks memory + (``0`` disables the budget).""" + return _env_int(ACTION_BUDGET_ENV, _DEFAULT_ACTION_BUDGET) + + +def _max_rearm() -> int: + return _env_int(MAX_REARM_ENV, _DEFAULT_MAX_REARM) + + +def _rearm_path(session: str) -> Path: + return paths.state_dir() / f"rearm-{_safe_sid(session)}" + + +def rearm_count(session: str) -> int: + """Mid-turn re-arms + injections so far this turn (reset at turn start).""" + try: + return int(_rearm_path(session).read_text(encoding="utf-8").strip() or "0") + except (OSError, ValueError): + return 0 + + +def bump_rearm(session: str) -> int: + path = _rearm_path(session) + with contextlib.suppress(OSError, ValueError): + path.parent.mkdir(parents=True, exist_ok=True) + with filelock.exclusive(_sibling_lock(path)): + nxt = rearm_count(session) + 1 + path.write_text(str(nxt), encoding="utf-8") + return nxt + return rearm_count(session) + + +def _clear_rearm(session: str) -> None: + with contextlib.suppress(OSError): + _rearm_path(session).unlink() + + +def _last_turn_path(session: str) -> Path: + """The previous turn's prompt + substantive task + timestamp, so a + continuation prompt can be resolved against what the agent was doing.""" + return paths.state_dir() / f"lastturn-{_safe_sid(session)}.json" + + +def _read_last_turn(session: str) -> dict[str, Any]: + try: + data = json.loads(_last_turn_path(session).read_text(encoding="utf-8") or "{}") + except (OSError, ValueError): + return {} + return data if isinstance(data, dict) else {} + + +def _write_last_turn(session: str, *, prompt: str, task: str, ts: float) -> None: + with contextlib.suppress(OSError, ValueError): + path = _last_turn_path(session) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps({"prompt": prompt[:2_000], "task": task[:4_000], "ts": ts}), + encoding="utf-8", + ) + + +def is_continuation_prompt(prompt: str) -> bool: + """True when ``prompt`` continues the prior turn rather than starting a task: + a harness-injected wrapper (````, ````) + or fewer than :data:`CONTINUATION_MAX_TERMS` meaningful terms. Empty is not + a continuation — an empty task keeps the gate strict elsewhere.""" + text = prompt.strip() + if not text: + return False + if text.startswith("<"): + return True + from omind import retrieve + + return retrieve.term_count(text) < CONTINUATION_MAX_TERMS + + +def actions_since_consult(session: str) -> int: + """Allowed non-consult actions since this turn's last OMI consult.""" + try: + return int(_read_sentinel(session).get("actions") or 0) + except (TypeError, ValueError): + return 0 + + +def action_trail(session: str) -> list[str]: + """The most recent allowed action texts this turn (newest last).""" + raw = _read_sentinel(session).get("trail") + return [str(t) for t in raw if isinstance(t, str)] if isinstance(raw, list) else [] + + +def count_action(session: str, text: str) -> None: + """Record one allowed non-consult action against the turn's budget and + append it to the activity trail. Never raises.""" + + def _count(data: dict[str, Any]) -> dict[str, Any]: + try: + data["actions"] = int(data.get("actions") or 0) + 1 + except (TypeError, ValueError): + data["actions"] = 1 + raw = data.get("trail") + trail = [str(t) for t in raw if isinstance(t, str)] if isinstance(raw, list) else [] + item = " ".join(text.split())[:_TRAIL_ITEM_CAP] + if item: + trail.append(item) + data["trail"] = trail[-_TRAIL_LEN:] + return data + + _mutate_sentinel(session, _count) + + +def reset_action_count(session: str) -> None: + def _reset(data: dict[str, Any]) -> dict[str, Any]: + data["actions"] = 0 + return data + + _mutate_sentinel(session, _reset) + + +#: Path components that name a machine's layout, not the work (a trail item +#: ``/home/x/Source/repos/telesto/deploy.sh`` should contribute "telesto" and +#: "deploy", not "home"/"source"/"repos"). +_TRAIL_NOISE_PARTS = frozenset( + {"home", "users", "source", "repos", "src", "tmp", "srv", "var", "opt", "usr", "bin", "lib"} +) +_TRAIL_SPLIT_RE = re.compile(r"[\\/]+") + + +def _trail_words(item: str) -> str: + """A trail item as retrieval words: path separators become spaces and the + layout-only components drop out, so a file's project and name both count + (the verifier's ``normalize_intent`` keeps only basenames, which throws + away the project — the strongest signal for *which memory* applies).""" + words = [] + for token in item.split(): + for part in _TRAIL_SPLIT_RE.split(token): + if part and part.casefold() not in _TRAIL_NOISE_PARTS: + words.append(part) + return " ".join(words) + + +def _activity_text(session: str, omi_dir: Path | str | None) -> str: + """What the agent has been doing: the sentinel's action trail (reaches the + core under every harness) plus the journal's recent activity where a + harness journals. Never raises.""" + parts = [_trail_words(item) for item in action_trail(session)] + if omi_dir is not None: + try: + from omind import verify + + parts.append(verify.recent_activity(session, omi_dir)) + except Exception: + pass + return " ".join(part for part in parts if part) + + +def _seen_note_stems(session: str) -> set[str]: + """Notes this session has already been shown or has consulted this turn — + a mid-turn re-arm must surface something NEW, never re-demand these.""" + stems = {Path(name).stem.casefold() for name in _injected_versions(session)} + for consult in consults(session): + target = str(consult.get("target") or "") + if target: + stems.add(Path(target).stem.casefold()) + demanded = demanded_note(session) + if demanded: + stems.add(Path(demanded).stem.casefold()) + return stems + + +def midturn_candidate( + session: str, omi_dir: Path | str, *, query: str = "" +) -> tuple[str, str] | None: + """``(filename, title)`` of the best note relevant to the work in progress + that this session has not seen yet, or ``None``. ``query`` defaults to the + turn's task plus the activity signal. Deterministic; no model call.""" + from omind import recall, retrieve + + if not query: + query = " ".join(p for p in (turn_task(session), _activity_text(session, omi_dir)) if p) + if not retrieve.term_count(query): + return None + seen = _seen_note_stems(session) + min_terms = retrieve.preflight_min_terms() + for title in retrieve.relevant_titles(query, omi_dir, limit=5): + filename = recall.filename_for_title(omi_dir, title) + if filename is None or Path(filename).stem.casefold() in seen: + continue + if min_terms: + memory = recall.compact_recall(omi_dir, filename, max_chars=recall.MIN_RECALL_CHARS) + haystack = " ".join( + str(memory.get(key) or "") for key in ("title", "summary", "content") + ) + if retrieve.matched_terms(query, haystack) < min_terms: + continue + return filename, title + return None + + +def _log_continuity( + session: str, *, tool: str, command: str, rule_id: str, outcome: str, detail: str +) -> None: + compliance.log_event( + compliance.KIND_DECISION, + session=session, + tool=tool, + command=command, + rule_id=rule_id, + severity="soft", + outcome=outcome, + detail=detail, + ) + + +def budget_verdict(action: dict[str, Any], omi_dir: Path | str | None) -> Verdict | None: + """The mid-turn continuity check for an action the gate already ALLOWED. + + Counts the action against the turn's budget; at the budget, looks for a + relevant memory this session has not seen. Found → the gate re-arms and + demands that note (one ``recall-note`` clears it — the verifier treats a + demanded read as obedience). Nothing new → the budget resets and the + auto-clear is logged. Returns a deny :class:`Verdict` or ``None`` (allow). + Runs in the harness-agnostic core, so every adapter inherits it; a harness + that can inject context after a tool call gets the same memory as a nudge + first (:func:`midturn_context`) and never reaches the deny. + """ + session = str(action.get("session") or "") + if not session or omi_dir is None or action.get("is_omi_consult") or gate_paused(): + return None + command = str(action.get("command") or "") + if command and _is_inert_command(command): + return None + if not consulted_this_turn(session): + return None + tool = str(action.get("tool") or "") + text = command or _action_path(action) or tool + actions = actions_since_consult(session) + budget = action_budget() + if budget and actions >= budget and rearm_count(session) < _max_rearm(): + found = midturn_candidate(session, omi_dir) + if found is not None: + from omind import recall + + filename, title = found + clear_gate(session) + record_demanded_note(session, filename) + record_pending(session, text) + bump_rearm(session) + _log_continuity( + session, + tool=tool, + command=command, + rule_id=GATE_REARM_RULE, + outcome="deny", + detail=f"actions={actions} note={filename!r}", + ) + call = json.dumps( + {"name": filename, "max_chars": recall.MAX_RECALL_CHARS}, + ensure_ascii=False, + separators=(",", ":"), + ) + reason = ( + f"omi-gate (re-arm): {actions} actions since this turn's last memory " + "consult and the work has moved on. Relevant memory not yet consulted " + f"this session: [[{title}]]. Next call OMI MCP `recall-note` with " + f"`{call}`, then retry this action. (Budget: {budget} actions between " + f"consults; {ACTION_BUDGET_ENV} tunes it.)" + ) + excerpt = _governing_excerpt(omi_dir, filename) + if excerpt: + reason += f"\n\n--- Governing memory (excerpt) ---\n{excerpt}" + return Verdict(allow=False, reason=reason, rule_id=GATE_REARM_RULE) + reset_action_count(session) + _log_continuity( + session, + tool=tool, + command=command, + rule_id=GATE_REARM_NO_MATCH_RULE, + outcome="auto-clear", + detail=f"actions={actions}", + ) + count_action(session, text) + return None + + +def midturn_context(event: dict[str, Any], omi_dir: Path | str | None) -> str: + """Proactive mid-turn recall for a harness whose post-tool hook can inject + context (Claude Code ``PostToolUse`` ``additionalContext``). At the action + budget, the same candidate :func:`budget_verdict` would demand is injected + as a nudge instead, and the budget resets — so the deny never fires there. + Returns ``""`` when there is nothing to inject. Never raises.""" + try: + session = str(event.get("session_id") or event.get("session") or "") + if not session or omi_dir is None or gate_paused(): + return "" + if not consulted_this_turn(session): + return "" + budget = action_budget() + actions = actions_since_consult(session) + if not budget or actions < budget or rearm_count(session) >= _max_rearm(): + return "" + found = midturn_candidate(session, omi_dir) + if found is None: + reset_action_count(session) + _log_continuity( + session, + tool="PostToolUse", + command="", + rule_id=GATE_REARM_NO_MATCH_RULE, + outcome="auto-clear", + detail=f"actions={actions}", + ) + return "" + from omind import ai_usage, recall + + filename, title = found + memory = recall.compact_recall( + omi_dir, filename, max_chars=ai_usage.policy(omi_dir).preflight_chars + ) + summary = str(memory.get("summary") or "").strip() + excerpt = str(memory.get("content") or "").strip() + content = "\n\n".join(part for part in (summary, excerpt) if part and part != summary) + if not content: + content = str(memory.get("title") or filename) + record_consult(session, kind="midturn", target=filename, relevant=True) + reset_offtopic(session) + _record_injected(session, filename, str(memory.get("version") or "")) + bump_rearm(session) + _log_continuity( + session, + tool="PostToolUse", + command="", + rule_id=GATE_REARM_RULE, + outcome="inject", + detail=f"actions={actions} note={filename!r}", + ) + context = ( + f"OMI mid-turn recall: {actions} actions since this turn's last memory " + f"consult and the work has moved on. [[{memory.get('title') or title}]] is a " + "standing operator instruction/memory relevant to the work in progress — " + "apply it unless the user's current message explicitly overrides it. " + "Silence is not an override.\n\n" + content + ) + ai_usage.record_context(omi_dir, "recall", len(context), session_id=session) + return context + except Exception: + return "" + + def clear_gate(session: str) -> None: """Clear the per-turn consult sentinel (the harness's turn-start reset). @@ -1387,6 +1783,12 @@ def check_action(action: dict[str, Any], omi_dir: Path | None = None) -> Verdict verdict = _note_rules_verdict(action, omi_dir) if verdict is None: verdict = decide(action) + if verdict.allow: + # #296: an allowed action still counts against the turn's budget, and at + # the budget the core may re-arm the gate around an unseen relevant note. + rearm = budget_verdict(action, omi_dir) + if rearm is not None: + verdict = rearm if not verdict.allow and verdict.rule_id == "repo-work-read-git-rules" and omi_dir is not None: # #241: place the governing rule text adjacent to the action it blocks. # The demand sentence stays first — the recall ceremony still runs and @@ -1409,7 +1811,7 @@ def check_action(action: dict[str, Any], omi_dir: Path | None = None) -> Verdict reason=f"omi-gate: {retrieve.suggest_message(turn_task(session), omi_dir)}", rule_id=verdict.rule_id, ) - if not verdict.allow and verdict.rule_id and verdict.rule_id != "omi-gate": + if not verdict.allow and verdict.rule_id and not verdict.rule_id.startswith("omi-gate"): compliance.log_event( compliance.KIND_DECISION, session=str(action.get("session") or ""), @@ -1589,14 +1991,54 @@ def preflight_turn(data: dict[str, Any], omi_dir: Path | None) -> str: """ session = str(data.get("session_id") or data.get("session") or "") task = str(data.get("prompt") or data.get("user_prompt") or "") + now = time.time() + last = _read_last_turn(session) + # #296: an identical continuation-shaped prompt inside the retry window is + # the harness's own API auto-retry (a burst of bare "retry" turns), not new + # work — carry the turn's gate state and injected memory instead of + # resetting and re-judging. A substantive prompt re-sent verbatim is a human + # re-asking and gets a normal (summary-only, cheap) preflight. + if ( + task + and str(last.get("prompt") or "") == task + and now - float(last.get("ts") or 0.0) <= RETRY_WINDOW_SECS + and is_continuation_prompt(task) + ): + _write_last_turn(session, prompt=task, task=str(last.get("task") or task), ts=now) + _log_continuity( + session, + tool="UserPromptSubmit", + command="", + rule_id=GATE_CARRY_RULE, + outcome="carry", + detail=f"task={task[:100]!r}", + ) + return "" + # Read the activity trail BEFORE the reset: it lives in the sentinel. + activity = _activity_text(session, omi_dir) if task else "" clear_gate(session) - begin_turn(session, task) + # #296: a continuation prompt ("retry", "go ahead", a task notification) + # carries no signal of its own — resolve it against the prior turn's task + # and what the agent has been doing, so the gate only auto-clears when the + # vault genuinely has nothing for the work in progress. + prior_task = str(last.get("task") or "") + continuation = bool(task) and is_continuation_prompt(task) + retrieval_task = ( + " ".join(p for p in (task, prior_task, activity) if p) if continuation else task + ) + begin_turn(session, retrieval_task) + _write_last_turn( + session, + prompt=task, + task=prior_task if continuation and prior_task else task, + ts=now, + ) if gate_paused() or omi_dir is None: return "" from omind import ai_usage, recall, retrieve - titles = retrieve.relevant_titles(task, omi_dir, limit=2) if task else [] + titles = retrieve.relevant_titles(retrieval_task, omi_dir, limit=2) if task else [] filename = recall.filename_for_title(omi_dir, titles[0]) if titles else None if filename is None: if task and not titles and not _miss_strict(): @@ -1635,7 +2077,7 @@ def preflight_turn(data: dict[str, Any], omi_dir: Path | None) -> str: min_terms = retrieve.preflight_min_terms() if min_terms: haystack = " ".join(str(memory.get(key) or "") for key in ("title", "summary", "content")) - if retrieve.matched_terms(task, haystack) < min_terms: + if retrieve.matched_terms(retrieval_task, haystack) < min_terms: if not _miss_strict(): record_consult(session, kind="weak-match", target=filename, relevant=False) compliance.log_event( @@ -1665,7 +2107,7 @@ def preflight_turn(data: dict[str, Any], omi_dir: Path | None) -> str: # decay exactly when it matters — re-inject the full excerpt whenever the # turn looks like an action (git/deploy/sudo/…), keep the optimization for # conversational turns. - action_shaped = bool(_ACTION_TURN_RE.search(task)) + action_shaped = bool(_ACTION_TURN_RE.search(f"{task} {prior_task}")) summary = str(memory.get("summary") or "").strip() excerpt = str(memory.get("content") or "").strip() content = ( @@ -1678,8 +2120,18 @@ def preflight_turn(data: dict[str, Any], omi_dir: Path | None) -> str: record_consult(session, kind="preflight", target=filename, relevant=True) reset_offtopic(session) _record_injected(session, filename, version) + _log_continuity( + session, + tool="UserPromptSubmit", + command="", + rule_id=GATE_PREFLIGHT_RULE, + outcome="inject", + detail=f"note={filename!r} continuation={continuation}", + ) context = ( - f"OMI turn preflight recalled [[{memory.get('title') or Path(filename).stem}]]" + "OMI turn preflight" + + (" (continuing the prior task)" if continuation else "") + + f" recalled [[{memory.get('title') or Path(filename).stem}]]" + ( " (full excerpt already injected earlier this session)" if repeated and not action_shaped diff --git a/src/omind/hooks.py b/src/omind/hooks.py index 03c1698..5b85576 100644 --- a/src/omind/hooks.py +++ b/src/omind/hooks.py @@ -58,6 +58,13 @@ def command_is_omind_hook(command: str) -> bool: """True when ``command`` is an ``omind hook ...`` invocation omind owns.""" return HOOK_MARKER in command or bool(HOOK_COMMAND_RE.search(command)) HANDLED_EVENTS = ("PostToolUse", "Stop", "SessionStart") +#: Harnesses whose post-tool hook can inject context back to the model +#: (``hookSpecificOutput.additionalContext``). Only these get the proactive +#: mid-turn recall (#296); every other harness gets the same memory as a +#: PreToolUse re-arm demand from the core, so nothing is lost — just nudged +#: later. Declared here, not inferred from the event, because a phantom +#: injection would be recorded as a consult the model never saw. +INJECTING_HARNESSES = frozenset({"claude"}) #: Hermes Agent has no SessionStart hook; it fires ``pre_llm_call`` before every #: LLM turn and consumes a ``{"context": ...}`` payload on stdout. omind installs #: this event to inject the same priming the Claude SessionStart hook does — but @@ -850,8 +857,12 @@ def run_hook( *, stdin: TextIO | None = None, stdout: TextIO | None = None, + harness: str = "", ) -> int: - """Dispatch one hook invocation. ALWAYS returns 0 so the agent never blocks.""" + """Dispatch one hook invocation. ALWAYS returns 0 so the agent never blocks. + + ``harness`` names the caller (``omind hook … --harness claude``); it only + gates the mid-turn recall injection, see :data:`INJECTING_HARNESSES`.""" try: if event_name == "SessionStart": event = read_event(stdin) @@ -926,6 +937,28 @@ def run_hook( "PostToolUse/verify.verify_consult", lambda: verify.verify_consult(event, omi_dir), ) + # #296: proactive mid-turn recall where this harness can inject + # context after a tool call. At the turn's action budget the note + # the core would otherwise DEMAND at the next PreToolUse is handed + # to the model here instead, and the budget resets. + if harness in INJECTING_HARNESSES: + context = _best_effort( + "PostToolUse/guard.midturn_context", + lambda: guard.midturn_context(event, Path(omi_dir)), + ) + if context: + sink = stdout if stdout is not None else sys.stdout + sink.write( + json.dumps( + { + "hookSpecificOutput": { + "hookEventName": "PostToolUse", + "additionalContext": context, + } + } + ) + + "\n" + ) # A freshness command that FAILED must not leave the repo marked # fresh: PreToolUse records optimistically (it cannot know the exit # code), this retracts on an explicit failure (2026-08-27 review). diff --git a/src/omind/omi-guard.opencode.js b/src/omind/omi-guard.opencode.js index f5114a7..560f40d 100644 --- a/src/omind/omi-guard.opencode.js +++ b/src/omind/omi-guard.opencode.js @@ -32,8 +32,14 @@ export const OmiGuard = async ({ $ }) => { .quiet() .nothrow(); const verdict = JSON.parse((res.stdout || "").toString().trim() || "{}"); - // Enforce only real hard-rule denies — never the consult gate. - if (verdict.allow === false && verdict.rule_id && verdict.rule_id !== "omi-gate") { + // Enforce only real hard-rule denies — never the consult gate or its + // mid-turn re-arm (the omi-gate* family), whose consult signals aren't + // verified live on OpenCode. + if ( + verdict.allow === false && + verdict.rule_id && + !String(verdict.rule_id).startsWith("omi-gate") + ) { throw new Error("OMI guard blocked this action: " + (verdict.reason || verdict.rule_id)); } } catch (e) { diff --git a/src/omind/provision.py b/src/omind/provision.py index 07f7fa5..febe54e 100644 --- a/src/omind/provision.py +++ b/src/omind/provision.py @@ -43,6 +43,9 @@ #: the consult-gate over-firing. Precision, not volume, decides whether the guard #: is helping — see the compliance_log check. _DENY_RATE_WARN_PCT = 25 +#: Window and threshold for the consult-gate continuity check (#296). +_CONTINUITY_WINDOW_DAYS = 7 +_AUTO_CLEAR_WARN_PCT = 40.0 #: The stable user-install path for the omind executable. On a `uv tool install` #: box this is a symlink into the tool env that uv retargets on every upgrade, @@ -712,9 +715,13 @@ def _hook_command(self, event: str) -> str: # which provision uses to find/replace omind's own entries. # Both values are quoted: the hook string goes through a shell, and an # unquoted folder like "My Memory" word-splits into a stray positional. + # Claude Code's PostToolUse can inject context back to the model, so + # its journal hook also carries the mid-turn recall (#296). The flag is + # per harness on purpose: see hooks.INJECTING_HARNESSES. + harness = " --harness claude" if event == "PostToolUse" else "" return ( f'{omind_exe} hook {event} --vault "{self.config.vault}" ' - f'--folder "{self.config.folder}"' + f'--folder "{self.config.folder}"{harness}' ) def _omind_hook_entries(self) -> dict[str, list[dict[str, Any]]]: @@ -1691,6 +1698,31 @@ def _diagnose_enforcement() -> list[CheckResult]: ) ) + # #296: does memory actually reach the agent's turns? Auto-clears are the + # turns that started with the gate open and NOTHING injected; a session made + # of continuation prompts used to auto-clear on nearly every turn, silently. + continuity = compliance.gate_continuity(days=_CONTINUITY_WINDOW_DAYS) + if continuity["turns"]: + pct = continuity["auto_clear_pct"] + status = "warn" if pct >= _AUTO_CLEAR_WARN_PCT else "ok" + detail = ( + f"consult gate ({_CONTINUITY_WINDOW_DAYS}d): {continuity['turns']} turn(s), " + f"{continuity['injected']} with memory injected, " + f"{continuity['auto_cleared']} auto-cleared ({pct:.0f}%), " + f"{continuity['carried']} retry carry; mid-turn: " + f"{continuity['rearm_injects']} injection(s), " + f"{continuity['rearm_denies']} re-arm(s), " + f"{continuity['rearm_no_match']} no-match" + ) + if status == "warn": + detail += ( + f" — over {_AUTO_CLEAR_WARN_PCT:.0f}% of turns start with no memory. " + "Check `omind guard log` for the auto-cleared prompts: continuation " + "prompts should resolve against the prior task (#296); a vault with " + "nothing on the current work is the other explanation" + ) + results.append(CheckResult("gate_continuity", status, detail)) + summary = compliance.summary() if summary["total"]: top = ", ".join(f"{rid}×{n}" for rid, n in summary["top_rules"][:3]) or "none" diff --git a/src/omind/retrieve.py b/src/omind/retrieve.py index 0867815..80ad6c4 100644 --- a/src/omind/retrieve.py +++ b/src/omind/retrieve.py @@ -224,6 +224,13 @@ def _tokens(text: str) -> set[str]: return {_stem(w) for w in _WORD_RE.findall(text.lower()) if w not in _STOPWORDS and len(w) > 2} +def term_count(text: str) -> int: + """How many distinct meaningful terms ``text`` carries (stopwords and short + tokens dropped, stemmed) — the guard's "is this prompt a continuation" + signal shares retrieval's definition of a meaningful term.""" + return len(_tokens(text)) + + def overlap_score(task: str, text: str) -> float: """Fraction of the task's meaningful terms covered by ``text`` (0..1). diff --git a/src/omind/server.py b/src/omind/server.py index c7cb4de..e89e2e6 100644 --- a/src/omind/server.py +++ b/src/omind/server.py @@ -15,24 +15,36 @@ from __future__ import annotations import contextlib +import functools import logging import os import sys -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Callable from contextlib import asynccontextmanager from pathlib import Path -from typing import Any +from typing import Any, TypeVar import anyio import mcp.types as mcp_types from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.exceptions import ToolError from mcp.shared.message import SessionMessage from omind import graph, searchindex from omind.help_system import render_help from omind.recall import DEFAULT_RECALL_CHARS, compact_recall -from omind.store import ActionItem, NoteFields, OmiStore, _clean_agent, parse_note +from omind.store import ( + ActionItem, + NoteConflictError, + NoteError, + NoteFields, + OmiStore, + _clean_agent, + parse_note, +) + +_T = TypeVar("_T") SERVER_NAME = "omi" @@ -187,6 +199,36 @@ def _parse_action_items(items: list[str]) -> list[ActionItem]: return parsed +#: Failures a tool ANTICIPATES — a missing note, an unsafe name, a stale +#: version token, a bad graph argument. Their text is the whole point: the +#: version-conflict message is what tells an agent to re-read before writing. +#: mcp >= 2.1 keeps a deliberate ``ToolError``'s text but treats any other +#: exception as a crash and hands the client only ``Error executing tool `` +#: (#294), so these are re-raised as ``ToolError`` at the tool boundary. A real +#: crash (OSError, a bug) stays masked, as the SDK intends. +_ANTICIPATED_ERRORS: tuple[type[BaseException], ...] = ( + NoteError, + NoteConflictError, + ValueError, +) + + +def _anticipated(fn: Callable[..., _T]) -> Callable[..., _T]: + """Re-raise a tool's anticipated domain failures as a deliberate ``ToolError`` + so the message reaches the caller under every mcp 2.x.""" + + @functools.wraps(fn) + def wrapper(*args: Any, **kwargs: Any) -> _T: + try: + return fn(*args, **kwargs) + except ToolError: + raise + except _ANTICIPATED_ERRORS as exc: + raise ToolError(str(exc)) from exc + + return wrapper + + def build_server(omi_dir: Path | str, node_id: str | None = None) -> MCPServer: """Build the node MCP server over one OMI folder. @@ -198,7 +240,20 @@ def build_server(omi_dir: Path | str, node_id: str | None = None) -> MCPServer: # the mesh daemon, not just this server's tools. store = OmiStore(omi_dir, node_id=node_id) - mcp = MCPServer(SERVER_NAME, instructions=_INSTRUCTIONS) + server = MCPServer(SERVER_NAME, instructions=_INSTRUCTIONS) + + class _Registrar: + """``mcp.tool(...)`` with every tool body wrapped by :func:`_anticipated`.""" + + def tool(self, *args: Any, **kwargs: Any) -> Callable[[Callable[..., _T]], Any]: + register = server.tool(*args, **kwargs) + + def decorate(fn: Callable[..., _T]) -> Any: + return register(_anticipated(fn)) + + return decorate + + mcp = _Registrar() # The five graph tools each rebuilt the whole [[wikilink]] graph from disk # (a full-vault read+parse) on every call. Cache it, invalidated by a cheap @@ -600,7 +655,7 @@ def graph_tool( ) -> dict[str, object]: return _graph_query(op, source, target, limit, offset) - return mcp + return server def run_node(omi_dir: Path, node_id: str | None = None) -> int: diff --git a/tests/test_compliance.py b/tests/test_compliance.py index 15f0eff..af91cba 100644 --- a/tests/test_compliance.py +++ b/tests/test_compliance.py @@ -197,3 +197,40 @@ def test_summary_separates_ceremony_from_blocking_denials() -> None: assert rollup["ceremony_denies"] == 2 assert rollup["blocking_denies"] == 1 # only the real refusal assert "sudo-use-fleet-sudo" not in compliance.CEREMONY_RULES + + +def test_gate_continuity_rolls_up_the_consult_gate_decisions() -> None: + """#296: the doctor's continuity check needs turns-with-memory vs + auto-cleared turns, plus what the mid-turn budget did.""" + for rule_id, outcome in ( + ("omi-gate-preflight", "inject"), + ("omi-gate-preflight", "inject"), + ("omi-gate-weak-match", "auto-clear"), + ("omi-gate-no-match", "auto-clear"), + ("omi-gate-carry", "carry"), + ("omi-gate-rearm", "deny"), + ("omi-gate-rearm", "inject"), + ("omi-gate-rearm-no-match", "auto-clear"), + ("public-main-push", "deny"), # a real (non-ceremony) refusal + ): + compliance.log_event( + compliance.KIND_DECISION, + session="s", + tool="t", + rule_id=rule_id, + severity="soft", + outcome=outcome, + ) + rollup = compliance.gate_continuity(days=7) + assert rollup["turns"] == 4 + assert rollup["injected"] == 2 + assert rollup["auto_cleared"] == 2 + assert rollup["auto_clear_pct"] == 50.0 + assert rollup["carried"] == 1 + assert rollup["rearm_denies"] == 1 + assert rollup["rearm_injects"] == 1 + assert rollup["rearm_no_match"] == 1 + # Old events fall outside the window. + assert compliance.gate_continuity(days=0)["turns"] == 0 + # The re-arm is a ceremony, not a blocking deny, in the headline summary. + assert compliance.summary()["blocking_denies"] == 1 diff --git a/tests/test_guard.py b/tests/test_guard.py index f331392..4b3fa95 100644 --- a/tests/test_guard.py +++ b/tests/test_guard.py @@ -1652,3 +1652,242 @@ def test_preflight_adds_second_title_summary_only(tmp_path: Path) -> None: {"session_id": "second-2", "prompt": "token budget usage bounds"}, omi ) assert "Also possibly relevant" not in economy # skipped on economy + + +# --------------------------------------------------------------------------- +# #296 — consult continuity: continuation prompts, retry carry, action budget +# --------------------------------------------------------------------------- + + +def _vault_with(tmp_path: Path, *notes: tuple[str, str, str]) -> Path: + from omind.store import NoteFields, OmiStore + + omi = tmp_path / "OMI" + omi.mkdir(exist_ok=True) + store = OmiStore(omi) + for title, summary, details in notes: + store.create_note(NoteFields(title=title, summary=summary, details=details)) + return omi + + +def test_continuation_prompt_is_recognised_by_shape() -> None: + assert guard.is_continuation_prompt("retry") + assert guard.is_continuation_prompt("Yes please") + assert guard.is_continuation_prompt("go ahead and do it") + assert guard.is_continuation_prompt("agent done") + assert not guard.is_continuation_prompt("") # empty stays strict elsewhere + assert not guard.is_continuation_prompt("is there a vpn turned on for this laptop?") + assert not guard.is_continuation_prompt("reduce the OMI token usage in the preflight") + + +def test_continuation_prompt_is_resolved_against_the_prior_turns_task(tmp_path: Path) -> None: + """The auto-clear hole: "go ahead" after a real task used to search the + vault for "go ahead", miss, and open the gate with nothing injected.""" + omi = _vault_with( + tmp_path, + ("Token Usage Strategy", "Keep OMI token usage bounded.", "Use compact recall."), + ) + sid = "cont-1" + first = guard.preflight_turn({"session_id": sid, "prompt": "reduce OMI token usage"}, omi) + assert "[[Token Usage Strategy]]" in first + follow = guard.preflight_turn({"session_id": sid, "prompt": "go ahead"}, omi) + assert "continuing the prior task" in follow + assert "[[Token Usage Strategy]]" in follow + assert guard.consulted_this_turn(sid) + # The captured task the verifier scores against is the composite, and the + # remembered substantive task survives a chain of continuations. + assert "token usage" in guard.turn_task(sid).casefold() + assert guard._read_last_turn(sid)["task"] == "reduce OMI token usage" + events = compliance.read_events() + assert events[-1]["rule_id"] == guard.GATE_PREFLIGHT_RULE + assert events[-1]["outcome"] == "inject" + assert "continuation=True" in events[-1]["detail"] + + +def test_continuation_with_nothing_prior_keeps_the_old_auto_clear(tmp_path: Path) -> None: + omi = _vault_with(tmp_path, ("Ghidra decompiler retry budget", "Retry the decompile.", "")) + context = guard.preflight_turn({"session_id": "cont-fresh", "prompt": "retry"}, omi) + assert "weak memory match" in context + assert compliance.read_events()[-1]["rule_id"] == guard.GATE_WEAK_MATCH_RULE + + +def test_identical_retry_inside_the_window_carries_the_gate_state(tmp_path: Path) -> None: + """A burst of bare "retry" turns is Claude Code auto-retrying an API error; + each one used to reset the gate, search for "retry", and auto-clear.""" + omi = _vault_with(tmp_path, ("Token Usage Strategy", "Keep OMI token usage bounded.", "")) + sid = "carry-1" + guard.preflight_turn({"session_id": sid, "prompt": "reduce OMI token usage"}, omi) + guard.mark_consulted(sid) + guard.count_action(sid, "pytest tests/") + before = guard._read_sentinel(sid) + first_retry = guard.preflight_turn({"session_id": sid, "prompt": "retry"}, omi) + assert first_retry # the first "retry" is judged like any continuation + assert guard.consulted_this_turn(sid) + second_retry = guard.preflight_turn({"session_id": sid, "prompt": "retry"}, omi) + assert second_retry == "" # carried: no reset, no re-judging, no injection + assert guard.consulted_this_turn(sid) + events = compliance.read_events() + assert events[-1]["rule_id"] == guard.GATE_CARRY_RULE + assert events[-1]["outcome"] == "carry" + # A substantive prompt re-sent verbatim is a human re-asking, not a retry. + again = guard.preflight_turn({"session_id": sid, "prompt": "reduce OMI token usage"}, omi) + assert "[[Token Usage Strategy]]" in again + assert before # (sentinel existed before the retries) + + +def test_action_budget_rearms_the_gate_around_an_unseen_relevant_note( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + omi = _vault_with( + tmp_path, + ( + "telesto deploy runbook", + "How to deploy the telesto services safely.", + "Stop the telesto services before a deploy, then restart them.", + ), + ) + monkeypatch.setenv(guard.ACTION_BUDGET_ENV, "3") + sid = "budget-1" + # The turn's opening consult was about something else entirely. + guard.preflight_turn({"session_id": sid, "prompt": "list the open pull requests"}, omi) + guard.mark_consulted(sid) + action = {"tool": "Edit", "file_path": "/srv/telesto/deploy.sh", "session": sid} + for _ in range(3): + assert guard.check_action(action, omi_dir=omi).allow + assert guard.actions_since_consult(sid) == 3 + assert "deploy.sh" in " ".join(guard.action_trail(sid)) + # The 4th action: the work has drifted onto telesto deploys; an unseen + # relevant note exists → the gate re-arms and demands exactly that note. + verdict = guard.check_action(action, omi_dir=omi) + assert not verdict.allow + assert verdict.rule_id == guard.GATE_REARM_RULE + assert "[[telesto deploy runbook]]" in verdict.reason + assert "recall-note" in verdict.reason + assert "Governing memory (excerpt)" in verdict.reason + assert guard.demanded_note(sid) == "telesto deploy runbook.md" + assert not guard.consulted_this_turn(sid) + assert guard.rearm_count(sid) == 1 + events = compliance.read_events() + assert events[-1]["rule_id"] == guard.GATE_REARM_RULE + assert events[-1]["severity"] == "soft" # a ceremony, never a hard deny + # Consulting the demanded note clears it and restarts the budget. + consult = { + "is_omi_consult": True, + "consult_target": "telesto deploy runbook.md", + "consult_kind": "read", + "session": sid, + } + assert guard.check_action(consult, omi_dir=omi).allow + assert guard.check_action(action, omi_dir=omi).allow + assert guard.actions_since_consult(sid) == 1 + # The same note is never re-demanded this session: budget exhausts to a + # logged no-match auto-clear instead. + for _ in range(3): # actions 2, 3, then the budget hit: no candidate → reset + assert guard.check_action(action, omi_dir=omi).allow + assert compliance.read_events()[-1]["rule_id"] == guard.GATE_REARM_NO_MATCH_RULE + assert guard.actions_since_consult(sid) == 1 + assert guard.check_action(action, omi_dir=omi).allow + assert guard.actions_since_consult(sid) == 2 + + +def test_action_budget_skips_notes_already_injected_this_session( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + omi = _vault_with( + tmp_path, ("telesto deploy runbook", "Deploy the telesto services.", "Restart telesto.") + ) + monkeypatch.setenv(guard.ACTION_BUDGET_ENV, "2") + sid = "budget-seen" + # Injected at turn start (the preflight found it) — it is already in context. + context = guard.preflight_turn({"session_id": sid, "prompt": "deploy telesto services"}, omi) + assert "[[telesto deploy runbook]]" in context + action = {"tool": "Bash", "command": "systemctl restart telesto", "session": sid} + for _ in range(4): + assert guard.check_action(action, omi_dir=omi).allow + assert guard.rearm_count(sid) == 0 + assert compliance.read_events()[-1]["rule_id"] == guard.GATE_REARM_NO_MATCH_RULE + + +def test_action_budget_is_capped_per_turn_and_reset_by_the_turn_start( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + omi = _vault_with( + tmp_path, + ("telesto deploy runbook", "Deploy the telesto services.", "Restart telesto."), + ("telesto backup policy", "Back up telesto before any deploy.", "Snapshot telesto."), + ) + monkeypatch.setenv(guard.ACTION_BUDGET_ENV, "1") + monkeypatch.setenv(guard.MAX_REARM_ENV, "1") + sid = "budget-cap" + guard.begin_turn(sid, "restart the telesto services after the deploy") + guard.mark_consulted(sid) + action = {"tool": "Bash", "command": "systemctl restart telesto", "session": sid} + assert guard.check_action(action, omi_dir=omi).allow + blocked = guard.check_action(action, omi_dir=omi) + assert not blocked.allow and blocked.rule_id == guard.GATE_REARM_RULE + guard.mark_consulted(sid) # (any consult re-opens; the cap is now reached) + for _ in range(4): + assert guard.check_action(action, omi_dir=omi).allow # never re-gated again + guard.begin_turn(sid, "next turn") + assert guard.rearm_count(sid) == 0 + + +def test_action_budget_ignores_consults_inert_commands_and_a_paused_gate( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + omi = _vault_with(tmp_path, ("telesto deploy runbook", "Deploy telesto.", "Restart telesto.")) + monkeypatch.setenv(guard.ACTION_BUDGET_ENV, "1") + sid = "budget-skip" + guard.begin_turn(sid, "deploy telesto") + guard.mark_consulted(sid) + assert guard.check_action({"command": "pwd", "session": sid}, omi_dir=omi).allow + assert guard.actions_since_consult(sid) == 0 # inert commands don't count + assert guard.check_action({"command": "ls -la", "session": sid}, omi_dir=omi).allow + assert guard.actions_since_consult(sid) == 1 + guard.pause_gate(60) + try: + assert guard.check_action({"command": "ls -la", "session": sid}, omi_dir=omi).allow + assert guard.check_action({"command": "ls -la", "session": sid}, omi_dir=omi).allow + finally: + guard.resume_gate() + assert guard.rearm_count(sid) == 0 + monkeypatch.setenv(guard.ACTION_BUDGET_ENV, "0") # 0 disables the budget + for _ in range(3): + assert guard.check_action({"command": "ls -la", "session": sid}, omi_dir=omi).allow + + +def test_midturn_context_injects_the_candidate_and_resets_the_budget( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + omi = _vault_with( + tmp_path, + ( + "telesto deploy runbook", + "How to deploy the telesto services safely.", + "Stop the telesto services before a deploy, then restart them.", + ), + ) + monkeypatch.setenv(guard.ACTION_BUDGET_ENV, "2") + sid = "midturn-1" + guard.begin_turn(sid, "restart the telesto services after the deploy") + guard.mark_consulted(sid) + event = {"session_id": sid, "tool_name": "Bash", "tool_input": {"command": "ls"}} + assert guard.midturn_context(event, omi) == "" # under budget: nothing + for _ in range(2): + guard.count_action(sid, "systemctl restart telesto") + context = guard.midturn_context(event, omi) + assert "OMI mid-turn recall" in context + assert "[[telesto deploy runbook]]" in context + assert "Stop the telesto services" in context + assert guard.actions_since_consult(sid) == 0 + assert guard.consulted_this_turn(sid) + assert guard.consults(sid)[-1]["kind"] == "midturn" + assert compliance.read_events()[-1]["outcome"] == "inject" + # Injected once, the note is "seen": the next budget hit finds nothing new + # and the PreToolUse path never re-arms around it either. + for _ in range(2): + guard.count_action(sid, "systemctl restart telesto") + assert guard.midturn_context(event, omi) == "" + assert compliance.read_events()[-1]["rule_id"] == guard.GATE_REARM_NO_MATCH_RULE + action = {"tool": "Bash", "command": "systemctl restart telesto", "session": sid} + assert guard.check_action(action, omi_dir=omi).allow diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 0e97eca..09057d4 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -790,3 +790,56 @@ def test_session_start_no_banner_below_threshold(tmp_path: Path) -> None: _write_failure_log([f"{stamp} append_entry(/v/OMI): PermissionError(1, 'op')"]) ctx = hooks.build_session_start_context(tmp_path) assert "MEMORY WRITES ARE FAILING" not in ctx + + +def test_post_tool_use_injects_midturn_recall_only_for_an_injecting_harness( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """#296: the mid-turn recall rides Claude's PostToolUse ``additionalContext``; + a harness that cannot inject after a tool call gets nothing here (its core + re-arm demand at PreToolUse carries the same memory instead).""" + from omind import guard + from omind.store import NoteFields + + omi = tmp_path / "OMI" + omi.mkdir() + OmiStore(omi).create_note( + NoteFields( + title="telesto deploy runbook", + summary="How to deploy the telesto services safely.", + details="Stop the telesto services before a deploy, then restart them.", + ) + ) + monkeypatch.setenv(guard.ACTION_BUDGET_ENV, "1") + sid = "hook-midturn" + guard.begin_turn(sid, "restart the telesto services after the deploy") + guard.mark_consulted(sid) + guard.count_action(sid, "systemctl restart telesto") + event = { + "hook_event_name": "PostToolUse", + "session_id": sid, + "tool_name": "Bash", + "tool_input": {"command": "systemctl restart telesto"}, + } + silent = io.StringIO() + assert ( + hooks.run_hook("PostToolUse", omi, stdin=io.StringIO(json.dumps(event)), stdout=silent) + == 0 + ) + assert silent.getvalue() == "" # unknown harness: never a phantom injection + assert guard.actions_since_consult(sid) == 1 + + out = io.StringIO() + rc = hooks.run_hook( + "PostToolUse", + omi, + stdin=io.StringIO(json.dumps(event)), + stdout=out, + harness="claude", + ) + assert rc == 0 + payload = json.loads(out.getvalue()) + specific = payload["hookSpecificOutput"] + assert specific["hookEventName"] == "PostToolUse" + assert "[[telesto deploy runbook]]" in specific["additionalContext"] + assert guard.actions_since_consult(sid) == 0 diff --git a/tests/test_server.py b/tests/test_server.py index 343478f..7982df5 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -531,3 +531,34 @@ def test_recall_note_warns_about_a_conflicting_memory(server: MCPServer) -> None plain = call(server, "recall-note", {"name": "Older.md"}) assert "conflicts_with" not in plain and "confidence" not in plain assert "warning" not in plain + + +def test_anticipated_domain_errors_surface_as_tool_errors_with_their_text() -> None: + """#294: mcp >= 2.1 masks any non-``ToolError`` exception as a bare + ``Error executing tool ``. The tool boundary re-raises the failures a + tool anticipates as a deliberate ``ToolError`` so the text — the version + conflict's "re-read before writing" in particular — still reaches the caller.""" + from omind.server import _anticipated + from omind.store import NoteConflictError, NoteNotFoundError + + @_anticipated + def conflict() -> None: + raise NoteConflictError("note changed on disk; re-read before writing") + + @_anticipated + def missing() -> None: + raise NoteNotFoundError("note not found: 'x.md'") + + @_anticipated + def crash() -> None: + raise OSError("disk on fire") + + with pytest.raises(ToolError, match="changed on disk") as info: + conflict() + assert isinstance(info.value.__cause__, NoteConflictError) + with pytest.raises(ToolError, match="not found"): + missing() + # A genuine crash is NOT anticipated: it stays an OSError for the SDK to + # mask and log with its traceback, exactly as the SDK intends. + with pytest.raises(OSError, match="disk on fire"): + crash() diff --git a/uv.lock b/uv.lock index 9decf12..3b3ab05 100644 --- a/uv.lock +++ b/uv.lock @@ -1852,7 +1852,7 @@ wheels = [ [[package]] name = "mcp" -version = "2.0.0" +version = "2.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1870,22 +1870,22 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/33/32d4dff2c95bb5d897c3ef4c83649a08996b17b58f0a326d2495d4c81179/mcp-2.0.0.tar.gz", hash = "sha256:0f440e735c13ece8bb19bc62cf0b86f4313448432fbb77d35e14034f4e050728", size = 1662284, upload-time = "2026-07-28T13:45:32.346Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/6e/21fb8e5d579dbe21d96ea4d5034200d46d8bdf2261053b5bd041f3c2f612/mcp-2.1.1.tar.gz", hash = "sha256:50b7ba1ebbe117008ea7bdd288234043e69c20b403d6851d19661e6d431a75ef", size = 3984589, upload-time = "2026-08-25T16:14:02.376Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/67/72/7d7897418912c1d12e87556630dfb7bf0eac71160e9bef8b447960804ee3/mcp-2.0.0-py3-none-any.whl", hash = "sha256:1cb4c75d2d2c7b8c1d756355e5d82a39f2822cc7f13e22a2051d7ca3592349d6", size = 349980, upload-time = "2026-07-28T13:45:28.853Z" }, + { url = "https://files.pythonhosted.org/packages/50/af/8644cc5fa26a59afd2df2e98eeb19e72926887fa4b7441aba4ff661140db/mcp-2.1.1-py3-none-any.whl", hash = "sha256:1c6c31c5d6471c58db76af3af8af67f46d11d01f0a59077d0a308cbdb3d3e915", size = 357912, upload-time = "2026-08-25T16:13:59.024Z" }, ] [[package]] name = "mcp-types" -version = "2.0.0" +version = "2.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/56/9b8e1c152f61f6c6b07c4b5896c88c7d0ae90bac6ee6306f852fcc5c1eb0/mcp_types-2.0.0.tar.gz", hash = "sha256:d7d939b9285c9961ae8866ba75ef85da34d12bafe276efbf4eb6a131786d8379", size = 66632, upload-time = "2026-07-28T13:45:33.804Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/dd/1c4417dc0b722c23a1669032d5f044e41170fe5d4773b488a50fcce98c32/mcp_types-2.1.1.tar.gz", hash = "sha256:77dcbe48fba73cca71a673f2646a5f037a017b7a0a07ac89cec1113028890eda", size = 66674, upload-time = "2026-08-25T16:14:03.861Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/4c/c78d78c3d52b0ac594ad7cc8ef5972adfe070e3597a8a4c6ce0cd39196ea/mcp_types-2.0.0-py3-none-any.whl", hash = "sha256:6b2de797ca2797f568b79529e1b25948e34de511bcc0bd82fef1039a6d1b8eb0", size = 69649, upload-time = "2026-07-28T13:45:30.713Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/242e63c510f4a17381f55b1549a3f94f5687a0595984febd2b6f87a687a0/mcp_types-2.1.1-py3-none-any.whl", hash = "sha256:26f9f7f03f2a5730717a5b98e2ab7eb640ac352d05a00cdc725c311864778295", size = 69656, upload-time = "2026-08-25T16:14:00.667Z" }, ] [[package]]