Files
wehub-resource-sync 0ef5fcb1c5
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 0s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 0s
Dev Containers / validate-worktree (push) Failing after 0s
CI / changes (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Deploy Documentation / deploy (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 1s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
OpenCode Plugin / typecheck + build + test (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 1s
Init E2E / docker-init-e2e (push) Failing after 4s
Merge Conflicts / merge-conflicts (push) Failing after 4s
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / promote-latest (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:03:20 +08:00

1714 lines
65 KiB
Python

"""Live Traffic Pattern Learner — extracts memories from proxy traffic.
Hooks into the proxy request/response pipeline to learn patterns without
any LLM calls. Rule-based extraction from traffic the proxy already sees:
- Error → Recovery patterns (tool fails → next success teaches right approach)
- Environment facts (commands that work/fail, paths, tool availability)
- Preference signals (repeated patterns, corrections)
- Architectural decisions (file references, dependency choices)
Usage:
learner = TrafficLearner(memory_backend)
await learner.on_request(messages, agent_type="claude")
await learner.on_response(response, messages, agent_type="claude")
The learner is designed to be zero-config and zero-latency: it processes
patterns in the background and never blocks the proxy pipeline.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import os
import re
import sqlite3
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar
if TYPE_CHECKING:
from headroom.learn.models import ProjectInfo
from headroom.memory.backends.local import LocalBackend
logger = logging.getLogger(__name__)
# Minimum seconds between successive flush_to_file calls when driven by the
# dirty-flag worker. Prevents CLAUDE.md thrash during bursty traffic while
# still staying "near real-time" from the user's perspective.
FLUSH_DEBOUNCE_SECONDS = 10.0
# Absolute file-path heuristic for anchoring a pattern to a project root.
# Matches POSIX paths (starts with /) and common Windows drive paths.
_ABS_PATH_RE = re.compile(r"(?:[A-Za-z]:[\\/]|/)[\w./\\@\-]+")
# Error-recovery refinement: the Learned: error recovery section is capped,
# decayed, and re-validated at render time. Other categories are untouched.
_ERROR_RECOVERY_SECTION_CAP = 15
_ERROR_RECOVERY_HALF_LIFE_DAYS = 5.0
_ERROR_RECOVERY_HARD_FLOOR_DAYS = 21
# Suffixes that vary between otherwise-identical Bash recoveries. Stripping
# them before hashing collapses near-duplicates.
_BASH_VOLATILE_SUFFIX_RE = re.compile(
r"(?:\s*\|\s*(?:head|tail)\s+-n?\s*\d+"
r"|\s+-A\s*\d+|\s+-B\s*\d+|\s+-C\s*\d+"
r"|\s+2>&1|\s+2>/dev/null)+\s*$"
)
# =============================================================================
# Pattern Categories
# =============================================================================
class PatternCategory(str, Enum):
"""Categories of patterns extracted from traffic."""
ERROR_RECOVERY = "error_recovery" # Tool failed → next call succeeded
ENVIRONMENT = "environment" # Working commands, paths, tool availability
PREFERENCE = "preference" # Repeated choices, corrections
ARCHITECTURE = "architecture" # File structure, dependencies, conventions
class AgentType(str, Enum):
"""Supported coding agent types."""
CLAUDE = "claude"
CURSOR = "cursor"
CODEX = "codex"
AIDER = "aider"
GEMINI = "gemini"
UNKNOWN = "unknown"
# =============================================================================
# Extracted Pattern Model
# =============================================================================
@dataclass
class ExtractedPattern:
"""A pattern extracted from proxy traffic."""
category: PatternCategory
content: str # Human-readable memory content
importance: float # 0.0 - 1.0
evidence_count: int = 1 # How many times this pattern was observed
entity_refs: list[str] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
content_hash: str = ""
first_seen_at: datetime | None = None
last_seen_at: datetime | None = None
def __post_init__(self) -> None:
if not self.content_hash:
key = _normalize_hash_key(self.category, self.content, self.metadata)
self.content_hash = hashlib.sha256(key.encode()).hexdigest()[:16]
def _normalize_hash_key(
category: PatternCategory,
content: str,
metadata: dict[str, Any],
) -> str:
"""Build the string that feeds the content hash.
Error-recovery rows are collapsed on recovery intent, not literal text:
trivial invocation differences (tail counts, pipe suffixes, full paths
that share a basename) hash to the same key. Other categories hash the
raw content for backwards compatibility.
"""
if category is not PatternCategory.ERROR_RECOVERY:
return content
tool = metadata.get("tool")
if tool == "Read":
error_path = metadata.get("error_path", "")
success_path = metadata.get("success_path", "")
return (
f"error_recovery|Read|{os.path.basename(error_path)}|{os.path.basename(success_path)}"
)
if tool == "Bash":
failed = metadata.get("failed_cmd", "")
success = metadata.get("success_cmd", "")
return (
f"error_recovery|Bash|"
f"{_normalize_bash_for_hash(failed)}|{_normalize_bash_for_hash(success)}"
)
return content
def _normalize_bash_for_hash(cmd: str) -> str:
"""Strip volatile suffixes and truncate at the first pipe/chain boundary."""
if not cmd:
return ""
# Drop paging, line-context flags, and redirections that vary between runs.
trimmed = _BASH_VOLATILE_SUFFIX_RE.sub("", cmd).strip()
# Cut at the first pipe or && so we hash the primary command, not the tail.
for sep in (" | ", " && "):
idx = trimmed.find(sep)
if idx != -1:
trimmed = trimmed[:idx].rstrip()
break
return trimmed
# =============================================================================
# Error Classification (reused from learn/scanner.py patterns)
# =============================================================================
_ERROR_PATTERNS: list[tuple[re.Pattern[str], str]] = [
(
re.compile(r"No such file or directory|ENOENT|FileNotFoundError|does not exist", re.I),
"file_not_found",
),
(re.compile(r"ModuleNotFoundError|ImportError|No module named", re.I), "module_not_found"),
(re.compile(r"command not found", re.I), "command_not_found"),
(re.compile(r"Permission denied|EACCES|EPERM|auto-denied", re.I), "permission_denied"),
(re.compile(r"file is too large|too many lines|exceeds.*limit", re.I), "file_too_large"),
(re.compile(r"SyntaxError|IndentationError", re.I), "syntax_error"),
(re.compile(r"Traceback \(most recent|Exception:|Error:", re.I), "runtime_error"),
(re.compile(r"timed? ?out|TimeoutError|deadline exceeded", re.I), "timeout"),
(re.compile(r"exit code|non-zero|exited with", re.I), "exit_code"),
(re.compile(r"BUILD FAILED|compilation error|compile error", re.I), "build_failure"),
]
def _classify_error(content: str) -> str | None:
"""Classify error content. Returns category or None if not an error."""
snippet = content[:2000]
for pattern, category in _ERROR_PATTERNS:
if pattern.search(snippet):
return category
return None
def _is_error(content: str) -> bool:
"""Quick check if tool output looks like an error."""
if not content or len(content) < 10:
return False
return _classify_error(content) is not None
# =============================================================================
# Tool Call Extractors
# =============================================================================
# Extract command from Bash tool calls
_COMMAND_RE = re.compile(r"^(?:source\s+\S+\s*&&\s*)?(.+)", re.I)
# Extract file paths
_FILE_PATH_RE = re.compile(r"(?:/[\w./-]+(?:\.\w+)?)")
# Extract package/module names from errors
_MODULE_RE = re.compile(r"No module named ['\"]?(\w[\w.]*)['\"]?")
_COMMAND_NF_RE = re.compile(r"(\w[\w-]*): command not found")
def _levenshtein(a: str, b: str) -> int:
"""Iterative Levenshtein distance. Pure Python, no deps.
Bounded use only — callers should keep input sizes reasonable
(basenames, command strings) to avoid O(n*m) blowups.
"""
if a == b:
return 0
if not a:
return len(b)
if not b:
return len(a)
if len(a) > len(b):
a, b = b, a
prev = list(range(len(a) + 1))
for j, cb in enumerate(b, 1):
curr = [j] + [0] * len(a)
for i, ca in enumerate(a, 1):
cost = 0 if ca == cb else 1
curr[i] = min(curr[i - 1] + 1, prev[i] + 1, prev[i - 1] + cost)
prev = curr
return prev[-1]
def _paths_related_as_typo(failed: str, success: str) -> bool:
"""Heuristic: are these two file paths plausibly the same target?
Two paths are "related as typo recovery" if their basenames are
identical or close in edit distance. Different basenames in the same
directory (e.g. `state.rs` vs `lib.rs`) are NOT related — the matcher
must reject them, otherwise unrelated reads get paired into bogus
"File X does not exist, use Y" rules.
"""
if not failed or not success or failed == success:
return False
a = failed.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
b = success.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
if not a or not b:
return False
if a == b:
return True
threshold = max(2, max(len(a), len(b)) // 3)
return _levenshtein(a, b) <= threshold
# Tokens that occur in many unrelated commands and don't, by themselves,
# suggest two commands are related retries.
_COMMAND_NOISE_TOKENS = frozenset(
{
"head",
"tail",
"cat",
"grep",
"awk",
"sed",
"sort",
"uniq",
"wc",
"xargs",
"find",
}
)
def _bash_first_binary(cmd: str) -> str | None:
"""Return the first binary name in a Bash command, or None.
Strips a leading `source <venv> && ` prefix and skips over `VAR=value`
environment-variable assignments before the binary. Used to gate
command-recovery pairing: if two commands don't share a binary, they
are not retries of each other.
"""
s = cmd.strip()
m = re.match(r"source\s+\S+\s*&&\s*(.*)", s, re.I)
if m:
s = m.group(1)
for tok in s.split():
if "=" in tok and tok.split("=", 1)[0].replace("_", "").isalnum():
continue
return tok
return None
def _bash_binaries_match(a: str, b: str) -> bool:
"""Treat two binaries as 'the same tool' for recovery purposes.
Equal strings, basename equality (`ruff` vs `.venv/bin/ruff`), and
short prefix-style versions (`python` vs `python3`) all qualify.
Different tools (`grep` vs `find`) do not.
"""
if a == b:
return True
a_base = a.rsplit("/", 1)[-1]
b_base = b.rsplit("/", 1)[-1]
if a_base == b_base:
return True
if (a_base.startswith(b_base) or b_base.startswith(a_base)) and _levenshtein(
a_base, b_base
) <= 2:
return True
return False
def _commands_related_as_retry(failed: str, success: str) -> bool:
"""Heuristic: is `success` plausibly a corrected retry of `failed`?
Requires the same binary AND either:
- low normalized edit distance (≤40% of max length), OR
- at least one shared substantive token (length ≥ 5, not a flag,
not a generic shell verb).
The bar is conservative: noise like `grep <pattern A> <file A>` paired
with `grep <pattern B> <file B>` shares the `grep` binary but no real
arguments, and gets rejected. Genuine retries (extra flag, single
arg edit) pass via the edit-distance path.
"""
if not failed or not success or failed == success:
return False
bin_a = _bash_first_binary(failed)
bin_b = _bash_first_binary(success)
if not bin_a or not bin_b or not _bash_binaries_match(bin_a, bin_b):
return False
max_len = max(len(failed), len(success))
if max_len > 0 and _levenshtein(failed, success) / max_len <= 0.40:
return True
def _substantive(cmd: str, binary: str) -> set[str]:
out: set[str] = set()
for tok in cmd.split():
if len(tok) < 5 or tok.startswith("-") or tok == binary:
continue
if tok.lower() in _COMMAND_NOISE_TOKENS:
continue
out.add(tok)
return out
return bool(_substantive(failed, bin_a) & _substantive(success, bin_b))
_FILE_X_DOES_NOT_EXIST_RE = re.compile(
r"^File `([^`]+)` does not exist\. The correct path is `([^`]+)`\.$"
)
def _drop_contradictions(patterns: list[ExtractedPattern]) -> list[ExtractedPattern]:
"""Remove A→B and B→A pairs from error_recovery patterns.
When the matcher emits a "File X does not exist, use Y" rule and the
inverse "File Y does not exist, use X" rule, both are likely the
result of opposite-direction typos rather than a stable truth. Drop
both rather than persisting contradictory advice.
"""
forward: dict[tuple[str, str], int] = {}
for idx, p in enumerate(patterns):
if p.category != PatternCategory.ERROR_RECOVERY:
continue
m = _FILE_X_DOES_NOT_EXIST_RE.match(p.content)
if not m:
continue
forward[(m.group(1), m.group(2))] = idx
drop: set[int] = set()
for (a, b), idx_ab in forward.items():
idx_ba = forward.get((b, a))
if idx_ba is not None:
drop.add(idx_ab)
drop.add(idx_ba)
if not drop:
return patterns
return [p for i, p in enumerate(patterns) if i not in drop]
# =============================================================================
# Traffic Learner
# =============================================================================
class TrafficLearner:
"""Extracts learnable patterns from live proxy traffic.
Operates entirely on rule-based heuristics — no LLM calls.
Designed to be called from the proxy request/response path
with minimal overhead (async, non-blocking).
"""
def __init__(
self,
backend: LocalBackend | None = None,
user_id: str = "default",
agent_type: str = "unknown",
max_history: int = 20,
dedup_window: int = 100,
min_evidence: int = 5,
) -> None:
"""Initialize the traffic learner.
Args:
backend: Memory backend to save patterns to. If None, patterns
are accumulated but not persisted until a backend is set.
user_id: Default user ID for saved memories.
agent_type: Which coding agent is being wrapped (claude, codex, gemini, etc.).
Used to determine the correct output file for flushing patterns.
max_history: Number of recent tool calls to keep for pattern matching.
dedup_window: Number of recent pattern hashes to track for dedup.
min_evidence: Minimum times a pattern must be seen before saving.
"""
self._backend = backend
self._user_id = user_id
self.agent_type = agent_type
self._max_history = max_history
self._min_evidence = min_evidence
# Recent tool call history for error→recovery matching
self._tool_history: list[dict[str, Any]] = []
# Pattern accumulator: hash → (pattern, count)
self._pattern_counts: dict[str, tuple[ExtractedPattern, int]] = {}
# Dedup: hashes of patterns already saved to DB
self._saved_hashes: set[str] = set()
# content_hash → memory.id for persisted rows. Lets re-sightings
# bump the existing row's evidence_count instead of creating a
# duplicate row.
self._persisted_ids: dict[str, str] = {}
self._dedup_window = dedup_window
# Stats
self._patterns_extracted = 0
self._patterns_saved = 0
self._requests_processed = 0
# Background save queue
self._save_queue: asyncio.Queue[ExtractedPattern] = asyncio.Queue(maxsize=100)
self._save_task: asyncio.Task[None] | None = None
self._stopping = False
# Dirty-flag debounced flush to CLAUDE.md / MEMORY.md. Set whenever
# a pattern is accumulated; checked by _flush_worker.
self._flush_dirty = False
self._last_flush_at = 0.0
self._flush_task: asyncio.Task[None] | None = None
# Cached project roots discovered via the learn plugin registry.
# Populated lazily in flush_to_file.
self._project_roots_cache: list[ProjectInfo] | None = None
# =========================================================================
# Public API
# =========================================================================
def set_backend(self, backend: LocalBackend) -> None:
"""Set or update the memory backend."""
self._backend = backend
async def start(self) -> None:
"""Start the background save worker and flush worker."""
# Hydrate persisted dedup state before workers spin up so cross-session
# re-sightings bump existing rows instead of creating duplicates.
await self._hydrate_persisted_state()
if self._save_task is None or self._save_task.done():
self._save_task = asyncio.create_task(self._save_worker())
if self._flush_task is None or self._flush_task.done():
self._flush_task = asyncio.create_task(self._flush_worker())
async def stop(self) -> None:
"""Stop the background workers, drain the save queue, final flush."""
self._stopping = True
if self._flush_task and not self._flush_task.done():
self._flush_task.cancel()
try:
await self._flush_task
except asyncio.CancelledError:
pass
# Drain any remaining patterns in the queue before cancelling
if self._save_task and not self._save_task.done():
self._save_task.cancel()
try:
await self._save_task
except asyncio.CancelledError:
pass
# Drain any patterns left in the queue (worker may have been cancelled mid-flight)
while not self._save_queue.empty():
try:
pattern = self._save_queue.get_nowait()
if self._backend is not None:
await self._backend.save_memory(
content=pattern.content,
user_id=self._user_id,
importance=pattern.importance,
metadata={
"source": "traffic_learner",
"category": pattern.category.value,
"evidence_count": pattern.evidence_count,
**pattern.metadata,
},
)
self._patterns_saved += 1
except Exception:
break
# Final flush on shutdown — bypass debounce.
await self.flush_to_file()
async def _flush_worker(self) -> None:
"""Background worker: call flush_to_file when dirty, rate-limited."""
while True:
try:
await asyncio.sleep(2.0)
if not self._flush_dirty:
continue
if time.monotonic() - self._last_flush_at < FLUSH_DEBOUNCE_SECONDS:
continue
# Reset before flushing so patterns accumulated during the
# flush still trigger a follow-up.
self._flush_dirty = False
self._last_flush_at = time.monotonic()
await self.flush_to_file()
except asyncio.CancelledError:
break
except Exception as e:
logger.warning("Traffic learner flush worker iteration failed: %s", e)
async def flush_to_file(self) -> None:
"""Flush patterns (persisted + in-memory) to agent-native context files.
Buckets patterns by project via longest-matching file path in content
or entity_refs, routes by category to CLAUDE.md vs MEMORY.md, and
delegates the actual write to the learn plugin writer.
Un-anchored patterns (no absolute path in content) are dropped in v1.
"""
try:
from headroom.learn.registry import auto_detect_plugins, get_plugin
except Exception as e:
logger.debug("Traffic learner flush: learn package unavailable (%s)", e)
return
# Resolve plugin: explicit agent_type wins, else first detected plugin.
try:
if self.agent_type and self.agent_type != "unknown":
plugin = get_plugin(self.agent_type)
else:
detected = auto_detect_plugins()
if not detected:
logger.debug("Traffic learner flush: no agent plugins detected")
return
plugin = detected[0]
except KeyError:
logger.debug("No learn plugin for agent_type=%s", self.agent_type)
return
# Gather patterns: persisted rows + in-memory accumulator, deduped.
patterns = self._collect_all_patterns()
if not patterns:
return
# Evidence gate: require self._min_evidence corroborations to flush,
# including at shutdown. One-shot singletons are noise, not signal.
patterns = [p for p in patterns if p.evidence_count >= self._min_evidence]
if not patterns:
return
# Drop A→B / B→A contradictions among error_recovery patterns.
# Both directions appearing with enough evidence usually means
# opposite-direction typos in different sessions, not stable advice.
patterns = _drop_contradictions(patterns)
if not patterns:
return
# Bucket patterns by project.
if self._project_roots_cache is None:
try:
self._project_roots_cache = plugin.discover_projects()
except Exception as e:
logger.warning("discover_projects failed: %s", e)
self._project_roots_cache = []
roots = self._project_roots_cache
if not roots:
logger.debug("Traffic learner flush: no projects discovered, skipping")
return
by_project: dict[Path, list[ExtractedPattern]] = {}
unanchored = 0
for p in patterns:
proj = _project_for_pattern(p, roots)
if proj is None:
unanchored += 1
continue
by_project.setdefault(proj.project_path, []).append(p)
if unanchored:
logger.debug("Traffic learner flush: dropped %d un-anchored pattern(s)", unanchored)
writer = plugin.create_writer()
project_by_path = {p.project_path: p for p in roots}
for project_path, proj_patterns in by_project.items():
project = project_by_path[project_path]
recommendations = _patterns_to_recommendations(proj_patterns)
if not recommendations:
continue
try:
result = writer.write(recommendations, project, dry_run=False)
if result.files_written:
logger.info(
"Traffic learner flushed %d pattern(s) to %s",
len(proj_patterns),
", ".join(str(f) for f in result.files_written),
)
except Exception as e:
logger.warning("Traffic learner write failed for %s: %s", project_path, e)
def _collect_all_patterns(self) -> list[ExtractedPattern]:
"""Merge persisted (memory.db) + in-memory patterns, deduped by content.
Evidence counts are summed across duplicates.
"""
by_hash: dict[str, ExtractedPattern] = {}
now = datetime.now(timezone.utc)
# Persisted rows from memory.db
db_path = _resolve_backend_db_path(self._backend)
if db_path is not None and db_path.exists():
try:
persisted = _load_persisted_patterns_from_sqlite(db_path)
except Exception as e:
logger.debug("Reading persisted traffic patterns failed: %s", e)
persisted = []
for p in persisted:
if p.content_hash in by_hash:
by_hash[p.content_hash].evidence_count += p.evidence_count
else:
by_hash[p.content_hash] = p
# In-memory accumulator (patterns not yet persisted). Re-sightings in
# this session bump last_seen_at to "now" on top of the persisted
# timestamp so recency ranking reflects live activity.
for pattern, count in self._pattern_counts.values():
h = pattern.content_hash
if h in by_hash:
existing = by_hash[h]
existing.evidence_count += count
existing.last_seen_at = now
else:
by_hash[h] = ExtractedPattern(
category=pattern.category,
content=pattern.content,
importance=pattern.importance,
evidence_count=count,
entity_refs=list(pattern.entity_refs),
metadata=dict(pattern.metadata),
content_hash=pattern.content_hash,
first_seen_at=now,
last_seen_at=now,
)
return list(by_hash.values())
def get_learned_patterns(self) -> list[ExtractedPattern]:
"""Return patterns from the in-memory accumulator.
Retained for backwards compatibility. Reads only the accumulator;
does not consult persisted rows. Use flush_to_file() for full data.
"""
return [pattern for pattern, count in self._pattern_counts.values() if count >= 1]
async def on_tool_result(
self,
tool_name: str,
tool_input: dict[str, Any],
tool_output: str,
is_error: bool,
agent_type: str = "unknown",
) -> None:
"""Process a tool call result from proxy traffic.
Called by the proxy after each tool_result block is processed.
Non-blocking — patterns are queued for async persistence.
Args:
tool_name: Name of the tool (Bash, Read, Grep, etc.)
tool_input: Tool input parameters
tool_output: Tool output content
is_error: Whether the tool call failed
agent_type: Which agent is being proxied
"""
self._requests_processed += 1
entry = {
"tool_name": tool_name,
"input": tool_input,
"output": tool_output[:2000], # Cap for memory
"is_error": is_error,
"error_category": _classify_error(tool_output) if is_error else None,
"timestamp": time.time(),
"agent_type": agent_type,
}
# Check for error→recovery pattern BEFORE adding to history
if not is_error and self._tool_history:
patterns = self._extract_error_recovery(entry)
for pattern in patterns:
await self._accumulate(pattern)
# Extract environment patterns
env_patterns = self._extract_environment(entry)
for pattern in env_patterns:
await self._accumulate(pattern)
# Add to history (bounded)
self._tool_history.append(entry)
if len(self._tool_history) > self._max_history:
self._tool_history.pop(0)
async def on_messages(
self,
messages: list[dict[str, Any]],
agent_type: str = "unknown",
) -> None:
"""Process message content for preference/architecture patterns.
Called with the messages array from a proxy request.
Extracts patterns from user corrections, assistant decisions, etc.
Args:
messages: The messages array from the API request
agent_type: Which agent is being proxied
"""
for msg in messages[-3:]: # Only look at recent messages
role = msg.get("role", "")
content = msg.get("content", "")
if isinstance(content, list):
# Extract text from content blocks
content = " ".join(
block.get("text", "")
for block in content
if isinstance(block, dict) and block.get("type") == "text"
)
if not content:
continue
if role == "user":
patterns = self._extract_preferences(content)
for pattern in patterns:
await self._accumulate(pattern)
def get_stats(self) -> dict[str, Any]:
"""Get learner statistics."""
return {
"requests_processed": self._requests_processed,
"patterns_extracted": self._patterns_extracted,
"patterns_saved": self._patterns_saved,
"pending_patterns": len(self._pattern_counts),
"history_size": len(self._tool_history),
}
# =========================================================================
# Pattern Extraction
# =========================================================================
def _extract_error_recovery(self, success_entry: dict[str, Any]) -> list[ExtractedPattern]:
"""Extract error→recovery patterns.
Looks backward in history for recent errors, then checks if the
current successful call is a recovery (same tool, different params).
"""
patterns: list[ExtractedPattern] = []
tool_name = success_entry["tool_name"]
# Look at recent history for matching errors
for i in range(len(self._tool_history) - 1, max(-1, len(self._tool_history) - 6), -1):
prev = self._tool_history[i]
if not prev["is_error"]:
continue
# Same tool type — likely a retry with corrected params
if prev["tool_name"] == tool_name:
pattern = self._build_recovery_pattern(prev, success_entry)
if pattern:
patterns.append(pattern)
break # Only match the most recent error
# Bash → Bash with different command (common for env issues)
if prev["tool_name"] == "Bash" and tool_name == "Bash":
pattern = self._build_command_recovery(prev, success_entry)
if pattern:
patterns.append(pattern)
break
return patterns
def _build_recovery_pattern(
self,
error_entry: dict[str, Any],
success_entry: dict[str, Any],
) -> ExtractedPattern | None:
"""Build a recovery pattern from an error→success pair."""
tool = error_entry["tool_name"]
error_cat = error_entry.get("error_category", "unknown")
if tool == "Bash":
return self._build_command_recovery(error_entry, success_entry)
elif tool == "Read":
error_path = error_entry["input"].get("file_path", "")
success_path = success_entry["input"].get("file_path", "")
# Reject pairs whose basenames don't look like typos of each
# other — different files in the same directory are unrelated
# reads, not a recovery, and emitting a rule is actively wrong.
if not _paths_related_as_typo(error_path, success_path):
return None
content = f"File `{error_path}` does not exist. The correct path is `{success_path}`."
return ExtractedPattern(
category=PatternCategory.ERROR_RECOVERY,
content=content,
importance=0.7,
entity_refs=[success_path],
metadata={
"error_category": error_cat,
"tool": "Read",
"error_path": error_path,
"success_path": success_path,
},
)
elif tool in ("Grep", "Glob"):
error_pattern = error_entry["input"].get("pattern", "")
success_pattern = success_entry["input"].get("pattern", "")
if error_pattern != success_pattern:
content = (
f"Search pattern `{error_pattern}` found no results. "
f"Use `{success_pattern}` instead."
)
return ExtractedPattern(
category=PatternCategory.ERROR_RECOVERY,
content=content,
importance=0.5,
)
return None
def _build_command_recovery(
self,
error_entry: dict[str, Any],
success_entry: dict[str, Any],
) -> ExtractedPattern | None:
"""Build a command recovery pattern from Bash error→success."""
failed_cmd = error_entry["input"].get("command", "")
success_cmd = success_entry["input"].get("command", "")
error_cat = error_entry.get("error_category", "unknown")
if not failed_cmd or not success_cmd or failed_cmd == success_cmd:
return None
# Require the two commands to look like the same operation retried.
# Without this, any failed Bash followed by any Bash success in the
# last 5 calls becomes a "use Y instead of X" rule, even when X and
# Y are unrelated (e.g. two grep calls with different needles and
# different files).
if not _commands_related_as_retry(failed_cmd, success_cmd):
return None
# Determine importance based on error category
importance = 0.7
if error_cat == "command_not_found":
importance = 0.85 # Environment setup is high-value
elif error_cat == "module_not_found":
importance = 0.8
# Truncate long commands
failed_short = failed_cmd[:200]
success_short = success_cmd[:200]
content = f"Command `{failed_short}` fails ({error_cat}). Use `{success_short}` instead."
# Extract entity references
entities: list[str] = []
module_match = _MODULE_RE.search(error_entry["output"])
if module_match:
entities.append(module_match.group(1))
cmd_match = _COMMAND_NF_RE.search(error_entry["output"])
if cmd_match:
entities.append(cmd_match.group(1))
return ExtractedPattern(
category=PatternCategory.ERROR_RECOVERY,
content=content,
importance=importance,
entity_refs=entities,
metadata={
"error_category": error_cat,
"tool": "Bash",
"failed_cmd": failed_short,
"success_cmd": success_short,
},
)
def _extract_environment(self, entry: dict[str, Any]) -> list[ExtractedPattern]:
"""Extract environment facts from tool calls."""
patterns: list[ExtractedPattern] = []
if entry["tool_name"] != "Bash":
return patterns
cmd = entry["input"].get("command", "")
output = entry["output"]
# Successful commands reveal working environment patterns
if not entry["is_error"]:
# Python/venv activation patterns
if "activate" in cmd and "source" in cmd:
# Extract the venv path
venv_match = re.search(r"source\s+(\S+/activate)", cmd)
if venv_match:
venv_path = venv_match.group(1)
patterns.append(
ExtractedPattern(
category=PatternCategory.ENVIRONMENT,
content=f"Python virtual environment: `source {venv_path}` before running Python tools.",
importance=0.8,
entity_refs=[venv_path],
metadata={"type": "venv_activation"},
)
)
# Detect working test commands
if "pytest" in cmd and "PASSED" in output:
patterns.append(
ExtractedPattern(
category=PatternCategory.ENVIRONMENT,
content=f"Working test command: `{cmd[:200]}`",
importance=0.6,
metadata={"type": "test_command"},
)
)
return patterns
# ---------------------------------------------------------------
# Preference detection (GH #464)
# ---------------------------------------------------------------
# The detector is regex-free on purpose. The previous regex-based
# implementation matched scaffolding ("don't mention this
# reminder…" injected by Claude Code's system-reminder blocks) and
# produced mid-sentence truncations because ``.{10,100}`` captured
# an arbitrary 100-char window with no boundary awareness. A
# tokenized scanner is easier to reason about, doesn't suffer
# catastrophic-backtracking edge cases, and lets us layer
# boundary rules (sentence terminator / end-of-input / max-length)
# without nesting more pattern syntax.
# Each entry is a sequence of lowercase tokens that must appear
# in order with whitespace / single-comma separation. ``max_chars``
# caps how much content we'll capture after the last trigger
# token. The "instead" trigger gets a tighter cap because in
# practice its tail tends to be shorter and we want to be less
# forgiving of long rambles after it.
_PREFERENCE_TRIGGERS: ClassVar[tuple[tuple[tuple[str, ...], int], ...]] = (
(("don't",), 98),
(("dont",), 98),
(("do", "not"), 98),
(("stop",), 98),
(("never",), 98),
(("avoid",), 98),
(("no", "use"), 98),
(("no", "try"), 98),
(("no", "do"), 98),
(("instead",), 78),
)
# Characters that mark the end of the captured preference.
_SENTENCE_TERMINATORS: ClassVar[frozenset[str]] = frozenset(".!?\n")
# Characters allowed between the trigger and the start of the
# capture (e.g. the comma in "No, use httpx").
_PRE_CAPTURE_PUNCT: ClassVar[frozenset[str]] = frozenset(",;:")
# Characters stripped from individual tokens before trigger
# matching ("don't," → "don't"; "stop." → "stop"). Whitespace is
# handled separately by the tokenizer.
_TOKEN_STRIP_CHARS: ClassVar[str] = ",.;:!?\"'()[]{}"
def _extract_preferences(self, user_text: str) -> list[ExtractedPattern]:
"""Extract preference signals from user messages.
Defends against GH #464 noise sources:
* Claude Code (and other agent harnesses) inject
``<system-reminder>…</system-reminder>`` blocks into user-role
message bodies. Their content is *not* user-stated
preferences ("don't mention this reminder", "use colgrep
instead of Grep") but the old correction regexes matched
them. We strip those blocks first so reminders never feed
the learner.
* The capture used to be a fixed-length window which produced
mid-sentence truncations. The token-based scanner below
ends each capture at a sentence terminator OR at
end-of-input, and rejects anything that would require
truncation past ``max_chars``.
"""
cleaned = self._strip_system_reminders(user_text)[:500]
correction = self._find_correction(cleaned)
if correction is None:
return []
return [
ExtractedPattern(
category=PatternCategory.PREFERENCE,
content=f"User preference: {correction}",
importance=0.75,
metadata={"type": "correction", "source_text": cleaned[:200]},
)
]
@classmethod
def _find_correction(cls, text: str) -> str | None:
"""Return the captured preference content or ``None``.
Walks the input once, tokenising on whitespace. At every
token position we try each trigger sequence in priority
order; the first satisfied trigger wins.
"""
tokens = cls._tokenize(text)
if not tokens:
return None
for trigger_idx in range(len(tokens)):
for sequence, max_chars in cls._PREFERENCE_TRIGGERS:
if not cls._matches_sequence(tokens, trigger_idx, sequence):
continue
last_token_end = tokens[trigger_idx + len(sequence) - 1][2]
captured = cls._capture_after(text, last_token_end, max_chars)
if captured is None:
continue
return captured
return None
@classmethod
def _matches_sequence(
cls,
tokens: list[tuple[str, int, int]],
start: int,
sequence: tuple[str, ...],
) -> bool:
if start + len(sequence) > len(tokens):
return False
for offset, expected in enumerate(sequence):
actual_token = tokens[start + offset][0]
normalised = actual_token.strip(cls._TOKEN_STRIP_CHARS)
if normalised != expected:
return False
return True
@classmethod
def _capture_after(
cls,
text: str,
capture_after_pos: int,
max_chars: int,
) -> str | None:
"""Capture up to ``max_chars`` of content starting at the first
non-whitespace, non-pre-punct character after ``capture_after_pos``.
Returns ``None`` when the capture would have to truncate past
``max_chars`` without hitting a sentence terminator or
end-of-input. Returns ``None`` for captures shorter than 10
chars (those are noise — likely a stray trigger word with no
real correction following it).
"""
n = len(text)
cap_start = capture_after_pos
while cap_start < n and (
text[cap_start].isspace() or text[cap_start] in cls._PRE_CAPTURE_PUNCT
):
cap_start += 1
cap_end = cap_start
while (
cap_end < n
and (cap_end - cap_start) < max_chars
and text[cap_end] not in cls._SENTENCE_TERMINATORS
):
cap_end += 1
length = cap_end - cap_start
if length < 10:
return None
# If we hit ``max_chars`` without finding a terminator and the
# text continues past us, this is a rambling fragment — reject.
if length >= max_chars and cap_end < n and text[cap_end] not in cls._SENTENCE_TERMINATORS:
return None
captured = text[cap_start:cap_end].strip()
captured = captured.rstrip("".join(cls._SENTENCE_TERMINATORS)).strip()
return captured or None
@staticmethod
def _tokenize(text: str) -> list[tuple[str, int, int]]:
"""Whitespace-split tokenizer.
Returns ``[(lower_token, start, end), …]``. Positions are byte
offsets into the original string so callers can resume
scanning from the end of a token. Tokens are lowercased once,
up front, so trigger comparisons don't have to call
``.lower()`` per match.
"""
out: list[tuple[str, int, int]] = []
i = 0
n = len(text)
while i < n:
while i < n and text[i].isspace():
i += 1
start = i
while i < n and not text[i].isspace():
i += 1
if i > start:
out.append((text[start:i].lower(), start, i))
return out
@staticmethod
def _strip_system_reminders(text: str) -> str:
"""Remove ``<system-reminder>…</system-reminder>`` blocks from text.
Uses a literal scan (``str.find``) rather than a regex so the
matcher cannot accidentally pick up unrelated ``<*>``-shaped
content. Unclosed reminders (missing ``</system-reminder>``)
are dropped to end-of-string — the agent harness writes
balanced tags, and an unbalanced one is corrupt input we
shouldn't index. Matching is case-insensitive on the tag name.
"""
if not text or "<" not in text:
return text
open_tag = "<system-reminder"
close_tag = "</system-reminder>"
lower = text.lower()
out: list[str] = []
cursor = 0
n = len(text)
while cursor < n:
start = lower.find(open_tag, cursor)
if start < 0:
out.append(text[cursor:])
break
out.append(text[cursor:start])
tag_end = text.find(">", start)
if tag_end < 0:
break
close_start = lower.find(close_tag, tag_end + 1)
if close_start < 0:
break
cursor = close_start + len(close_tag)
return "".join(out)
# =========================================================================
# Pattern Accumulation & Persistence
# =========================================================================
async def _accumulate(self, pattern: ExtractedPattern) -> None:
"""Accumulate a pattern, saving when evidence threshold is met."""
self._patterns_extracted += 1
self._flush_dirty = True
h = pattern.content_hash
# Already saved — bump the persisted row's evidence_count rather
# than creating a duplicate.
if h in self._saved_hashes:
memory_id = self._persisted_ids.get(h)
if memory_id is not None:
await self._bump_persisted_evidence(memory_id)
return
# Accumulate evidence
if h in self._pattern_counts:
existing, count = self._pattern_counts[h]
count += 1
self._pattern_counts[h] = (existing, count)
else:
self._pattern_counts[h] = (pattern, 1)
return # First sighting — wait for more evidence
# Check if evidence threshold met
_, count = self._pattern_counts[h]
if count >= self._min_evidence:
# Ready to save
del self._pattern_counts[h]
self._saved_hashes.add(h)
# Trim saved hashes to prevent unbounded growth
if len(self._saved_hashes) > self._dedup_window:
# Remove oldest (arbitrary, set is unordered, but prevents growth)
self._saved_hashes.pop()
# Persist the real accumulated count, not the dataclass default.
pattern.evidence_count = count
try:
self._save_queue.put_nowait(pattern)
except asyncio.QueueFull:
logger.debug("Traffic learner save queue full, dropping pattern")
async def _save_worker(self) -> None:
"""Background worker that persists patterns to memory backend."""
while True:
try:
pattern = await self._save_queue.get()
if self._backend is None:
continue
now_iso = datetime.now(timezone.utc).isoformat()
memory = await self._backend.save_memory(
content=pattern.content,
user_id=self._user_id,
importance=pattern.importance,
metadata={
"source": "traffic_learner",
"category": pattern.category.value,
"evidence_count": pattern.evidence_count,
"first_seen_at": now_iso,
"last_seen_at": now_iso,
**pattern.metadata,
},
)
self._patterns_saved += 1
# Track id so future re-sightings bump this row.
memory_id = getattr(memory, "id", None)
if memory_id is not None:
self._persisted_ids[pattern.content_hash] = memory_id
logger.debug(f"Traffic learner saved pattern: {pattern.content[:80]}")
except asyncio.CancelledError:
break
except Exception as e:
logger.warning(f"Traffic learner save failed: {e}")
async def _hydrate_persisted_state(self) -> None:
"""Load existing traffic_learner rows into _saved_hashes / _persisted_ids.
Runs once at start() so re-sightings across process restarts bump the
existing row rather than inserting a duplicate. Read-only; if the DB
is absent or unreadable we simply skip.
"""
db_path = _resolve_backend_db_path(self._backend)
if db_path is None or not db_path.exists():
return
def _read() -> list[tuple[str, str, str]]:
uri = f"file:{db_path}?mode=ro"
try:
conn = sqlite3.connect(uri, uri=True)
except sqlite3.OperationalError:
return []
try:
rows = conn.execute(
"SELECT id, content, metadata FROM memories "
"WHERE json_extract(metadata, '$.source') = 'traffic_learner'"
).fetchall()
except sqlite3.DatabaseError:
return []
finally:
try:
conn.close()
except Exception:
pass
return [(row[0], row[1] or "", row[2] or "{}") for row in rows]
try:
rows = await asyncio.to_thread(_read)
except Exception as e:
logger.debug("Traffic learner hydrate failed: %s", e)
return
for memory_id, content, metadata_json in rows:
if not content:
continue
try:
metadata = json.loads(metadata_json) if metadata_json else {}
except json.JSONDecodeError:
metadata = {}
category_value = metadata.get("category")
try:
category = PatternCategory(category_value) if category_value else None
except ValueError:
category = None
if category is None:
# Legacy row without category — fall back to literal hash.
key = content
else:
key = _normalize_hash_key(category, content, metadata)
h = hashlib.sha256(key.encode()).hexdigest()[:16]
self._saved_hashes.add(h)
# If multiple rows share the same content (legacy duplicates),
# last-wins — we only need one id to target the bump.
self._persisted_ids[h] = memory_id
async def _bump_persisted_evidence(self, memory_id: str) -> None:
"""Atomically increment a persisted row's metadata.evidence_count."""
db_path = _resolve_backend_db_path(self._backend)
if db_path is None or not db_path.exists():
return
now_iso = datetime.now(timezone.utc).isoformat()
def _bump() -> None:
conn = sqlite3.connect(str(db_path))
try:
conn.execute(
"UPDATE memories SET metadata = json_set("
"metadata, '$.evidence_count', "
"COALESCE(json_extract(metadata, '$.evidence_count'), 0) + 1, "
"'$.last_seen_at', ?"
") WHERE id = ?",
(now_iso, memory_id),
)
conn.commit()
finally:
conn.close()
try:
await asyncio.to_thread(_bump)
except Exception as e:
logger.debug("Traffic learner evidence bump failed for %s: %s", memory_id, e)
# =========================================================================
# Convenience: Extract from Anthropic messages format
# =========================================================================
def extract_tool_results_from_messages(
self,
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Extract tool_result blocks from Anthropic-format messages.
Useful for processing the messages array to find tool calls and
their results for pattern extraction.
Returns list of dicts with: tool_name, input, output, is_error
"""
results: list[dict[str, Any]] = []
# Build tool_use_id → tool_use mapping
tool_uses: dict[str, dict[str, Any]] = {}
for msg in messages:
content = msg.get("content", [])
if not isinstance(content, list):
continue
for block in content:
if isinstance(block, dict) and block.get("type") == "tool_use":
tool_uses[block.get("id", "")] = block
# Find tool_results and match with tool_uses
for msg in messages:
content = msg.get("content", [])
if not isinstance(content, list):
continue
for block in content:
if not isinstance(block, dict) or block.get("type") != "tool_result":
continue
tool_use_id = block.get("tool_use_id", "")
tool_use = tool_uses.get(tool_use_id, {})
# Extract output text
result_content = block.get("content", "")
if isinstance(result_content, list):
result_content = " ".join(
b.get("text", "")
for b in result_content
if isinstance(b, dict) and b.get("type") == "text"
)
results.append(
{
"tool_name": tool_use.get("name", "unknown"),
"input": tool_use.get("input", {}),
"output": str(result_content),
"is_error": block.get("is_error", False) or _is_error(str(result_content)),
}
)
return results
# =============================================================================
# Module helpers: project routing, memory.db loading, recommendation build
# =============================================================================
# Category → file routing. Stable project facts go to CLAUDE.md; evolving
# preferences and error recovery tips go to MEMORY.md (which the user's
# auto-memory system already owns).
_CATEGORY_TO_TARGET: dict[PatternCategory, str] = {
PatternCategory.ENVIRONMENT: "context_file",
PatternCategory.ARCHITECTURE: "context_file",
PatternCategory.PREFERENCE: "memory_file",
PatternCategory.ERROR_RECOVERY: "memory_file",
}
_CATEGORY_SECTION_TITLE: dict[PatternCategory, str] = {
PatternCategory.ENVIRONMENT: "Learned: environment",
PatternCategory.ARCHITECTURE: "Learned: architecture",
PatternCategory.PREFERENCE: "Learned: preference",
PatternCategory.ERROR_RECOVERY: "Learned: error recovery",
}
def _project_for_pattern(pattern: ExtractedPattern, roots: list[ProjectInfo]) -> ProjectInfo | None:
"""Return the project whose root most specifically matches this pattern.
We look for absolute paths in the pattern's content and entity_refs, then
pick the longest project root that prefixes any of those paths. Returns
None if the pattern mentions no paths under a known project.
"""
if not roots:
return None
# Collect candidate absolute paths from content and entity_refs
candidates: list[str] = []
for match in _ABS_PATH_RE.findall(pattern.content or ""):
candidates.append(match)
for ref in pattern.entity_refs or []:
if ref and (ref.startswith("/") or (len(ref) > 2 and ref[1] == ":")):
candidates.append(ref)
if not candidates:
return None
# Longest root first — most specific wins
roots_sorted = sorted(roots, key=lambda p: len(str(p.project_path)), reverse=True)
for cand in candidates:
for root in roots_sorted:
root_str = str(root.project_path).rstrip("/\\")
if not root_str:
continue
if (
cand == root_str
or cand.startswith(root_str + "/")
or cand.startswith(root_str + "\\")
):
return root
return None
def _resolve_backend_db_path(backend: Any) -> Path | None:
"""Best-effort lookup of the SQLite path used by the memory backend.
Returns None if the backend is not a LocalBackend or its config is not
accessible (e.g. mem0 remote backend).
"""
if backend is None:
return None
cfg = getattr(backend, "_config", None)
db_path = getattr(cfg, "db_path", None) if cfg is not None else None
if not db_path:
return None
return Path(db_path)
def _load_persisted_patterns_from_sqlite(db_path: Path) -> list[ExtractedPattern]:
"""Read traffic_learner rows from memory.db, dedupe, return patterns.
Uses a direct read-only SQLite connection — we don't go through the
backend's vector search because we want all rows, not semantically
similar ones, and the backend doesn't expose a "list by source" query.
"""
uri = f"file:{db_path}?mode=ro"
patterns: dict[str, ExtractedPattern] = {}
try:
conn = sqlite3.connect(uri, uri=True)
except sqlite3.OperationalError:
return []
try:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT content, metadata, entity_refs, importance, created_at "
"FROM memories "
"WHERE json_extract(metadata, '$.source') = 'traffic_learner'"
).fetchall()
except sqlite3.DatabaseError:
conn.close()
return []
finally:
try:
conn.close()
except Exception:
pass
for row in rows:
content = row["content"] or ""
if not content:
continue
try:
meta = json.loads(row["metadata"] or "{}")
except json.JSONDecodeError:
meta = {}
try:
entity_refs = json.loads(row["entity_refs"] or "[]") or []
except json.JSONDecodeError:
entity_refs = []
cat_str = meta.get("category", "")
try:
category = PatternCategory(cat_str)
except ValueError:
continue # Skip rows whose category we don't recognize
evidence = int(meta.get("evidence_count", 1) or 1)
try:
importance = float(row["importance"]) if row["importance"] is not None else 0.5
except (TypeError, ValueError):
importance = 0.5
first_seen = _parse_iso_timestamp(meta.get("first_seen_at")) or _parse_iso_timestamp(
row["created_at"]
)
last_seen = _parse_iso_timestamp(meta.get("last_seen_at")) or first_seen
key = _normalize_hash_key(category, content, meta)
h = hashlib.sha256(key.encode()).hexdigest()[:16]
if h in patterns:
existing = patterns[h]
existing.evidence_count += evidence
if importance > existing.importance:
existing.importance = importance
if last_seen and (existing.last_seen_at is None or last_seen > existing.last_seen_at):
existing.last_seen_at = last_seen
if first_seen and (
existing.first_seen_at is None or first_seen < existing.first_seen_at
):
existing.first_seen_at = first_seen
else:
patterns[h] = ExtractedPattern(
category=category,
content=content,
importance=importance,
evidence_count=evidence,
entity_refs=list(entity_refs),
metadata=meta,
content_hash=h,
first_seen_at=first_seen,
last_seen_at=last_seen,
)
return list(patterns.values())
def _parse_iso_timestamp(value: Any) -> datetime | None:
"""Parse an ISO-8601 timestamp stored as TEXT. Returns None on any failure."""
if not value or not isinstance(value, str):
return None
try:
parsed = datetime.fromisoformat(value)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
def _patterns_to_recommendations(patterns: list[ExtractedPattern]) -> list:
"""Group patterns by category into one Recommendation per category.
Returns a list of Recommendation objects ready for ContextWriter.write.
"""
from headroom.learn.models import Recommendation, RecommendationTarget
by_category: dict[PatternCategory, list[ExtractedPattern]] = {}
for p in patterns:
by_category.setdefault(p.category, []).append(p)
recs: list[Recommendation] = []
for category, items in by_category.items():
target_str = _CATEGORY_TO_TARGET.get(category)
if target_str is None:
continue
target = (
RecommendationTarget.CONTEXT_FILE
if target_str == "context_file"
else RecommendationTarget.MEMORY_FILE
)
if category is PatternCategory.ERROR_RECOVERY:
items = _refine_error_recovery(items)
else:
# Sort by evidence_count desc so the most-supported rules appear first.
items.sort(key=lambda p: p.evidence_count, reverse=True)
if not items:
continue
bullets = "\n".join(f"- {p.content}" for p in items)
recs.append(
Recommendation(
target=target,
section=_CATEGORY_SECTION_TITLE.get(category, f"Learned: {category.value}"),
content=bullets,
confidence=max((p.importance for p in items), default=0.5),
evidence_count=sum(p.evidence_count for p in items),
)
)
return recs
def _refine_error_recovery(patterns: list[ExtractedPattern]) -> list[ExtractedPattern]:
"""Apply the render-time pipeline for error_recovery patterns.
Pipeline: hard-floor drop by last_seen_at, re-validate Read success
paths against the filesystem, collapse ambiguous error_paths into a
single "search first" hint, rank by recency-weighted evidence, and
cap the section at _ERROR_RECOVERY_SECTION_CAP bullets.
"""
now = datetime.now(timezone.utc)
# 1. Hard floor — drop rows not re-observed in the last N days.
alive: list[ExtractedPattern] = []
for p in patterns:
last_seen = p.last_seen_at or p.first_seen_at
if last_seen is None:
# No timestamp — treat as just-seen so it survives one render.
alive.append(p)
continue
age_days = (now - last_seen).total_seconds() / 86400.0
if age_days <= _ERROR_RECOVERY_HARD_FLOOR_DAYS:
alive.append(p)
# 2. Re-validate Read recoveries — drop if success_path no longer exists.
validated: list[ExtractedPattern] = []
for p in alive:
if p.metadata.get("tool") == "Read":
success_path = p.metadata.get("success_path")
if success_path:
try:
if not Path(success_path).exists():
continue
except OSError:
# Path check failed (permissions, etc.) — keep the row
# rather than drop on a transient error.
pass
validated.append(p)
# 3. Collision-collapse — same error_path with >=2 distinct success_paths
# is an ambiguity signal, not N separate lessons. Replace the group
# with one synthesized "search first" bullet.
read_groups: dict[str, list[ExtractedPattern]] = {}
others: list[ExtractedPattern] = []
for p in validated:
if p.metadata.get("tool") == "Read" and p.metadata.get("error_path"):
read_groups.setdefault(p.metadata["error_path"], []).append(p)
else:
others.append(p)
collapsed: list[ExtractedPattern] = list(others)
for error_path, group in read_groups.items():
distinct_targets = {g.metadata.get("success_path") for g in group}
distinct_targets.discard(None)
if len(group) >= 2 and len(distinct_targets) >= 2:
basename = os.path.basename(error_path) or error_path
synth_content = (
f"Path `{basename}` has been guessed wrong repeatedly — "
f"use Glob/Grep to locate before reading."
)
max_last_seen = max(
(g.last_seen_at for g in group if g.last_seen_at),
default=now,
)
collapsed.append(
ExtractedPattern(
category=PatternCategory.ERROR_RECOVERY,
content=synth_content,
importance=max(g.importance for g in group),
evidence_count=sum(g.evidence_count for g in group),
metadata={
"tool": "Read",
"error_path": error_path,
"collapsed": True,
},
last_seen_at=max_last_seen,
first_seen_at=min(
(g.first_seen_at for g in group if g.first_seen_at),
default=max_last_seen,
),
)
)
else:
collapsed.extend(group)
# 4. Recency-weighted score.
def _score(p: ExtractedPattern) -> float:
last_seen = p.last_seen_at or p.first_seen_at or now
age_days = max(0.0, (now - last_seen).total_seconds() / 86400.0)
decay = float(0.5 ** (age_days / _ERROR_RECOVERY_HALF_LIFE_DAYS))
return float(p.evidence_count) * decay
collapsed.sort(key=_score, reverse=True)
# 5. Cap the section.
return collapsed[:_ERROR_RECOVERY_SECTION_CAP]