Skip to content
Open
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
2 changes: 1 addition & 1 deletion docs/mesh.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ Every machine runs an identical **omind node**:
| `omind/server.py` | Local **node MCP server** exposing `OmiStore` as MCP tools over stdio. Replaces the provisioned `obsidian-mcp`. (Since 2.27.0 the debounced-sync write signal lives in `OmiStore` itself, so every write surface — MCP, web UI, `omind note`, import — triggers it.) |
| `omind/clock.py` | **Logical versioning** — a Lamport counter + stable node-id stamped into each note, the source of ordering truth for merges. |
| `omind/merge.py` | The **git merge driver** for OMI notes (the core of the project). Field-level 3-way merge over `NoteFields`. |
| `omind/mesh.py` | **Replication daemon** — `init`, `commit_local`, `sync(peers)`, `daemon`, `clone`, peer membership. |
| `omind/mesh.py` | **Replication daemon** — `init`, `sync(peers)`, `daemon`, `clone`, peer membership. |

### Reused unchanged

Expand Down
5 changes: 0 additions & 5 deletions src/omind/compliance.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,11 +241,6 @@ def read_events(limit: int | None = None) -> list[dict[str, Any]]:
return list(events[-limit:]) if limit is not None else list(events)


def recidivism(rule_id: str) -> int:
"""How many times ``rule_id`` has been recorded (decision + violation)."""
return sum(1 for e in read_events() if e.get("rule_id") == rule_id)


def recidivism_counts(events: list[dict[str, Any]] | None = None) -> Counter[str]:
"""Per-rule occurrence counts across the whole log (drives escalation).

Expand Down
10 changes: 2 additions & 8 deletions src/omind/guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -1833,15 +1833,9 @@ def _env_chdir_value(word: str, after: str) -> str | None:
return None


def _shell_body(tokens: list[str]) -> str | None:
"""The ``-c`` body of ``sh/bash/zsh … -c '<body>'`` (``-lc``, ``-ec`` and
``-o opt`` before it included), or ``None`` when there is no ``-c``."""
i = _shell_body_index(tokens)
return None if i is None else tokens[i]


def _shell_body_index(tokens: list[str]) -> int | None:
"""Where :func:`_shell_body` finds the body in ``tokens``."""
"""Where the ``-c`` body of ``sh/bash/zsh … -c '<body>'`` sits in ``tokens``
(``-lc``, ``-ec`` and ``-o opt`` before it included), or ``None``."""
has_c = False
i = 1
while i < len(tokens):
Expand Down
27 changes: 0 additions & 27 deletions src/omind/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,6 @@ def command_is_omind_hook(command: str) -> bool:
# current working directory. Auto-journal actions remain searchable but are not
# injected at session start.
_SESSION_STATE_GLOB = "Session State *.md"
_JOURNAL_GLOB = paths.JOURNAL_GLOB
_JOURNAL_TAIL_BULLETS = 20
# Maximum canonical "full" capsule. Runtime selection lives in ai_usage.
_TOTAL_CONTEXT_CHAR_CAP = 24_000
_TRUNCATION_MARKER = "\n…[truncated]"
Expand Down Expand Up @@ -410,19 +408,6 @@ def _read_priming_note(path: Path) -> str | None:
return text


def _latest_by_name(directory: Path, pattern: str) -> Path | None:
"""Newest note matching ``pattern``, by filename descending. Never raises.

Filenames embed ``YYYY-MM-DD``, so a plain lexicographic sort is also a
chronological sort.
"""
try:
matches = sorted(directory.glob(pattern), key=lambda p: p.name, reverse=True)
except OSError:
return None
return matches[0] if matches else None


def action_bullets(text: str) -> list[str]:
"""The ``- `` bullets under a journal's ``## Actions`` heading.

Expand All @@ -441,18 +426,6 @@ def action_bullets(text: str) -> list[str]:
return bullets


def _journal_tail(path: Path, limit: int = _JOURNAL_TAIL_BULLETS) -> str | None:
"""Last ``limit`` action bullets of a journal note, or ``None``. Never raises."""
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return None
bullets = action_bullets(text)
if not bullets:
return None
return "\n".join(bullets[-limit:])


def _update_nudge_line() -> str | None:
"""The "newer omind available" line for the SessionStart context, or ``None``.

Expand Down
8 changes: 0 additions & 8 deletions src/omind/mesh.py
Original file line number Diff line number Diff line change
Expand Up @@ -530,14 +530,6 @@ def _commit_locked(omi_dir: Path, message: str) -> bool:
return True


def commit_local(omi_dir: Path, node_id: str) -> bool:
"""Commit local changes. The lock spans staging, so a concurrent store
write can never be half-staged."""
store = OmiStore(omi_dir)
with store.write_lock():
return _commit_locked(omi_dir, f"omind: local changes on {node_id}")


def _first_line(text: str) -> str:
"""The most diagnostic line of a git message (CONFLICT/error/fatal first)."""
lines = [ln for ln in text.strip().splitlines() if ln.strip()]
Expand Down
15 changes: 3 additions & 12 deletions src/omind/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -388,16 +388,6 @@ def command_search(compiled: re.Pattern[str], pattern: str, text: str) -> bool:
_OWNER_SEP_RE = re.compile(r"[\n;&|(`]")


def _heredoc_owner_is_shell(command: str, start: int) -> bool:
"""True when the simple command owning the heredoc at ``start`` is a shell.

Scans back to the nearest separator and takes the first token that is not a
``VAR=val`` assignment or a transparent wrapper, comparing its basename.
One heredoc's answer; :class:`_HeredocOwners` answers a run of them.
"""
return _HeredocOwners(command).is_shell(start)


def _owner_word_skipped(token: str) -> bool:
"""Whether a word before a heredoc's owner is passed over: a ``VAR=val``
assignment or a transparent wrapper."""
Expand All @@ -412,8 +402,9 @@ def _owner_word_skipped(token: str) -> bool:


class _HeredocOwners:
""":func:`_heredoc_owner_is_shell` for every heredoc in ``command``, in
linear time overall (#445). Each separator-bounded segment's words are
"""Whether the simple command owning each heredoc in ``command`` is a shell
(skipping ``VAR=val`` assignments and transparent wrappers), in linear time
overall (#445). Each separator-bounded segment's words are
found once, with the first one that is not skipped; a heredoc's owner is
that word when it ends before the heredoc. Walking the words from the
separator per heredoc was quadratic in a run of assignments followed by
Expand Down
90 changes: 0 additions & 90 deletions src/omind/searchindex.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,18 +129,6 @@ def available() -> bool:
return not os.environ.get(DISABLE_ENV) and _fts5_available()


@dataclass
class SearchPage:
"""One page of hits, with what the caller needs to decide whether to go on."""

hits: list[Hit]
#: Pass back as ``cursor`` to get the next page; ``None`` means this was all.
next_cursor: int | None = None
#: Summed estimate for this page, so context spend is a number the caller
#: can act on rather than something it discovers after the fact.
tokens_returned: int = 0


@dataclass
class Hit:
"""One ranked chunk. ``excerpt`` is the matched text, not the whole note."""
Expand Down Expand Up @@ -1051,50 +1039,6 @@ def _embed_chunks(self, db: sqlite3.Connection, pending: list[tuple[int, str]])

# -- query --------------------------------------------------------------

@_locked
def search_page(
self,
query: str,
*,
tag: str | None = None,
limit: int = 25,
include_disabled: bool = False,
max_tokens: int | None = None,
cursor: int = 0,
) -> SearchPage | None:
"""One bounded page of results, plus the cursor needed to ask for more.

Why a cursor rather than a bigger ``limit``: under load a model tends to
conclude before it has exhausted what it was given — it stops early and
answers from what it has. A page that ends with an explicit
``next_cursor`` makes continuing a deliberate act rather than something
the model has to remember to keep doing, and makes "there was more" a
fact the caller can see instead of one it has to infer.

``max_tokens`` bounds the page by estimated cost and reorders it
edge-first; see :func:`_pack_edge_first`. ``None`` leaves both off.
"""
hits = self.search(
query,
tag=tag,
limit=limit + cursor + 1, # +1 so we can tell "more" from "exactly full"
include_disabled=include_disabled,
)
if hits is None:
return None
window = hits[cursor : cursor + limit]
has_more = len(hits) > cursor + limit
if max_tokens is not None and window:
window = _pack_edge_first(window, max_tokens)
# Packing can drop the tail, so "more" is now also true when the
# budget — not the page size — is what cut the results short.
has_more = has_more or len(window) < len(hits[cursor : cursor + limit])
return SearchPage(
hits=window,
next_cursor=(cursor + len(window)) if has_more else None,
tokens_returned=sum(h.tokens for h in window),
)

@_locked
def search(
self,
Expand Down Expand Up @@ -2106,40 +2050,6 @@ def _annotate(hits: list[Hit]) -> None:
hit.separation = hit.score - hits[i + 1].score if i + 1 < len(hits) else 0.0


def _pack_edge_first(hits: list[Hit], max_tokens: int) -> list[Hit]:
"""Greedily fit hits into ``max_tokens``, then order them best-outward.

Two separate jobs, and the second is the interesting one.

The budget exists because retrieval quality is not the binding constraint:
even with the right passage located, accuracy falls as the surrounding
context grows. Better ranking only decides how large the bill is; it cannot
stop the caller from paying it. So cap what leaves this function.

The ORDER exists because attention over a long context is U-shaped — the
beginning and end are read well and the middle thins out. Emitting
best-first / second-best-LAST / weakest-in-the-middle puts the strongest
evidence where it is actually read. Note this only pays off once a caller
concatenates enough text to have a "middle" at all; below that it is
harmless reordering, which is why the budget is opt-in rather than default.
"""
kept: list[Hit] = []
spent = 0
for hit in hits:
cost = hit.tokens or _estimate_tokens(hit.excerpt)
# Always keep the best hit, even if it alone busts the budget: returning
# nothing is strictly worse than returning the one thing that matched.
if kept and spent + cost > max_tokens:
break
kept.append(hit)
spent += cost
head: list[Hit] = []
tail: list[Hit] = []
for i, hit in enumerate(kept):
(head if i % 2 == 0 else tail).append(hit)
return head + tail[::-1]


def _fuse(legs: list[tuple[float, list[int]]]) -> list[tuple[int, float]]:
"""Reciprocal Rank Fusion over weighted ranked lists, best first."""
scores: dict[int, float] = {}
Expand Down
76 changes: 14 additions & 62 deletions src/omind/verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@
import json
import os
import re
import subprocess
import sys
from datetime import datetime
from pathlib import Path
Expand Down Expand Up @@ -435,17 +434,17 @@ def _parse_verdict(text: str) -> bool | None:
return None


def _ask_model(task: str, text: str, omi_dir: Path | str | None = None) -> bool | None:
def _ask_model(task: str, text: str, omi_dir: Path | str) -> bool | None:
"""Ask a headless one-shot model whether the consult was relevant.

Backend-agnostic: whichever of the known CLIs is on PATH, or a custom one via
``OMI_MODEL_CMD``. ``None`` on any unavailability/error/timeout (the caller
fails open)."""
from omind import ai_usage

limits = ai_usage.policy(omi_dir) if omi_dir is not None else None
task_cap = limits.verifier_task_chars if limits else 1_000
material_cap = limits.verifier_material_chars if limits else 2_000
limits = ai_usage.policy(omi_dir)
task_cap = limits.verifier_task_chars
material_cap = limits.verifier_material_chars
prompt = (
"You are an OMI-compliance relevance checker. An agent was told to consult "
"its memory (OMI) before acting on a task, and it consulted the material "
Expand All @@ -461,35 +460,12 @@ def _ask_model(task: str, text: str, omi_dir: Path | str | None = None) -> bool
timeout = int(os.environ.get(_TIMEOUT_ENV) or _DEFAULT_TIMEOUT)
except ValueError:
timeout = _DEFAULT_TIMEOUT
if omi_dir is None:
# Compatibility path for the public pure ``judge`` helper. Real hook
# calls always supply the vault and therefore use the accounted wrapper.
from omind import ai_usage as _au

resolved = _au.resolve_model_backend()
if resolved is None:
return None
template, _as_json, _name = resolved
argv = [
prompt if part == "{prompt}" else part.replace("{prompt}", prompt)
for part in template
]
stdin_text = prompt if _au.STDIN in template else None
if stdin_text is not None:
argv = [p for p in argv if p != _au.STDIN]
try:
result = subprocess.run(
argv, input=stdin_text, capture_output=True, text=True, timeout=timeout
)
except (subprocess.TimeoutExpired, OSError):
return None
return _parse_verdict(result.stdout or "") if result.returncode == 0 else None
response = ai_usage.run_claude(
omi_dir,
"verifier",
prompt,
timeout=timeout,
allowed=limits.verifier_llm if limits else True,
allowed=limits.verifier_llm,
)
return _parse_verdict(response or "")

Expand All @@ -513,12 +489,15 @@ def _judge_scored(
task: str,
activity: str,
text: str,
pending: str = "",
omi_dir: Path | str | None = None,
pending: str,
omi_dir: Path | str,
) -> tuple[bool, float]:
""":func:`judge_with_activity` plus the deterministic score it decided on,
so the off-topic log can carry WHAT the consult was judged against (#148) —
without it, a sampling pass cannot adjudicate false positives after the fact."""
"""Relevance verdict plus the deterministic score it decided on. A consult is
relevant if it overlaps **any** of the turn task, the agent's recent activity
(#95), or the action the gate just blocked (#96), scored by ``max`` so none
dilutes another; the model only breaks the ambiguous middle band, fail-open
everywhere else. The score lets the off-topic log carry WHAT the consult was
judged against (#148)."""
if not text or (not task and not activity and not pending):
return True, 0.0 # can't judge without a signal -> fail open (relevant)
high = _threshold(_HIGH_ENV, _HIGH)
Expand All @@ -532,37 +511,10 @@ def _judge_scored(
return True, score
if score <= low:
return False, score
verdict = (
_ask_model(task or activity or pending, text)
if omi_dir is None
else _ask_model(task or activity or pending, text, omi_dir)
)
verdict = _ask_model(task or activity or pending, text, omi_dir)
return (True if verdict is None else verdict), score


def judge_with_activity(task: str, activity: str, text: str, pending: str = "") -> bool:
"""Relevance verdict blending the captured task, the agent's recent activity
(issue #95), and the action the consult-gate just BLOCKED (issue #96): a consult
is relevant if it overlaps **any** of the three. Same deterministic-prefilter →
model-fallback ladder as :func:`judge`, scoring against ``max`` of the overlaps so
none dilutes another (concatenating would inflate the recall denominator — the
dilution 2.43.2 fought). The pending (blocked) action is the agent's freshest
intent: at a work *transition* the task and activity are both cold (still the
previous thread), but the blocked action is the new-thread work that tripped the
gate, so the FIRST consult clears instead of burning re-closes. Path noise in a
blocked command is normalized away first (#97). Only the REACTIVE order (attempt →
blocked → consult) records a pending; a proactive consult at a transition has none
to lean on, so prefer attempting the new work first at a transition."""
return _judge_scored(task, activity, text, pending)[0]


def judge(task: str, text: str) -> bool:
"""Relevance verdict for a single consult against the turn task alone.
Deterministic prefilter first (with operator-tunable thresholds), the model
only for the ambiguous middle, fail-open everywhere else."""
return judge_with_activity(task, "", text)


def _require_mode(require: bool | None) -> bool:
if require is not None:
return require
Expand Down
4 changes: 2 additions & 2 deletions tests/test_compliance.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,13 @@ def test_the_log_rotates_at_the_cap_without_losing_counts(
# The live log restarted, but history spans both generations — escalation
# still counts every hit that came before the rotation.
assert compliance.compliance_log_path().stat().st_size <= 200
assert compliance.recidivism("r") == logged
assert compliance.recidivism_counts()["r"] == logged


def test_recidivism_counts_exclude_the_gate() -> None:
for rid in ("r1", "r1", "r2", "omi-gate"):
compliance.log_event(compliance.KIND_DECISION, rule_id=rid, outcome="deny")
assert compliance.recidivism("r1") == 2
assert compliance.recidivism_counts()["r1"] == 2
counts = compliance.recidivism_counts()
assert counts["r1"] == 2 and counts["r2"] == 1
assert "omi-gate" not in counts
Expand Down
2 changes: 1 addition & 1 deletion tests/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -530,7 +530,7 @@ def test_session_start_journal_tail_is_last_bullets_only(tmp_path: Path) -> None
_write_priming_files(tmp_path)
old = datetime(2026, 6, 1, 9, 0, 0)
hooks.append_entry(tmp_path, "- 09:00 [session old00000] PostToolUse Bash -> OLD-J (ok)", old)
for i in range(hooks._JOURNAL_TAIL_BULLETS + 5):
for i in range(25):
bullet = f"- 14:32 [session abcd1234] PostToolUse Bash -> c{i:02d} (ok)"
hooks.append_entry(tmp_path, bullet, _NOW)
ctx = hooks.build_session_start_context(tmp_path)
Expand Down
Loading
Loading