Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 42 additions & 21 deletions src/omind/guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
MISS_STRICT_ENV = "OMI_GATE_MISS_STRICT"
#: 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"
GIT_RULES_NOTE = "Operational Rules - Git Repos and Secrets"
GIT_RULES_MESSAGE = (
"ACTION BLOCKED. Next call OMI MCP `recall-note` with "
Expand Down Expand Up @@ -360,9 +361,7 @@ def mark_consulted(session: str) -> None:
_write_sentinel(session, data)


def record_consult(
session: str, *, kind: str, target: str, relevant: bool | None = None
) -> None:
def record_consult(session: str, *, kind: str, target: str, relevant: bool | None = None) -> None:
"""Append one OMI consult (note read / search) to the turn's sentinel with
its relevance verdict (``None`` = not yet judged), and mark the gate
consulted. Never raises."""
Expand Down Expand Up @@ -833,9 +832,7 @@ def _opt_in_satisfied(opt_in: str, command: str) -> bool:
The optional ``env `` prefix must ITSELF be at command position — otherwise
``echo "use env OMI_SUDO_OK=1" && sudo …`` forged the opt-in from inside a
string (the ``\\benv``-anywhere bug) and skipped a hard rule."""
pattern = (
r"(?:^|[;&|\n])[ \t]*(?:env[ \t]+)?" + re.escape(opt_in) + r"(?=\s|$)"
)
pattern = r"(?:^|[;&|\n])[ \t]*(?:env[ \t]+)?" + re.escape(opt_in) + r"(?=\s|$)"
return re.search(pattern, command) is not None


Expand Down Expand Up @@ -1168,9 +1165,7 @@ def decide(action: dict[str, Any]) -> Verdict:
if gate_paused():
return Verdict(allow=True)

if _is_global_config_mutation(action) and not _turn_has_explicit_global_auth(
action, session
):
if _is_global_config_mutation(action) and not _turn_has_explicit_global_auth(action, session):
return Verdict(
allow=False,
reason=f"omi-guard (hard): {GLOBAL_MUTATION_MESSAGE}",
Expand Down Expand Up @@ -1296,11 +1291,7 @@ 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 (
not verdict.allow
and verdict.rule_id == "repo-work-read-git-rules"
and omi_dir is not None
):
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
Expand All @@ -1310,9 +1301,7 @@ def check_action(action: dict[str, Any], omi_dir: Path | None = None) -> Verdict
if excerpt:
verdict = Verdict(
allow=False,
reason=(
f"{verdict.reason}\n\n--- Governing memory (excerpt) ---\n{excerpt}"
),
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:
Expand Down Expand Up @@ -1528,6 +1517,37 @@ def preflight_turn(data: dict[str, Any], omi_dir: Path | None) -> str:
filename,
max_chars=ai_usage.policy(omi_dir).preflight_chars,
)
# #257: the ranking surfaces the best candidate even when "best" is a single
# shared word (a bare "retry" turn pulling an unrelated note). Require a
# minimum absolute term overlap before an unsolicited injection; a weak
# match is treated like a miss (auto-clear unless MISS_STRICT opts back in).
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 not _miss_strict():
record_consult(session, kind="weak-match", target=filename, relevant=False)
compliance.log_event(
compliance.KIND_DECISION,
session=session,
tool="UserPromptSubmit",
rule_id=GATE_WEAK_MATCH_RULE,
severity="soft",
outcome="auto-clear",
detail=f"note={filename!r} task={task[:100]!r}",
)
return (
"OMI turn preflight found only a weak memory match (fewer "
f"than {min_terms} task terms shared) — not injecting it. "
"Consult gate cleared for this turn — proceeding without a "
f"forced read (set {MISS_STRICT_ENV}=1 to require one anyway)."
)
return (
"OMI turn preflight found no confident memory match. The consult "
"gate remains armed. Before any non-memory tool, call OMI MCP "
"`search-vault` with a focused query, then `recall-note` on one "
"result."
)
version = str(memory.get("version") or "")
repeated = _injected_versions(session).get(filename) == version
# #241: the summary-only optimization for repeated notes loses to attention
Expand All @@ -1537,8 +1557,10 @@ def preflight_turn(data: dict[str, Any], omi_dir: Path | None) -> str:
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 and not action_shaped else "\n\n".join(
part for part in (summary, excerpt) if part and part != summary
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:
content = str(memory.get("title") or filename)
Expand All @@ -1554,8 +1576,7 @@ def preflight_turn(data: dict[str, Any], omi_dir: Path | None) -> str:
)
+ ". 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
"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)
Expand Down
185 changes: 157 additions & 28 deletions src/omind/retrieve.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

import importlib
import json
import os
import re
from pathlib import Path

Expand All @@ -31,16 +32,79 @@
_STOPWORDS = frozenset(
{
# function words
"and", "are", "but", "for", "from", "has", "have", "how", "into", "its",
"that", "the", "their", "then", "there", "these", "this", "was", "were",
"what", "when", "which", "who", "why", "with", "you", "your", "our", "does",
"than", "them", "they", "here", "over", "out",
"and",
"are",
"but",
"for",
"from",
"has",
"have",
"how",
"into",
"its",
"that",
"the",
"their",
"then",
"there",
"these",
"this",
"was",
"were",
"what",
"when",
"which",
"who",
"why",
"with",
"you",
"your",
"our",
"does",
"than",
"them",
"they",
"here",
"over",
"out",
# instruction filler / generic verbs that never carry the task's topic
"please", "before", "after", "again", "also", "just", "now", "more",
"most", "want", "wants", "need", "needs", "make", "makes", "made", "let",
"lets", "use", "uses", "used", "using", "can", "will", "would", "should",
"could", "must", "may", "might", "get", "gets", "got", "about", "any",
"all", "further",
"please",
"before",
"after",
"again",
"also",
"just",
"now",
"more",
"most",
"want",
"wants",
"need",
"needs",
"make",
"makes",
"made",
"let",
"lets",
"use",
"uses",
"used",
"using",
"can",
"will",
"would",
"should",
"could",
"must",
"may",
"might",
"get",
"gets",
"got",
"about",
"any",
"all",
"further",
}
)

Expand All @@ -55,14 +119,42 @@
_SUFFIXES: tuple[str, ...] = tuple(
sorted(
{
"ization", "isation", "ational",
"fulness", "iveness", "ousness",
"ation", "ition", "ement",
"ance", "ence", "able", "ible",
"ingly", "edly", "fully",
"tion", "sion", "ness", "ment", "ical",
"ing", "ies", "ied", "ity", "ive", "ous", "ant", "ent",
"er", "or", "al", "ed", "es", "ly", "s",
"ization",
"isation",
"ational",
"fulness",
"iveness",
"ousness",
"ation",
"ition",
"ement",
"ance",
"ence",
"able",
"ible",
"ingly",
"edly",
"fully",
"tion",
"sion",
"ness",
"ment",
"ical",
"ing",
"ies",
"ied",
"ity",
"ive",
"ous",
"ant",
"ent",
"er",
"or",
"al",
"ed",
"es",
"ly",
"s",
},
key=len,
reverse=True,
Expand All @@ -74,9 +166,21 @@
#: heavily de-ranked unless the task is itself about credentials.
_CREDENTIAL_TERMS = frozenset(
{
"credential", "credentials", "secret", "secrets", "token", "tokens",
"password", "passwords", "passphrase", "auth", "apikey", "keyfile",
"keyring", "gpg", "pass",
"credential",
"credentials",
"secret",
"secrets",
"token",
"tokens",
"password",
"passwords",
"passphrase",
"auth",
"apikey",
"keyfile",
"keyring",
"gpg",
"pass",
}
)

Expand Down Expand Up @@ -117,11 +221,7 @@ def _stem(word: str) -> str:


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
}
return {_stem(w) for w in _WORD_RE.findall(text.lower()) if w not in _STOPWORDS and len(w) > 2}


def overlap_score(task: str, text: str) -> float:
Expand All @@ -137,6 +237,37 @@ def overlap_score(task: str, text: str) -> float:
return len(task_terms & _tokens(text)) / len(task_terms)


#: Minimum DISTINCT meaningful task terms a note must share with the turn's
#: task before the proactive preflight injects it (``0`` disables the filter).
#: The ranking paths surface the best candidate even when "best" is one shared
#: word — a bare "retry" turn matching an unrelated note's title — which is the
#: right behavior for gate-deny *suggestions* but far too eager for unsolicited
#: per-turn injection. Ratio scores can't gate this (a one-term task trivially
#: scores 1.0), so the preflight requires an absolute match count instead.
PREFLIGHT_MIN_TERMS_ENV = "OMIND_PREFLIGHT_MIN_TERMS"
_PREFLIGHT_MIN_TERMS_DEFAULT = 2


def matched_terms(task: str, text: str) -> int:
"""How many distinct meaningful task terms appear in ``text``.

Shares :func:`_tokens` (stopwords + stemming) with :func:`overlap_score`,
so "matched" means the same thing as everywhere else in retrieval.
"""
return len(_tokens(task) & _tokens(text))


def preflight_min_terms() -> int:
"""The preflight injection threshold (env-overridable, never negative)."""
raw = os.environ.get(PREFLIGHT_MIN_TERMS_ENV, "").strip()
if raw:
try:
return max(0, int(raw))
except ValueError:
pass
return _PREFLIGHT_MIN_TERMS_DEFAULT


def normalize_intent(text: str) -> str:
"""Strip command scaffolding from a gate-blocked action before it is scored
as the turn's *pending intent* (#97). Drops a leading ``cd <dir> &&|;`` and
Expand Down Expand Up @@ -199,9 +330,7 @@ def _indexed_titles(
cred_files = set()
if not task_is_cred:
cred_files = {
row.filename
for row in rows
if _looks_credential(row.title, " ".join(row.tags))
row.filename for row in rows if _looks_credential(row.title, " ".join(row.tags))
}
titles = [
title_by_file[hit.filename]
Expand Down
Loading