diff --git a/CHANGELOG.md b/CHANGELOG.md index a1c9cd2..4874823 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [8.4.0] - 2026-08-14 + +### Added +- **Governing rule text now rides inside the guard's own block message** + (#241). When repo work is blocked pending the git-rules consult, the deny + carries the note's summary and leading excerpt (capped at 1,600 chars) under + a "Governing memory (excerpt)" divider — an instruction adjacent to the + action wins attention that one injected 200 turns earlier has lost. The + demand sentence stays first and the recall ceremony still feeds consult + telemetry; a missing note degrades to the bare demand. +- **Turn preflight surfaces a runner-up match.** `relevant_titles` now returns + two; the second appears as "Also possibly relevant: [[title]] — summary" + only (never a body), and is skipped on the economy profile. + +### Changed +- **Action-shaped turns re-inject the full preflight excerpt.** The repeated- + note summary-only downgrade now applies only to conversational turns; a turn + matching git/push/commit/merge/deploy/release/sudo/rm/delete/publish/ + provision gets the full excerpt every time (#241). + ## [8.3.2] - 2026-08-14 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 50bf807..3d505b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "omind" -version = "8.3.2" +version = "8.4.0" description = "Reproduce the OMI/Obsidian memory integration for AI agents, plus a local web app to view, edit, and add memory entries." readme = "README.md" requires-python = ">=3.10" diff --git a/src/omind/__init__.py b/src/omind/__init__.py index e6773c2..4d920d4 100644 --- a/src/omind/__init__.py +++ b/src/omind/__init__.py @@ -2,4 +2,4 @@ # Copyright 2026 Aaron K. Clark """omind — OMI/Obsidian memory tooling for AI agents.""" -__version__ = "8.3.2" +__version__ = "8.4.0" diff --git a/src/omind/guard.py b/src/omind/guard.py index 8f98cd3..a04fe49 100644 --- a/src/omind/guard.py +++ b/src/omind/guard.py @@ -1224,6 +1224,26 @@ def decide(action: dict[str, Any]) -> Verdict: return Verdict(allow=False, reason=f"omi-gate: {GATE_MESSAGE}", rule_id="omi-gate") +#: Hard ceiling for the embedded excerpt so a huge note can't bloat every deny. +_EXCERPT_CAP = 1_600 + + +def _governing_excerpt(omi_dir: Path | str, note: str) -> str: + """Summary + leading excerpt of ``note``, capped, for embedding in a deny + message (#241). Best-effort: any failure returns ``""`` — the deny still + stands on its demand sentence alone.""" + try: + from omind import recall + + memory = recall.compact_recall(omi_dir, note, max_chars=1_200) + summary = str(memory.get("summary") or "").strip() + content = str(memory.get("content") or "").strip() + text = "\n\n".join(part for part in (summary, content) if part) + return text[:_EXCERPT_CAP] + except Exception: + return "" + + def check_action(action: dict[str, Any], omi_dir: Path | None = None) -> Verdict: """Decide an action and log a real policy-rule deny to the compliance log. @@ -1232,6 +1252,25 @@ def check_action(action: dict[str, Any], omi_dir: Path | None = None) -> Verdict routine ``omi-gate`` "you didn't consult" deny is friction, not logged. """ verdict = decide(action) + 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 + # feeds consult telemetry — but the rule itself rides along, because an + # instruction next to the action wins attention that one injected 200 + # turns earlier has lost. + excerpt = _governing_excerpt(omi_dir, GIT_RULES_NOTE) + if excerpt: + verdict = Verdict( + allow=False, + reason=( + f"{verdict.reason}\n\n--- Governing memory (excerpt) ---\n{excerpt}" + ), + rule_id=verdict.rule_id, + ) if not verdict.allow and verdict.rule_id == "omi-gate" and omi_dir is not None: from omind import retrieve @@ -1360,6 +1399,37 @@ def _miss_strict() -> bool: return bool(os.environ.get(MISS_STRICT_ENV)) +#: Turns that look like an action rather than a conversation (#241). Fixed by +#: design — an env knob here would be one more thing that silently degrades. +_ACTION_TURN_RE = re.compile( + r"\b(git|push|commit|merge|deploy|release|sudo|rm|delete|publish|provision)\b", + re.IGNORECASE, +) + + +def _second_title_line(omi_dir: Path | str, titles: list[str], first: str) -> str: + """Title + summary of the runner-up preflight match, never a full body + (#241). Skipped on the economy profile, where the preflight budget is too + tight for a second note. Best-effort — a failure adds nothing.""" + if len(titles) < 2: + return "" + try: + from omind import ai_usage, recall + + if ai_usage.policy(omi_dir).preflight_chars < 2_000: + return "" + filename = recall.filename_for_title(omi_dir, titles[1]) + if filename is None or filename == first: + return "" + memory = recall.compact_recall(omi_dir, filename, max_chars=recall.MIN_RECALL_CHARS) + title = str(memory.get("title") or Path(filename).stem) + summary = str(memory.get("summary") or "").strip() + line = f"\n\nAlso possibly relevant: [[{title}]]" + return line + (f" — {summary}" if summary else "") + except Exception: + return "" + + def preflight_turn(data: dict[str, Any], omi_dir: Path | None) -> str: """Prepare one turn with compact relevant memory and satisfy the soft gate. @@ -1383,7 +1453,7 @@ def preflight_turn(data: dict[str, Any], omi_dir: Path | None) -> str: from omind import ai_usage, recall, retrieve - titles = retrieve.relevant_titles(task, omi_dir, limit=1) if task else [] + titles = retrieve.relevant_titles(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(): @@ -1416,9 +1486,14 @@ def preflight_turn(data: dict[str, Any], omi_dir: Path | None) -> str: ) version = str(memory.get("version") or "") repeated = _injected_versions(session).get(filename) == version + # #241: the summary-only optimization for repeated notes loses to attention + # 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)) summary = str(memory.get("summary") or "").strip() excerpt = str(memory.get("content") or "").strip() - content = summary if repeated else "\n\n".join( + content = summary if repeated and not action_shaped else "\n\n".join( part for part in (summary, excerpt) if part and part != summary ) if not content: @@ -1428,12 +1503,17 @@ def preflight_turn(data: dict[str, Any], omi_dir: Path | None) -> str: _record_injected(session, filename, version) context = ( f"OMI turn preflight recalled [[{memory.get('title') or Path(filename).stem}]]" - + (" (full excerpt already injected earlier this session)" if repeated else "") + + ( + " (full excerpt already injected earlier this session)" + if repeated and not action_shaped + else "" + ) + ". This is a standing operator instruction/memory relevant to this " "turn — apply it unless the user's current message explicitly " "overrides it. Silence is not an override.\n\n" + content ) + context += _second_title_line(omi_dir, titles, filename) ai_usage.record_context(omi_dir, "recall", len(context), session_id=session) return context diff --git a/tests/test_guard.py b/tests/test_guard.py index c4337d5..36ef465 100644 --- a/tests/test_guard.py +++ b/tests/test_guard.py @@ -1464,3 +1464,100 @@ def test_truncated_demanded_read_keeps_git_rules_unconsulted() -> None: assert guard._has_consulted_git_rules(session) # full re-read clears it guard.begin_turn(session, "next turn") assert guard.incomplete_consult(session) == "" # per-turn state resets + + +# -- #241: rule text adjacent to the action ----------------------------------- + + +def test_repo_block_message_embeds_governing_excerpt(tmp_path: Path) -> None: + from omind.store import NoteFields, OmiStore + + omi = tmp_path / "OMI" + omi.mkdir() + OmiStore(omi).create_note( + NoteFields( + title=guard.GIT_RULES_NOTE, + summary="Branch plus PR on public repos; private goes straight to main.", + details="EXCEPTION TABLE: repo-x pushes directly to master.", + ) + ) + session = "adj1" + guard.begin_turn(session, "push it") + verdict = guard.check_action( + {"tool": "Bash", "command": "git push origin main", "session": session}, + omi_dir=omi, + ) + assert not verdict.allow + assert "Governing memory (excerpt)" in verdict.reason + assert "Branch plus PR on public repos" in verdict.reason + assert "EXCEPTION TABLE" in verdict.reason + assert verdict.reason.index("ACTION BLOCKED") < verdict.reason.index("Governing") + assert len(verdict.reason) < 2_400 # demand + capped excerpt + + +def test_repo_block_message_survives_a_missing_note(tmp_path: Path) -> None: + omi = tmp_path / "OMI" + omi.mkdir() + guard.begin_turn("adj2", "push it") + verdict = guard.check_action( + {"tool": "Bash", "command": "git push origin main", "session": "adj2"}, + omi_dir=omi, + ) + assert not verdict.allow # the demand still stands on its own + assert "Governing memory" not in verdict.reason + + +def test_preflight_reinjects_full_excerpt_on_action_shaped_turns(tmp_path: Path) -> None: + from omind.store import NoteFields, OmiStore + + omi = tmp_path / "OMI" + omi.mkdir() + OmiStore(omi).create_note( + NoteFields( + title="Deploy Rules", + summary="Deploys are gated.", + details="Always deploy from a tagged release build.", + ) + ) + event = {"session_id": "act-turn", "prompt": "deploy the release build"} + first = guard.preflight_turn(event, omi) + assert "tagged release" in first + repeated = guard.preflight_turn(event, omi) + # Action-shaped turn: full excerpt again, no summary-only downgrade (#241). + assert "tagged release" in repeated + assert "already injected earlier this session" not in repeated + + +def test_preflight_adds_second_title_summary_only(tmp_path: Path) -> None: + from omind import ai_usage + from omind.store import NoteFields, OmiStore + + omi = tmp_path / "OMI" + omi.mkdir() + store = OmiStore(omi) + store.create_note( + NoteFields( + title="Token Budget Alpha", + summary="primary token budget note", + details="ALPHA-BODY token budget usage bounds", + ) + ) + store.create_note( + NoteFields( + title="Token Budget Beta", + summary="secondary token budget note", + details="BETA-BODY token budget usage bounds", + ) + ) + ai_usage.set_profile(omi, "full") + context = guard.preflight_turn( + {"session_id": "second-1", "prompt": "token budget usage bounds"}, omi + ) + assert "Also possibly relevant: [[" in context + assert "BETA-BODY" not in context or "ALPHA-BODY" not in context # runner-up is summary-only + ai_usage.set_profile(omi, "economy") + guard.clear_gate("second-2") + economy = guard.preflight_turn( + {"session_id": "second-2", "prompt": "token budget usage bounds"}, omi + ) + assert "Also possibly relevant" not in economy # skipped on economy diff --git a/uv.lock b/uv.lock index 54a55ee..1dde09e 100644 --- a/uv.lock +++ b/uv.lock @@ -2354,7 +2354,7 @@ wheels = [ [[package]] name = "omind" -version = "8.3.2" +version = "8.4.0" source = { editable = "." } dependencies = [ { name = "cryptography" },