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
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
from .codex import _handle_codex_context_event, _handle_post_edit_use
from .read_state import _handle_edit_post, _handle_read_post, _record_edit
from .search import _handle_search_post
from .served_reads import _handle_mcp_read_post, _log_read_after_served
from .session_start import _handle_claude_session_start

_EDIT_TOOL_NAMES = {"apply_patch", "Edit", "Write"}
Expand Down Expand Up @@ -246,11 +247,18 @@ def _handle_post_tool_use(
return _handle_post_edit_use(cwd)
return _handle_edit_post(tool_input, cwd, session_id)
if tool_name == "Read":
# Read-after-served KPI: logged to the ledger, never spoken about.
_log_read_after_served(tool_input, tool_output, cwd, session_id)
return _handle_read_post(tool_input, tool_output, cwd, session_id)
if tool_name in ("Bash", "PowerShell"):
# The PowerShell tool (Windows Claude Code) surfaces the same
# stdout/stderr response shape as Bash — one handler covers both.
return _handle_bash_post(tool_input, tool_output, cwd)
if tool_name in ("Grep", "Glob"):
return _handle_search_post(tool_name, tool_input, tool_output, cwd)
return _handle_search_post(tool_name, tool_input, tool_output, cwd, session_id)
if tool_name.startswith("mcp__") and "repowise" in tool_name.lower():
# Served-content bookkeeping for the read-after-served KPI. Never
# emits — measurement only.
_handle_mcp_read_post(tool_output, cwd, session_id)
return None
return None
Original file line number Diff line number Diff line change
Expand Up @@ -547,9 +547,20 @@ def _edit_decision_notice(repo_path: Path, rel: str, session_id: str, state: dic
"session_id TEXT NOT NULL, decision_id TEXT NOT NULL, "
"node_id TEXT NOT NULL DEFAULT '', shown_at REAL NOT NULL, "
"evaluated INTEGER NOT NULL DEFAULT 0, "
"surface TEXT NOT NULL DEFAULT '', "
"category TEXT NOT NULL DEFAULT '', "
"chars INTEGER NOT NULL DEFAULT 0, "
"PRIMARY KEY (session_id, decision_id))"
)

#: Mirror of core.sessions.staging.INJECTIONS_LEDGER_COLUMNS — the hook path
#: must not import repowise.core, so the migration is duplicated verbatim.
_LEDGER_COLUMNS = (
("surface", "TEXT NOT NULL DEFAULT ''"),
("category", "TEXT NOT NULL DEFAULT ''"),
("chars", "INTEGER NOT NULL DEFAULT 0"),
)


def _open_injections(repo_path: Path) -> sqlite3.Connection | None:
db_path = repo_path / ".repowise" / "sessions" / "sessions.db"
Expand All @@ -559,6 +570,10 @@ def _open_injections(repo_path: Path) -> sqlite3.Connection | None:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=1000")
conn.execute(_INJECTIONS_TABLE_SQL)
existing = {row[1] for row in conn.execute("PRAGMA table_info(injections)")}
for name, decl in _LEDGER_COLUMNS:
if name not in existing:
conn.execute(f"ALTER TABLE injections ADD COLUMN {name} {decl}")
return conn
except (sqlite3.Error, OSError):
return None
Expand All @@ -581,8 +596,9 @@ def _record_injections(
try:
now = time.time()
conn.executemany(
"INSERT OR IGNORE INTO injections (session_id, decision_id, node_id, shown_at) "
"VALUES (?, ?, ?, ?)",
"INSERT OR IGNORE INTO injections "
"(session_id, decision_id, node_id, shown_at, surface, category) "
"VALUES (?, ?, ?, ?, 'decision', 'session_start')",
[(session_id, did, node_id, now) for did in decision_ids],
)
conn.commit()
Expand All @@ -592,6 +608,48 @@ def _record_injections(
conn.close()


def _claim_ledger(
repo_path: Path,
session_id: str,
key: str,
*,
node_id: str,
surface: str,
category: str,
chars: int,
) -> tuple[bool, int]:
"""Atomically claim one non-decision ledger emission.

Generic twin of :func:`_claim_injection` for the read/search enrichment
surfaces: *key* replaces the decision id in the primary key, so INSERT OR
IGNORE is the once-per-session-per-key gate. Returns ``(claimed,
surface_injection_count)`` where the count covers only rows that actually
carried text (``chars > 0``) on *surface* — pure measurement rows must not
eat into an injection cap. Fail-closed: any error reports unclaimed.
"""
conn = _open_injections(repo_path)
if conn is None:
return False, 0
try:
cur = conn.execute(
"INSERT OR IGNORE INTO injections "
"(session_id, decision_id, node_id, shown_at, surface, category, chars) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(session_id, key, node_id, time.time(), surface, category, chars),
)
claimed = cur.rowcount > 0
count = conn.execute(
"SELECT COUNT(*) FROM injections WHERE session_id = ? AND surface = ? AND chars > 0",
(session_id, surface),
).fetchone()[0]
conn.commit()
return claimed, int(count)
except sqlite3.Error:
return False, 0
finally:
conn.close()


def _claim_injection(
repo_path: Path, session_id: str, decision_id: str, node_id: str
) -> tuple[bool, int]:
Expand All @@ -608,13 +666,17 @@ def _claim_injection(
return False, 0
try:
cur = conn.execute(
"INSERT OR IGNORE INTO injections (session_id, decision_id, node_id, shown_at) "
"VALUES (?, ?, ?, ?)",
"INSERT OR IGNORE INTO injections "
"(session_id, decision_id, node_id, shown_at, surface, category) "
"VALUES (?, ?, ?, ?, 'decision', 'edit_notice')",
(session_id, decision_id, node_id, time.time()),
)
claimed = cur.rowcount > 0
# Surface-scoped: read/search enrichment rows also carry a node_id and
# must not eat into the edit-notice cap.
count = conn.execute(
"SELECT COUNT(*) FROM injections WHERE session_id = ? AND node_id != ''",
"SELECT COUNT(*) FROM injections WHERE session_id = ? AND node_id != '' "
"AND surface IN ('', 'decision')",
(session_id,),
).fetchone()[0]
conn.commit()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ def _load_session_state(repo_path: Path, session_id: str) -> dict:
"stale_notified": [],
"reread_notified": [],
"decisions_shown": [],
# File ranges served by repowise MCP responses this session, kept for
# the read-after-served KPI (see read_enrich; rel -> [[start, end]]).
"served": {},
}
try:
state = json.loads(_session_state_path(repo_path).read_text(encoding="utf-8"))
Expand Down
35 changes: 34 additions & 1 deletion packages/cli/src/repowise/cli/commands/augment_cmd/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ def _handle_search_post(
tool_input: dict,
tool_output: object,
cwd: str,
session_id: str = "",
) -> str | None:
"""Decide whether to enrich a Grep/Glob result and how."""
repo_path = _find_repo_root(Path(cwd))
Expand All @@ -40,6 +41,7 @@ def _handle_search_post(
if result_count >= _DIGEST_THRESHOLD:
digest = _grep_flood_digest(repo_path, output_text)
if digest:
_log_search_firing(repo_path, session_id, "digest", output_text, digest)
return digest
# Unparseable output (e.g. Glob path lists): fall through to triage.

Expand Down Expand Up @@ -69,7 +71,38 @@ def _handle_search_post(

import asyncio

return asyncio.run(_search_enrich(repo_path, pattern, mode, result_count))
enrichment = asyncio.run(_search_enrich(repo_path, pattern, mode, result_count))
if enrichment:
_log_search_firing(repo_path, session_id, mode, pattern, enrichment)
return enrichment


def _log_search_firing(
repo_path: Path, session_id: str, category: str, keyed_on: str, text: str
) -> None:
"""Record one search enrichment in the shared ledger; measurement only.

All hook surfaces share the sessions.db efficacy ledger so the miner can
classify used vs ignored firings in one pass. Keyed on the category plus a
content hash — the same rescue repeated in one session logs once. Never
changes what the agent sees; any failure is silent.
"""
if not session_id:
return
import hashlib

from .decision_inject import _claim_ledger

digest = hashlib.sha1(keyed_on.encode("utf-8", "replace")).hexdigest()[:12]
_claim_ledger(
repo_path,
session_id,
f"search:{category}:{digest}",
node_id="",
surface="search",
category=category,
chars=len(text),
)


def _grep_flood_digest(repo_path: Path, output_text: str) -> str | None:
Expand Down
Loading
Loading