diff --git a/CHANGELOG.md b/CHANGELOG.md index 700363f..c35449a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [4.0.0] - 2026-07-16 + +### Added +- **OMI-attributable AI token accounting and expense profiles.** A privacy-safe, + per-vault JSONL ledger records session-priming estimates and provider-reported + verifier/checkpoint usage without storing prompts or responses. `omind ai + profile low|medium|high` applies hard context budgets and progressively disables + optional model work; `omind ai usage` reports exact, estimated, cached, and + avoided tokens in text or JSON. The local web app adds the same profile control + and 24-hour/7-day/30-day/all-time usage dashboard through `/api/ai/*` endpoints. + +### Security +- Parse AI-usage reporting windows with a bounded direct grammar rather than a + regular expression over public API input, eliminating the polynomial-runtime + pattern identified by CodeQL during the 4.0.0 release review. + +### Fixed +- Preserve backslashes when resolving `git -C` targets on Windows, and keep + POSIX permission-bit assertions scoped to platforms that implement them. + ## [3.8.6] - 2026-07-06 ### Changed diff --git a/README.md b/README.md index 4957bbb..695eb29 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,8 @@ reads and writes as long-term memory. `omind` does two things with it: - **`omind checkpoint`** — a scheduled job that records what the agent has been doing every N minutes into a daily worklog note, by mining the trails the hooks already capture (see [Activity checkpoints](#activity-checkpoints) below). +- **`omind ai`** — account for tokens attributable to OMI and select a manual + low/medium/high model-expense profile that bounds priming and optional model calls. The web UI works **fully offline** (fonts, styles, and the Markdown renderer are vendored — no CDN). It shows **backlinks** for the open note, refreshes the list @@ -360,6 +362,32 @@ text. Because it's a scheduled job — the same systemd-user-timer mechanism as `omind backup` and `omind mesh` — it doesn't depend on the agent's cooperation, which is what makes it a reliable *record* rather than a hopeful instruction. +## AI token usage and expense profiles + +omind keeps a machine-local, per-vault ledger for the AI tokens it causes: session +priming injected into an agent plus the verifier and optional checkpoint +`claude -p` calls. It does **not** parse or claim the rest of an agent session. +Provider-reported subprocess usage is recorded exactly; provider-neutral priming +is shown as an estimate (`ceil(characters / 4)`) and never mixed into the +provider-reported subtotal. The ledger contains counts and operational metadata +only — never prompts, responses, note contents, or credentials. + +```bash +omind ai profile # show saved/effective profile +omind ai profile medium # persist low, medium, or high per vault +omind ai usage --since 7d # 24h, 7d, 30d, or all +omind ai usage --since all --json # machine-readable report +``` + +Profiles describe the expense of the model already selected; they do not select a +model. `low` is the backward-compatible default (48,000-character priming cap and +all optional calls), `medium` halves the priming/prompt inputs, and `high` caps +priming at 8,000 characters and uses deterministic verifier/checkpoint behavior +without optional model calls. Set `OMI_AI_EXPENSE=low|medium|high` for a temporary +override; it wins over the saved profile. The web app's **AI Usage** view exposes +the same profile control, time windows, exact/estimated breakdown, per-operation +totals, and estimated avoided tokens. + ## Other agents: Hermes, OpenClaw, OpenCode, Codex, Gemini, Claude Desktop, Kiro, VS Code, Amazon Q [Claude Code](https://github.com/anthropics/claude-code) is the default, but the diff --git a/pyproject.toml b/pyproject.toml index effad3c..f3e3089 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "omind" -version = "3.8.6" +version = "4.0.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 66f633c..c09ca6f 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__ = "3.8.6" +__version__ = "4.0.0" diff --git a/src/omind/ai_usage.py b/src/omind/ai_usage.py new file mode 100644 index 0000000..f8c05ef --- /dev/null +++ b/src/omind/ai_usage.py @@ -0,0 +1,339 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Aaron K. Clark +"""OMI-attributable AI token accounting and model-expense profiles. + +The ledger deliberately stores counts and operational metadata only: never the +prompt, response, note body, or user text that produced those counts. Provider +usage is exact when ``claude -p --output-format json`` reports it; priming and +legacy/malformed responses use a clearly-labelled, provider-neutral estimate. +""" + +from __future__ import annotations + +import json +import math +import os +import shutil +import subprocess +from dataclasses import asdict, dataclass +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any + +from omind import filelock, paths + +PROFILES = ("low", "medium", "high") +PROFILE_ENV = "OMI_AI_EXPENSE" +DEFAULT_PROFILE = "low" + + +@dataclass(frozen=True) +class ProfilePolicy: + context_chars: int + verifier_task_chars: int + verifier_material_chars: int + checkpoint_actions: int + checkpoint_guard_events: int + verifier_llm: bool + checkpoint_llm: bool + + +PROFILE_POLICIES: dict[str, ProfilePolicy] = { + "low": ProfilePolicy(48_000, 1_000, 2_000, 60, 30, True, True), + "medium": ProfilePolicy(24_000, 500, 1_000, 30, 15, True, True), + "high": ProfilePolicy(8_000, 1_000, 2_000, 0, 0, False, False), +} + + +def _vault_key(omi_dir: Path | str) -> str: + import hashlib + + resolved = str(Path(omi_dir).expanduser().resolve()) + return hashlib.sha256(resolved.encode()).hexdigest()[:12] + + +def profile_path(omi_dir: Path | str) -> Path: + return paths.state_dir() / f"ai-profile-{_vault_key(omi_dir)}.json" + + +def usage_path(omi_dir: Path | str) -> Path: + return paths.state_dir() / f"ai-usage-{_vault_key(omi_dir)}.jsonl" + + +def saved_profile(omi_dir: Path | str) -> str: + try: + data = json.loads(profile_path(omi_dir).read_text(encoding="utf-8")) + except (OSError, ValueError, TypeError): + return DEFAULT_PROFILE + value = data.get("profile") if isinstance(data, dict) else None + return str(value) if value in PROFILES else DEFAULT_PROFILE + + +def profile_info(omi_dir: Path | str) -> dict[str, str]: + saved = saved_profile(omi_dir) + override = os.environ.get(PROFILE_ENV, "").strip().lower() + if override in PROFILES: + return {"saved": saved, "effective": override, "source": "environment"} + return { + "saved": saved, + "effective": saved, + "source": "saved" if profile_path(omi_dir).exists() else "default", + } + + +def effective_profile(omi_dir: Path | str) -> str: + return profile_info(omi_dir)["effective"] + + +def policy(omi_dir: Path | str) -> ProfilePolicy: + return PROFILE_POLICIES[effective_profile(omi_dir)] + + +def set_profile(omi_dir: Path | str, profile: str) -> dict[str, str]: + value = profile.strip().lower() + if value not in PROFILES: + raise ValueError(f"profile must be one of: {', '.join(PROFILES)}") + path = profile_path(omi_dir) + paths.atomic_write_text(path, json.dumps({"profile": value}, indent=2) + "\n", mode=0o600) + return profile_info(omi_dir) + + +def estimate_tokens(text_or_chars: str | int) -> int: + """Provider-neutral estimate used only when exact tokenizer usage is absent.""" + chars = text_or_chars if isinstance(text_or_chars, int) else len(text_or_chars) + return math.ceil(max(0, chars) / 4) + + +def log_event( + omi_dir: Path | str, + operation: str, + *, + status: str = "executed", + measurement: str = "exact", + model: str = "", + input_tokens: int = 0, + output_tokens: int = 0, + cache_read_tokens: int = 0, + cache_write_tokens: int = 0, + characters: int = 0, + avoided_tokens: int = 0, + reason: str = "", + now: datetime | None = None, +) -> None: + """Append a privacy-safe usage record. Never raises into an agent hook.""" + record: dict[str, Any] = { + "ts": (now or datetime.now()).isoformat(timespec="seconds"), + "operation": operation, + "profile": effective_profile(omi_dir), + "status": status, + "measurement": measurement, + "input_tokens": max(0, int(input_tokens)), + "output_tokens": max(0, int(output_tokens)), + "cache_read_tokens": max(0, int(cache_read_tokens)), + "cache_write_tokens": max(0, int(cache_write_tokens)), + "characters": max(0, int(characters)), + "avoided_tokens": max(0, int(avoided_tokens)), + } + if model: + record["model"] = model[:120] + if reason: + record["reason"] = reason[:160] + try: + path = usage_path(omi_dir) + path.parent.mkdir(parents=True, exist_ok=True) + binary = getattr(os, "O_BINARY", 0) + fd = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT | binary, 0o600) + try: + filelock.lock_fd(fd) + os.write(fd, (json.dumps(record, separators=(",", ":")) + "\n").encode()) + finally: + filelock.unlock_fd(fd) + os.close(fd) + except (OSError, ValueError, TypeError): + return + + +def record_priming(omi_dir: Path | str, characters: int, *, avoided_characters: int = 0) -> None: + log_event( + omi_dir, + "priming", + measurement="estimated", + characters=characters, + input_tokens=estimate_tokens(characters), + avoided_tokens=estimate_tokens(avoided_characters), + ) + + +def read_events(omi_dir: Path | str) -> list[dict[str, Any]]: + try: + lines = usage_path(omi_dir).read_text(encoding="utf-8", errors="replace").splitlines() + except OSError: + return [] + events: list[dict[str, Any]] = [] + for line in lines: + try: + value = json.loads(line) + except (ValueError, TypeError): + continue + if isinstance(value, dict): + events.append(value) + return events + + +def parse_window(value: str) -> timedelta | None: + clean = (value or "").strip().lower() + if clean == "all": + return None + # This is a tiny public-input grammar; parse it directly instead of using a + # backtracking regex over attacker-controlled text (CodeQL py/redos). + if ( + not 2 <= len(clean) <= 10 + or clean[-1] not in {"h", "d"} + or any(char < "0" or char > "9" for char in clean[:-1]) + ): + raise ValueError("--since must be 24h, 7d, 30d, or all") + number = int(clean[:-1]) + return timedelta(hours=number) if clean[-1] == "h" else timedelta(days=number) + + +def usage_summary( + omi_dir: Path | str, *, since: str = "7d", now: datetime | None = None +) -> dict[str, Any]: + window = parse_window(since) + current = now or datetime.now() + cutoff = current - window if window is not None else None + events: list[dict[str, Any]] = [] + for event in read_events(omi_dir): + try: + stamp = datetime.fromisoformat(str(event.get("ts") or "")) + except ValueError: + continue + if stamp.tzinfo is not None: + stamp = stamp.astimezone().replace(tzinfo=None) + if cutoff is None or stamp >= cutoff: + events.append(event) + + numeric = ( + "input_tokens", + "output_tokens", + "cache_read_tokens", + "cache_write_tokens", + "avoided_tokens", + ) + + def totals(rows: list[dict[str, Any]]) -> dict[str, int]: + result: dict[str, int] = {} + for key in numeric: + value = 0 + for row in rows: + try: + value += max(0, int(row.get(key) or 0)) + except (ValueError, TypeError): + continue + result[key] = value + return result + + operations: dict[str, dict[str, int]] = {} + for operation in ("priming", "verifier", "checkpoint"): + operations[operation] = totals([e for e in events if e.get("operation") == operation]) + return { + "since": since, + "profile": profile_info(omi_dir), + "events": len(events), + "totals": totals(events), + "exact": totals([e for e in events if e.get("measurement") == "exact"]), + "estimated": totals([e for e in events if e.get("measurement") == "estimated"]), + "operations": operations, + } + + +def _usage_int(usage: dict[str, Any], *names: str) -> int: + for name in names: + value = usage.get(name) + if isinstance(value, (int, float)): + return max(0, int(value)) + return 0 + + +def run_claude( + omi_dir: Path | str, + operation: str, + prompt: str, + *, + timeout: int, + allowed: bool = True, +) -> str | None: + """Run a headless Claude call, account for it, and return response text. + + Any failure preserves the historic fail-open contract by returning ``None``. + """ + if not allowed: + log_event( + omi_dir, + operation, + status="skipped", + measurement="estimated", + characters=len(prompt), + avoided_tokens=estimate_tokens(prompt), + reason="disabled by expense profile", + ) + return None + claude = shutil.which("claude") + if not claude: + return None + try: + result = subprocess.run( + [claude, "-p", "--output-format", "json", prompt], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + ) + except (subprocess.TimeoutExpired, OSError): + return None + if result.returncode != 0: + return None + stdout = (result.stdout or "").strip() + if not stdout: + return None + try: + payload = json.loads(stdout) + except ValueError: + log_event( + omi_dir, + operation, + measurement="estimated", + characters=len(prompt) + len(stdout), + input_tokens=estimate_tokens(prompt), + output_tokens=estimate_tokens(stdout), + ) + return stdout + if not isinstance(payload, dict): + return None + text = payload.get("result") + if not isinstance(text, str) or not text.strip(): + return None + usage = payload.get("usage") + usage = usage if isinstance(usage, dict) else {} + has_usage = any(isinstance(v, (int, float)) for v in usage.values()) + log_event( + omi_dir, + operation, + measurement="exact" if has_usage else "estimated", + model=str(payload.get("model") or ""), + characters=len(prompt) + len(text), + input_tokens=(_usage_int(usage, "input_tokens") if has_usage else estimate_tokens(prompt)), + output_tokens=(_usage_int(usage, "output_tokens") if has_usage else estimate_tokens(text)), + cache_read_tokens=_usage_int(usage, "cache_read_input_tokens", "cache_read_tokens"), + cache_write_tokens=_usage_int( + usage, "cache_creation_input_tokens", "cache_write_input_tokens" + ), + ) + return text.strip() + + +def profile_payload(omi_dir: Path | str) -> dict[str, Any]: + info: dict[str, Any] = profile_info(omi_dir) + info["policies"] = {name: asdict(value) for name, value in PROFILE_POLICIES.items()} + return info diff --git a/src/omind/checkpoint.py b/src/omind/checkpoint.py index 46399c1..225e8de 100644 --- a/src/omind/checkpoint.py +++ b/src/omind/checkpoint.py @@ -144,47 +144,68 @@ def gather_activity(omi_dir: Path | str, cutoff: datetime, now: datetime) -> Act return Activity(actions=actions, guard_events=guard) -def _llm_narrative(activity: Activity, since: str) -> str | None: +def _llm_narrative( + activity: Activity, since: str, omi_dir: Path | str | None = None +) -> str | None: """A one-paragraph narrative from headless ``claude -p``; ``None`` on any unavailability/error/timeout (caller falls back to the deterministic summary).""" - claude = shutil.which("claude") - if not claude: - return None - lines = [f"{a['time']} {a['event']} {a['tool']} {a['detail']}" for a in activity.actions[-60:]] - guard = [f"{e.get('ts')} {e.get('outcome')} {e.get('tool')} {e.get('command')}" for e in - activity.guard_events[-30:]] + from omind import ai_usage + + limits = ai_usage.policy(omi_dir) if omi_dir is not None else None + action_limit = limits.checkpoint_actions if limits else 60 + guard_limit = limits.checkpoint_guard_events if limits else 30 + # A high-expense profile still builds the prompt shape so the skipped event + # can report an avoided-token estimate without exposing its contents. + lines = [ + f"{a['time']} {a['event']} {a['tool']} {a['detail']}" + for a in activity.actions[-max(action_limit, 60 if action_limit == 0 else action_limit) :] + ] + guard = [ + f"{e.get('ts')} {e.get('outcome')} {e.get('tool')} {e.get('command')}" + for e in activity.guard_events[ + -max(guard_limit, 30 if guard_limit == 0 else guard_limit) : + ] + ] prompt = ( "Summarize what this agent worked on in the last " f"{since}, in 1-3 sentences, factual and concise. No preamble.\n\n" "ACTIONS:\n" + "\n".join(lines) + "\n\nGUARD EVENTS:\n" + "\n".join(guard) + "\n" ) + if omi_dir is not None: + return ai_usage.run_claude( + omi_dir, + "checkpoint", + prompt, + timeout=_LLM_TIMEOUT, + allowed=limits.checkpoint_llm if limits else True, + ) + claude = shutil.which("claude") + if not claude: + return None try: result = subprocess.run( - [claude, "-p", prompt], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=_LLM_TIMEOUT, + [claude, "-p", prompt], capture_output=True, text=True, timeout=_LLM_TIMEOUT ) except (subprocess.TimeoutExpired, OSError): return None - # A non-zero exit means claude printed a diagnostic, not a summary — don't - # adopt an error message as the day's narrative. - if result.returncode != 0: - return None - text = (result.stdout or "").strip() - return text or None + return (result.stdout or "").strip() or None if result.returncode == 0 else None -def render_section(activity: Activity, since: str, now: datetime, *, llm: bool = False) -> str: +def render_section( + activity: Activity, + since: str, + now: datetime, + *, + llm: bool = False, + omi_dir: Path | str | None = None, +) -> str: """One worklog section for this run (deterministic; ``llm`` adds a narrative).""" header = f"### {now.strftime('%H:%M')} — last {since}" if activity.is_empty(): return f"{header}\n- no recorded activity in this window\n" lines = [header] if llm: - narrative = _llm_narrative(activity, since) + narrative = _llm_narrative(activity, since, omi_dir) if narrative: lines.append(narrative) tools = Counter(a["tool"] for a in activity.actions if a["tool"]) @@ -234,7 +255,7 @@ def write_checkpoint( now = now or datetime.now() cutoff = now - parse_since(since) activity = gather_activity(omi_dir, cutoff, now) - section = render_section(activity, since, now, llm=llm) + section = render_section(activity, since, now, llm=llm, omi_dir=omi_dir) title = f"Worklog {now.strftime('%Y-%m-%d')}" details = _append_section(_existing_details(omi_dir, title), section) fields = NoteFields( diff --git a/src/omind/cli.py b/src/omind/cli.py index ceb6bfd..5110f73 100644 --- a/src/omind/cli.py +++ b/src/omind/cli.py @@ -64,7 +64,7 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--version", action="version", version=f"omind {__version__}") sub = parser.add_subparsers( dest="command", - metavar="{setup,quickstart,serve,doctor,self-update,backup,export,import,reindex,note,rollup,hook}", + metavar="{setup,quickstart,serve,doctor,self-update,backup,ai,export,import,reindex,note,rollup,hook}", ) setup = sub.add_parser( @@ -400,6 +400,20 @@ def build_parser() -> argparse.ArgumentParser: ) _add_vault_args(checkpoint) + ai = sub.add_parser( + "ai", help="inspect OMI-attributable token usage and select a model-expense profile" + ) + ai_sub = ai.add_subparsers(dest="ai_command", required=True) + ai_profile = ai_sub.add_parser("profile", help="show or set low/medium/high expense mode") + ai_profile.add_argument("profile", nargs="?", choices=("low", "medium", "high")) + _add_vault_args(ai_profile) + ai_usage = ai_sub.add_parser("usage", help="summarize OMI-attributable token usage") + ai_usage.add_argument( + "--since", choices=("24h", "7d", "30d", "all"), default="7d" + ) + ai_usage.add_argument("--json", action="store_true", help="emit machine-readable JSON") + _add_vault_args(ai_usage) + rollup = sub.add_parser( "rollup", help="compact weeks of daily session journals into one summary note each, " @@ -935,6 +949,56 @@ def _run_checkpoint(args: argparse.Namespace) -> int: return 0 +def _run_ai(args: argparse.Namespace) -> int: + import json + + from omind import ai_usage + + omi_dir = (args.vault / args.folder).expanduser() + if args.ai_command == "profile": + try: + info = ( + ai_usage.set_profile(omi_dir, args.profile) + if args.profile + else ai_usage.profile_info(omi_dir) + ) + except (OSError, ValueError) as exc: + print(f"error: could not set AI expense profile: {exc}", file=sys.stderr) + return 1 + print( + f"saved={info['saved']} effective={info['effective']} source={info['source']}" + ) + if info["source"] == "environment": + print(f"note: {ai_usage.PROFILE_ENV} overrides the saved profile") + return 0 + summary = ai_usage.usage_summary(omi_dir, since=args.since) + if args.json: + print(json.dumps(summary, indent=2)) + return 0 + totals = summary["totals"] + profile = summary["profile"] + print( + f"AI usage ({args.since}) — profile {profile['effective']} " + f"({profile['source']})" + ) + print( + f"input {totals['input_tokens']:,} | output {totals['output_tokens']:,} | " + f"cache read {totals['cache_read_tokens']:,} | " + f"cache write {totals['cache_write_tokens']:,}" + ) + print( + f"exact input {summary['exact']['input_tokens']:,} | " + f"estimated input {summary['estimated']['input_tokens']:,} | " + f"estimated avoided {totals['avoided_tokens']:,}" + ) + for name, values in summary["operations"].items(): + print( + f" {name}: input {values['input_tokens']:,}, " + f"output {values['output_tokens']:,}, avoided {values['avoided_tokens']:,}" + ) + return 0 + + def _split_csv(value: str) -> list[str]: """Split a comma-separated CLI flag into a clean list.""" return [item.strip() for item in value.split(",") if item.strip()] @@ -1107,6 +1171,8 @@ def main(argv: list[str] | None = None) -> int: return _run_graph(args) if args.command == "checkpoint": return _run_checkpoint(args) + if args.command == "ai": + return _run_ai(args) if args.command == "reindex": return _run_reindex(args) if args.command == "convert": diff --git a/src/omind/guard.py b/src/omind/guard.py index f3facc6..bd1373c 100644 --- a/src/omind/guard.py +++ b/src/omind/guard.py @@ -745,7 +745,18 @@ def _git_dash_c_path(command: str) -> Path | None: parts = _split_simple_commands(command) if not parts: return None - tokens = shlex.split(parts[0]) + # POSIX shlex treats every backslash as an escape and turns an unquoted + # Windows path such as ``C:\\repo`` into ``C:repo``. PowerShell/cmd do + # not use backslashes that way, so retain them on Windows. Non-POSIX + # shlex keeps surrounding quotes; remove only a matching outer pair. + tokens = shlex.split(parts[0], posix=os.name != "nt") + if os.name == "nt": + tokens = [ + token[1:-1] + if len(token) >= 2 and token[0] == token[-1] and token[0] in {'"', "'"} + else token + for token in tokens + ] except ValueError: return None if not tokens or tokens[0] != "git": diff --git a/src/omind/hooks.py b/src/omind/hooks.py index 1df068e..9673941 100644 --- a/src/omind/hooks.py +++ b/src/omind/hooks.py @@ -64,6 +64,8 @@ _SESSION_STATE_GLOB = "Session State *.md" _JOURNAL_GLOB = paths.JOURNAL_GLOB _JOURNAL_TAIL_BULLETS = 20 +# Backward-compatible name for the low-expense/default cap. Runtime selection +# lives in :mod:`omind.ai_usage`. _TOTAL_CONTEXT_CHAR_CAP = 48_000 _TRUNCATION_MARKER = "\n…[truncated]" @@ -341,7 +343,44 @@ def _update_nudge_line() -> str | None: return f"⚠️ {nudge}" if nudge else None -def build_session_start_context(omi_dir: Path | str) -> str: +def _allocate_sections( + sections: list[str], budget: int, *, minimum: int = 1_000 +) -> list[str]: + """Fit sections into ``budget`` while giving every section a useful floor. + + The floor prevents a long index from consuming the whole context before the + Playbook/workflow/persona sections get a voice. Unused space is then handed + out in priority order (the caller's order). + """ + if not sections or budget <= len(_TRUNCATION_MARKER): + return [] + allocations = [min(len(section), minimum) for section in sections] + total = sum(allocations) + if total > budget: + share = max(0, budget // len(sections)) + allocations = [min(len(section), share) for section in sections] + remaining = max(0, budget - sum(allocations)) + for index, section in enumerate(sections): + extra = min(remaining, len(section) - allocations[index]) + allocations[index] += extra + remaining -= extra + if remaining <= 0: + break + fitted: list[str] = [] + for section, size in zip(sections, allocations, strict=True): + if size <= len(_TRUNCATION_MARKER): + continue + fitted.append( + section + if size >= len(section) + else section[: size - len(_TRUNCATION_MARKER)] + _TRUNCATION_MARKER + ) + return fitted + + +def build_session_start_context( + omi_dir: Path | str, *, _context_cap: int | None = None +) -> str: """Build the SessionStart ``additionalContext`` payload. Injects the *content* of the OMI priming notes (:data:`PRIMING_FILES`) @@ -349,9 +388,10 @@ def build_session_start_context(omi_dir: Path | str) -> str: the agent issuing reads, then two dynamic sections: the newest ``Session State YYYY-MM-DD`` handoff note and the last :data:`_JOURNAL_TAIL_BULLETS` bullets of the newest auto-journal. The whole - payload is capped at :data:`_TOTAL_CONTEXT_CHAR_CAP` chars — static files - always win the budget; dynamic sections truncate (or drop) first. Falls - back to a read-the-vault reminder if nothing can be read. Never raises. + payload is capped by the active AI expense profile. Every static note gets a + useful floor and 20% of the remaining budget is reserved for current dynamic + state. Falls back to a read-the-vault reminder if nothing can be read. Never + raises. """ directory = Path(omi_dir) sections: list[str] = [] @@ -393,35 +433,64 @@ def build_session_start_context(omi_dir: Path | str) -> str: "already read. Read any [[wikilinked]] note you need before acting." ) if not sections and not dynamic: - return prefix + ( + fallback = prefix + ( header + " (Priming notes could not be read this session; read index.md, " "Memory Workflow.md, and CLAUDE CODE PERSONALITY.md from the vault " "directly.)" ) - - payload = "\n\n".join([header, *sections]) # static sections are never cut - for section in dynamic: - remaining = _TOTAL_CONTEXT_CHAR_CAP - len(payload) - len("\n\n") - if remaining <= len(_TRUNCATION_MARKER): - break # no useful room left for dynamic content - if len(section) > remaining: - section = section[: remaining - len(_TRUNCATION_MARKER)] + _TRUNCATION_MARKER - payload += "\n\n" + section - return prefix + payload + from omind import ai_usage + + return fallback[: (_context_cap or ai_usage.policy(omi_dir).context_chars)] + + from omind import ai_usage + + cap = _context_cap or ai_usage.policy(omi_dir).context_chars + base = prefix + header + remaining = max(0, cap - len(base) - 2) + # Keep 20% of the available section budget for current state. The newest + # handoff gets 60% of that pool and the journal tail gets 40%; when either is + # absent the other naturally receives the whole pool. + dynamic_budget = int(remaining * 0.20) if dynamic else 0 + static_budget = remaining - dynamic_budget + fitted_static = _allocate_sections(sections, static_budget) + fitted_dynamic: list[str] = [] + if len(dynamic) == 1: + fitted_dynamic = _allocate_sections(dynamic, dynamic_budget, minimum=0) + elif len(dynamic) >= 2: + first = _allocate_sections(dynamic[:1], int(dynamic_budget * 0.60), minimum=0) + second = _allocate_sections( + dynamic[1:2], dynamic_budget - int(dynamic_budget * 0.60), minimum=0 + ) + fitted_dynamic = [*first, *second] + payload = "\n\n".join([base, *fitted_static, *fitted_dynamic]) + if len(payload) > cap: + payload = payload[: cap - len(_TRUNCATION_MARKER)] + _TRUNCATION_MARKER + return payload def emit_session_start_context(omi_dir: Path | str, out: TextIO | None = None) -> None: """Emit OMI priming-note content as SessionStart ``additionalContext``. Never raises.""" sink = out if out is not None else sys.stdout + context = build_session_start_context(omi_dir) payload = { "hookSpecificOutput": { "hookEventName": "SessionStart", - "additionalContext": build_session_start_context(omi_dir), + "additionalContext": context, } } try: sink.write(json.dumps(payload) + "\n") + from omind import ai_usage + + baseline = ( + build_session_start_context(omi_dir, _context_cap=_TOTAL_CONTEXT_CHAR_CAP) + if ai_usage.policy(omi_dir).context_chars < _TOTAL_CONTEXT_CHAR_CAP + else context + ) + ai_usage.record_priming( + omi_dir, len(context), avoided_characters=max(0, len(baseline) - len(context)) + ) except Exception as exc: _record_failure(f"emit_session_start_context({omi_dir})", exc) @@ -482,8 +551,19 @@ def emit_pre_llm_call_context( guard.begin_turn(session, str(event.get("prompt") or "")) if _already_primed(session): return - payload = {"context": build_session_start_context(omi_dir)} + context = build_session_start_context(omi_dir) + payload = {"context": context} sink.write(json.dumps(payload) + "\n") + from omind import ai_usage + + baseline = ( + build_session_start_context(omi_dir, _context_cap=_TOTAL_CONTEXT_CHAR_CAP) + if ai_usage.policy(omi_dir).context_chars < _TOTAL_CONTEXT_CHAR_CAP + else context + ) + ai_usage.record_priming( + omi_dir, len(context), avoided_characters=max(0, len(baseline) - len(context)) + ) except Exception as exc: _record_failure(f"emit_pre_llm_call_context({omi_dir})", exc) diff --git a/src/omind/verify.py b/src/omind/verify.py index fee5d8a..cf04944 100644 --- a/src/omind/verify.py +++ b/src/omind/verify.py @@ -271,34 +271,48 @@ def _parse_verdict(text: str) -> bool | None: return None -def _ask_model(task: str, text: str) -> bool | None: +def _ask_model(task: str, text: str, omi_dir: Path | str | None = None) -> bool | None: """Ask headless ``claude -p`` whether the consult was relevant. ``None`` on any unavailability/error/timeout (the caller fails open).""" - claude = shutil.which("claude") - if not claude: - return None + 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 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 " "below. Answer with exactly one word — RELEVANT or IRRELEVANT — for whether " "that material is relevant to the task.\n\n" f"{_past_mistakes_context()}" - f"TASK:\n{task[:1000]}\n\n" - f"CONSULTED MATERIAL:\n{text[:2000]}\n" + f"TASK:\n{task[:task_cap]}\n\n" + f"CONSULTED MATERIAL:\n{text[:material_cap]}\n" ) try: timeout = int(os.environ.get(_TIMEOUT_ENV) or _DEFAULT_TIMEOUT) except ValueError: timeout = _DEFAULT_TIMEOUT - try: - result = subprocess.run( - [claude, "-p", prompt], capture_output=True, text=True, timeout=timeout - ) - except (subprocess.TimeoutExpired, OSError): - return None - if result.returncode != 0: - return None - return _parse_verdict(result.stdout or "") + 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. + claude = shutil.which("claude") + if not claude: + return None + try: + result = subprocess.run( + [claude, "-p", prompt], 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, + ) + return _parse_verdict(response or "") def _signal_score(signal: str, text: str) -> float: @@ -316,7 +330,13 @@ def _signal_score(signal: str, text: str) -> float: return max(keyword, semantic) if semantic is not None else keyword -def _judge_scored(task: str, activity: str, text: str, pending: str = "") -> tuple[bool, float]: +def _judge_scored( + task: str, + activity: str, + text: str, + pending: str = "", + omi_dir: Path | str | None = None, +) -> 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.""" @@ -333,7 +353,11 @@ def _judge_scored(task: str, activity: str, text: str, pending: str = "") -> tup return True, score if score <= low: return False, score - verdict = _ask_model(task or activity or pending, text) + 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) + ) return (True if verdict is None else verdict), score @@ -419,7 +443,9 @@ def verify_consult( activity = recent_activity(session, omi_dir, now=now) # #96/#97: the gate-blocked action (path noise stripped) — the agent's freshest intent. pending = retrieve.normalize_intent(guard.pending_intent(session)) - judged, score = _judge_scored(task, activity, _consult_text(kind, target, omi_dir), pending) + judged, score = _judge_scored( + task, activity, _consult_text(kind, target, omi_dir), pending, omi_dir + ) relevant = _always_relevant(target) or _guard_demanded(session, target) or judged guard.record_consult(session, kind=kind, target=target, relevant=relevant) if relevant: diff --git a/src/omind/web/app.py b/src/omind/web/app.py index ca93883..b674d2e 100644 --- a/src/omind/web/app.py +++ b/src/omind/web/app.py @@ -13,13 +13,14 @@ from collections.abc import Callable from dataclasses import asdict from pathlib import Path -from typing import TypeVar +from typing import Literal, TypeVar from fastapi import FastAPI, HTTPException from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from starlette.middleware.trustedhost import TrustedHostMiddleware +from omind import ai_usage from omind import graph as graph_mod from omind.store import ( NoteConflictError, @@ -63,6 +64,10 @@ class RawUpdate(BaseModel): content: str +class AIProfileUpdate(BaseModel): + profile: Literal["low", "medium", "high"] + + #: Host headers accepted by default (a localhost bind). ``testserver`` is #: Starlette's TestClient host. DEFAULT_ALLOWED_HOSTS = ["localhost", "127.0.0.1", "[::1]", "testserver"] @@ -93,6 +98,21 @@ def get_meta() -> dict[str, object]: # mesh tells the UI whether DELETE archives (restorable) or removes. return {"mesh": store.mesh_mode()} + @app.get("/api/ai/profile") + async def get_ai_profile() -> dict[str, object]: + return ai_usage.profile_payload(store.omi_dir) + + @app.put("/api/ai/profile") + async def put_ai_profile(payload: AIProfileUpdate) -> dict[str, str]: + try: + return ai_usage.set_profile(store.omi_dir, payload.profile) + except OSError as exc: + raise HTTPException(status_code=500, detail="could not save AI profile") from exc + + @app.get("/api/ai/usage") + async def get_ai_usage(since: Literal["24h", "7d", "30d", "all"] = "7d") -> dict[str, object]: + return ai_usage.usage_summary(store.omi_dir, since=since) + @app.get("/api/notes/{name}") def get_note(name: str) -> dict[str, object]: raw = _guard(lambda: store.read_note(name)) diff --git a/src/omind/web/static/app.css b/src/omind/web/static/app.css index a1e9f43..1bdb819 100644 --- a/src/omind/web/static/app.css +++ b/src/omind/web/static/app.css @@ -177,6 +177,58 @@ body, color: var(--text); } +/* ---- AI usage ---------------------------------------------------------- */ + +.ai-sheet { max-width: 1040px; } +.ai-heading, +.ai-controls, +.ai-measurement { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} +.ai-controls label { + display: grid; + gap: 0.35rem; + color: var(--text-faint); + font: 600 10px var(--font-ui); + letter-spacing: 0.1em; + text-transform: uppercase; +} +.ai-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.75rem; + margin: 2rem 0 1rem; +} +.ai-stat { + display: grid; + gap: 0.5rem; + padding: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface-2); +} +.ai-stat span, +.ai-measurement, +.ai-table th { + color: var(--text-faint); + font: 600 10px var(--font-ui); + letter-spacing: 0.08em; + text-transform: uppercase; +} +.ai-stat strong { font: 800 1.45rem var(--font-ui); } +.ai-measurement { justify-content: flex-start; margin: 1rem 0; } +.ai-table { width: 100%; border-collapse: collapse; } +.ai-table th, +.ai-table td { padding: 0.75rem; border-bottom: 1px solid var(--border); text-align: start; } +.ai-table td { font-family: "JetBrains Mono", monospace; font-size: 12px; } +@media (max-width: 900px) { + .ai-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .ai-heading { align-items: flex-start; flex-direction: column; } +} + /* ---- Theme swatches ---------------------------------------------------- */ .swatch { diff --git a/src/omind/web/static/app.js b/src/omind/web/static/app.js index c1908ed..5cfc6cf 100644 --- a/src/omind/web/static/app.js +++ b/src/omind/web/static/app.js @@ -9,7 +9,7 @@ const state = { activeTag: null, current: null, // filename currentVersion: "", // mtime+size token of the open note, for conflict detection - mode: "empty", // empty | view | edit | raw | new + mode: "empty", // empty | view | edit | raw | new | ai lang: "en", showArchived: false, // include soft-deleted (Disabled: true) notes in the list mesh: false, // server-reported: DELETE archives (restorable) instead of removing @@ -66,6 +66,12 @@ const I18N = { archivedToggle: "archived", archivedBadge: "archived", restore: "Restore", restoredToast: "Restored.", archivedToast: "Archived.", confirmArchive: 'Archive "{name}"? It stays restorable.', + aiUsage: "AI Usage", expenseProfile: "Model expense", window: "Window", + inputTokens: "Input tokens", outputTokens: "Output tokens", + cacheTokens: "Cache tokens", avoidedTokens: "Estimated avoided", + exactUsage: "Provider-reported", estimatedUsage: "Estimated", + attributionNote: "OMI overhead only · profiles do not select the model", + operation: "Operation", profileSaved: "Profile saved.", }, es: { tagline: "memoria", search: "buscar…", theme: "Tema", language: "Idioma", @@ -93,6 +99,12 @@ const I18N = { archivedToggle: "archivadas", archivedBadge: "archivada", restore: "Restaurar", restoredToast: "Restaurada.", archivedToast: "Archivada.", confirmArchive: "¿Archivar «{name}»? Podrás restaurarla.", + aiUsage: "Uso de IA", expenseProfile: "Coste del modelo", window: "Periodo", + inputTokens: "Tokens de entrada", outputTokens: "Tokens de salida", + cacheTokens: "Tokens en caché", avoidedTokens: "Ahorro estimado", + exactUsage: "Informado por el proveedor", estimatedUsage: "Estimado", + attributionNote: "Solo sobrecarga de OMI · los perfiles no eligen el modelo", + operation: "Operación", profileSaved: "Perfil guardado.", }, fr: { tagline: "mémoire", search: "rechercher…", theme: "Thème", language: "Langue", @@ -121,6 +133,12 @@ const I18N = { archivedToggle: "archivées", archivedBadge: "archivée", restore: "Restaurer", restoredToast: "Restaurée.", archivedToast: "Archivée.", confirmArchive: "Archiver « {name} » ? Restauration possible.", + aiUsage: "Utilisation IA", expenseProfile: "Coût du modèle", window: "Période", + inputTokens: "Jetons d’entrée", outputTokens: "Jetons de sortie", + cacheTokens: "Jetons en cache", avoidedTokens: "Économie estimée", + exactUsage: "Rapporté par le fournisseur", estimatedUsage: "Estimé", + attributionNote: "Surcharge OMI uniquement · le profil ne choisit pas le modèle", + operation: "Opération", profileSaved: "Profil enregistré.", }, ar: { tagline: "ذاكرة", search: "بحث…", theme: "السمة", language: "اللغة", @@ -147,6 +165,12 @@ const I18N = { archivedToggle: "المؤرشفة", archivedBadge: "مؤرشفة", restore: "استعادة", restoredToast: "تمت الاستعادة.", archivedToast: "تمت الأرشفة.", confirmArchive: "أرشفة «{name}»؟ يمكن استعادتها لاحقًا.", + aiUsage: "استخدام الذكاء الاصطناعي", expenseProfile: "تكلفة النموذج", window: "الفترة", + inputTokens: "رموز الإدخال", outputTokens: "رموز الإخراج", + cacheTokens: "رموز التخزين المؤقت", avoidedTokens: "التوفير التقديري", + exactUsage: "حسب تقرير المزوّد", estimatedUsage: "تقديري", + attributionNote: "تكلفة OMI فقط · الملفات لا تختار النموذج", + operation: "العملية", profileSaved: "تم حفظ الملف.", }, ru: { tagline: "память", search: "поиск…", theme: "Тема", language: "Язык", @@ -173,6 +197,12 @@ const I18N = { archivedToggle: "архив", archivedBadge: "в архиве", restore: "Восстановить", restoredToast: "Восстановлено.", archivedToast: "В архиве.", confirmArchive: "Архивировать «{name}»? Её можно будет восстановить.", + aiUsage: "Использование ИИ", expenseProfile: "Стоимость модели", window: "Период", + inputTokens: "Входные токены", outputTokens: "Выходные токены", + cacheTokens: "Токены кэша", avoidedTokens: "Оценка экономии", + exactUsage: "Отчёт провайдера", estimatedUsage: "Оценка", + attributionNote: "Только накладные расходы OMI · профиль не выбирает модель", + operation: "Операция", profileSaved: "Профиль сохранён.", }, zh: { tagline: "记忆", search: "搜索…", theme: "主题", language: "语言", @@ -197,6 +227,12 @@ const I18N = { archivedToggle: "已归档", archivedBadge: "已归档", restore: "恢复", restoredToast: "已恢复。", archivedToast: "已归档。", confirmArchive: "归档“{name}”?之后仍可恢复。", + aiUsage: "AI 用量", expenseProfile: "模型费用", window: "时间范围", + inputTokens: "输入令牌", outputTokens: "输出令牌", + cacheTokens: "缓存令牌", avoidedTokens: "预计节省", + exactUsage: "提供商报告", estimatedUsage: "估算", + attributionNote: "仅 OMI 开销 · 配置不会选择模型", + operation: "操作", profileSaved: "配置已保存。", }, }; @@ -371,6 +407,7 @@ function renderSidebar() { function renderEmpty() { teardownGraph(); + $("#ai-btn").classList.remove("active"); state.mode = "empty"; state.current = null; renderSidebar(); @@ -386,6 +423,7 @@ function renderEmpty() { async function openNote(name) { teardownGraph(); + $("#ai-btn").classList.remove("active"); try { const data = await api("GET", `/api/notes/${encodeURIComponent(name)}`); state.current = name; @@ -597,6 +635,7 @@ function openEdit(data) { function openNew() { teardownGraph(); + $("#ai-btn").classList.remove("active"); state.mode = "new"; state.current = null; renderSidebar(); @@ -719,12 +758,76 @@ searchEl.addEventListener("input", () => { }); $("#new-btn").addEventListener("click", openNew); +// ---- AI usage -------------------------------------------------------------- + +const tokenNumber = (value) => Number(value || 0).toLocaleString(); + +async function openAI(since = "7d") { + teardownGraph(); + state.current = null; + state.mode = "ai"; + $("#ai-btn").classList.add("active"); + $("#graph-btn").classList.remove("active"); + try { history.replaceState(null, "", "#ai"); } catch (_) {} + renderSidebar(); + try { + const usage = await api("GET", `/api/ai/usage?since=${encodeURIComponent(since)}`); + const totals = usage.totals; + const exact = usage.exact; + const estimated = usage.estimated; + const profile = usage.profile; + const rows = Object.entries(usage.operations).map(([name, values]) => ` +
| ${escapeHtml(t("operation"))} | +${escapeHtml(t("inputTokens"))} | ${escapeHtml(t("outputTokens"))} | +${escapeHtml(t("avoidedTokens"))} |
|---|