chopratejas commited on
Commit
18d80ca
·
2 Parent(s): df6661042196be

Merge pull request #262 from gglucass/fix/traffic-learner-error-recovery

Browse files
CHANGELOG.md CHANGED
@@ -8,6 +8,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8
  ## [Unreleased]
9
 
10
  ### Fixed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  - **`headroom unwrap codex` now actually undoes `headroom wrap codex`** —
12
  previously there was no `unwrap codex` subcommand at all, so the injected
13
  `model_provider = "headroom"` / `[model_providers.headroom]` block stayed
 
8
  ## [Unreleased]
9
 
10
  ### Fixed
11
+ - **`Learned: error recovery` section in MEMORY.md no longer bloats with
12
+ stale or contradictory entries.** The dedup key for error-recovery
13
+ patterns was the literal rendered bullet text, so near-duplicate
14
+ recoveries (same intent, different `| tail -N` count, same error path
15
+ guessed against different successors) each created a new row. There was
16
+ also no TTL or re-validation, so wrong-today entries lingered. Fixed by:
17
+ (1) normalizing the hash on recovery intent — Read recoveries key on
18
+ `(basename(error_path), basename(success_path))`; Bash recoveries strip
19
+ volatile suffixes and hash only the primary command before the first
20
+ `|`/`&&`; (2) stamping `first_seen_at` / `last_seen_at` on every pattern
21
+ and bumping them in `_bump_persisted_evidence` via `json_set`; (3)
22
+ refining at render time — drop rows not re-observed in 21 days,
23
+ re-validate Read success paths against the filesystem, collapse
24
+ same-error_path-with-multiple-targets into one "use Glob/Grep first"
25
+ bullet, rank by `evidence_count * 0.5 ** (days/5)`, cap the section at
26
+ 15. Other `Learned: …` categories (environment, preference,
27
+ architecture) are untouched.
28
  - **`headroom unwrap codex` now actually undoes `headroom wrap codex`** —
29
  previously there was no `unwrap codex` subcommand at all, so the injected
30
  `model_provider = "headroom"` / `[model_providers.headroom]` block stayed
headroom/memory/traffic_learner.py CHANGED
@@ -22,10 +22,12 @@ import asyncio
22
  import hashlib
23
  import json
24
  import logging
 
25
  import re
26
  import sqlite3
27
  import time
28
  from dataclasses import dataclass, field
 
29
  from enum import Enum
30
  from pathlib import Path
31
  from typing import TYPE_CHECKING, Any
@@ -45,6 +47,20 @@ FLUSH_DEBOUNCE_SECONDS = 10.0
45
  # Matches POSIX paths (starts with /) and common Windows drive paths.
46
  _ABS_PATH_RE = re.compile(r"(?:[A-Za-z]:[\\/]|/)[\w./\\\-]+")
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
  # =============================================================================
50
  # Pattern Categories
@@ -87,10 +103,60 @@ class ExtractedPattern:
87
  entity_refs: list[str] = field(default_factory=list)
88
  metadata: dict[str, Any] = field(default_factory=dict)
89
  content_hash: str = ""
 
 
90
 
91
  def __post_init__(self) -> None:
92
  if not self.content_hash:
93
- self.content_hash = hashlib.sha256(self.content.encode()).hexdigest()[:16]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
 
95
 
96
  # =============================================================================
@@ -389,6 +455,7 @@ class TrafficLearner:
389
  Evidence counts are summed across duplicates.
390
  """
391
  by_hash: dict[str, ExtractedPattern] = {}
 
392
 
393
  # Persisted rows from memory.db
394
  db_path = _resolve_backend_db_path(self._backend)
@@ -404,11 +471,15 @@ class TrafficLearner:
404
  else:
405
  by_hash[p.content_hash] = p
406
 
407
- # In-memory accumulator (patterns not yet persisted)
 
 
408
  for pattern, count in self._pattern_counts.values():
409
  h = pattern.content_hash
410
  if h in by_hash:
411
- by_hash[h].evidence_count += count
 
 
412
  else:
413
  by_hash[h] = ExtractedPattern(
414
  category=pattern.category,
@@ -418,6 +489,8 @@ class TrafficLearner:
418
  entity_refs=list(pattern.entity_refs),
419
  metadata=dict(pattern.metadata),
420
  content_hash=pattern.content_hash,
 
 
421
  )
422
 
423
  return list(by_hash.values())
@@ -578,7 +651,12 @@ class TrafficLearner:
578
  content=content,
579
  importance=0.7,
580
  entity_refs=[success_path],
581
- metadata={"error_category": error_cat},
 
 
 
 
 
582
  )
583
  elif tool in ("Grep", "Glob"):
584
  error_pattern = error_entry["input"].get("pattern", "")
@@ -635,7 +713,12 @@ class TrafficLearner:
635
  content=content,
636
  importance=importance,
637
  entity_refs=entities,
638
- metadata={"error_category": error_cat, "failed_cmd": failed_short},
 
 
 
 
 
639
  )
640
 
641
  def _extract_environment(self, entry: dict[str, Any]) -> list[ExtractedPattern]:
@@ -762,6 +845,7 @@ class TrafficLearner:
762
  if self._backend is None:
763
  continue
764
 
 
765
  memory = await self._backend.save_memory(
766
  content=pattern.content,
767
  user_id=self._user_id,
@@ -770,6 +854,8 @@ class TrafficLearner:
770
  "source": "traffic_learner",
771
  "category": pattern.category.value,
772
  "evidence_count": pattern.evidence_count,
 
 
773
  **pattern.metadata,
774
  },
775
  )
@@ -796,7 +882,7 @@ class TrafficLearner:
796
  if db_path is None or not db_path.exists():
797
  return
798
 
799
- def _read() -> list[tuple[str, str]]:
800
  uri = f"file:{db_path}?mode=ro"
801
  try:
802
  conn = sqlite3.connect(uri, uri=True)
@@ -804,7 +890,7 @@ class TrafficLearner:
804
  return []
805
  try:
806
  rows = conn.execute(
807
- "SELECT id, content FROM memories "
808
  "WHERE json_extract(metadata, '$.source') = 'traffic_learner'"
809
  ).fetchall()
810
  except sqlite3.DatabaseError:
@@ -814,7 +900,7 @@ class TrafficLearner:
814
  conn.close()
815
  except Exception:
816
  pass
817
- return [(row[0], row[1] or "") for row in rows]
818
 
819
  try:
820
  rows = await asyncio.to_thread(_read)
@@ -822,10 +908,24 @@ class TrafficLearner:
822
  logger.debug("Traffic learner hydrate failed: %s", e)
823
  return
824
 
825
- for memory_id, content in rows:
826
  if not content:
827
  continue
828
- h = hashlib.sha256(content.encode()).hexdigest()[:16]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
829
  self._saved_hashes.add(h)
830
  # If multiple rows share the same content (legacy duplicates),
831
  # last-wins — we only need one id to target the bump.
@@ -837,15 +937,18 @@ class TrafficLearner:
837
  if db_path is None or not db_path.exists():
838
  return
839
 
 
 
840
  def _bump() -> None:
841
  conn = sqlite3.connect(str(db_path))
842
  try:
843
  conn.execute(
844
  "UPDATE memories SET metadata = json_set("
845
  "metadata, '$.evidence_count', "
846
- "COALESCE(json_extract(metadata, '$.evidence_count'), 0) + 1"
 
847
  ") WHERE id = ?",
848
- (memory_id,),
849
  )
850
  conn.commit()
851
  finally:
@@ -1007,7 +1110,7 @@ def _load_persisted_patterns_from_sqlite(db_path: Path) -> list[ExtractedPattern
1007
  try:
1008
  conn.row_factory = sqlite3.Row
1009
  rows = conn.execute(
1010
- "SELECT content, metadata, entity_refs, importance "
1011
  "FROM memories "
1012
  "WHERE json_extract(metadata, '$.source') = 'traffic_learner'"
1013
  ).fetchall()
@@ -1045,12 +1148,24 @@ def _load_persisted_patterns_from_sqlite(db_path: Path) -> list[ExtractedPattern
1045
  except (TypeError, ValueError):
1046
  importance = 0.5
1047
 
1048
- h = hashlib.sha256(content.encode()).hexdigest()[:16]
 
 
 
 
 
 
1049
  if h in patterns:
1050
  existing = patterns[h]
1051
  existing.evidence_count += evidence
1052
  if importance > existing.importance:
1053
  existing.importance = importance
 
 
 
 
 
 
1054
  else:
1055
  patterns[h] = ExtractedPattern(
1056
  category=category,
@@ -1060,11 +1175,26 @@ def _load_persisted_patterns_from_sqlite(db_path: Path) -> list[ExtractedPattern
1060
  entity_refs=list(entity_refs),
1061
  metadata=meta,
1062
  content_hash=h,
 
 
1063
  )
1064
 
1065
  return list(patterns.values())
1066
 
1067
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1068
  def _patterns_to_recommendations(patterns: list[ExtractedPattern]) -> list:
1069
  """Group patterns by category into one Recommendation per category.
1070
 
@@ -1086,8 +1216,13 @@ def _patterns_to_recommendations(patterns: list[ExtractedPattern]) -> list:
1086
  if target_str == "context_file"
1087
  else RecommendationTarget.MEMORY_FILE
1088
  )
1089
- # Sort by evidence_count desc so the most-supported rules appear first.
1090
- items.sort(key=lambda p: p.evidence_count, reverse=True)
 
 
 
 
 
1091
  bullets = "\n".join(f"- {p.content}" for p in items)
1092
  recs.append(
1093
  Recommendation(
@@ -1099,3 +1234,99 @@ def _patterns_to_recommendations(patterns: list[ExtractedPattern]) -> list:
1099
  )
1100
  )
1101
  return recs
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  import hashlib
23
  import json
24
  import logging
25
+ import os
26
  import re
27
  import sqlite3
28
  import time
29
  from dataclasses import dataclass, field
30
+ from datetime import datetime, timezone
31
  from enum import Enum
32
  from pathlib import Path
33
  from typing import TYPE_CHECKING, Any
 
47
  # Matches POSIX paths (starts with /) and common Windows drive paths.
48
  _ABS_PATH_RE = re.compile(r"(?:[A-Za-z]:[\\/]|/)[\w./\\\-]+")
49
 
50
+ # Error-recovery refinement: the Learned: error recovery section is capped,
51
+ # decayed, and re-validated at render time. Other categories are untouched.
52
+ _ERROR_RECOVERY_SECTION_CAP = 15
53
+ _ERROR_RECOVERY_HALF_LIFE_DAYS = 5.0
54
+ _ERROR_RECOVERY_HARD_FLOOR_DAYS = 21
55
+
56
+ # Suffixes that vary between otherwise-identical Bash recoveries. Stripping
57
+ # them before hashing collapses near-duplicates.
58
+ _BASH_VOLATILE_SUFFIX_RE = re.compile(
59
+ r"(?:\s*\|\s*(?:head|tail)\s+-n?\s*\d+"
60
+ r"|\s+-A\s*\d+|\s+-B\s*\d+|\s+-C\s*\d+"
61
+ r"|\s+2>&1|\s+2>/dev/null)+\s*$"
62
+ )
63
+
64
 
65
  # =============================================================================
66
  # Pattern Categories
 
103
  entity_refs: list[str] = field(default_factory=list)
104
  metadata: dict[str, Any] = field(default_factory=dict)
105
  content_hash: str = ""
106
+ first_seen_at: datetime | None = None
107
+ last_seen_at: datetime | None = None
108
 
109
  def __post_init__(self) -> None:
110
  if not self.content_hash:
111
+ key = _normalize_hash_key(self.category, self.content, self.metadata)
112
+ self.content_hash = hashlib.sha256(key.encode()).hexdigest()[:16]
113
+
114
+
115
+ def _normalize_hash_key(
116
+ category: PatternCategory,
117
+ content: str,
118
+ metadata: dict[str, Any],
119
+ ) -> str:
120
+ """Build the string that feeds the content hash.
121
+
122
+ Error-recovery rows are collapsed on recovery intent, not literal text:
123
+ trivial invocation differences (tail counts, pipe suffixes, full paths
124
+ that share a basename) hash to the same key. Other categories hash the
125
+ raw content for backwards compatibility.
126
+ """
127
+ if category is not PatternCategory.ERROR_RECOVERY:
128
+ return content
129
+
130
+ tool = metadata.get("tool")
131
+ if tool == "Read":
132
+ error_path = metadata.get("error_path", "")
133
+ success_path = metadata.get("success_path", "")
134
+ return (
135
+ f"error_recovery|Read|{os.path.basename(error_path)}|{os.path.basename(success_path)}"
136
+ )
137
+ if tool == "Bash":
138
+ failed = metadata.get("failed_cmd", "")
139
+ success = metadata.get("success_cmd", "")
140
+ return (
141
+ f"error_recovery|Bash|"
142
+ f"{_normalize_bash_for_hash(failed)}|{_normalize_bash_for_hash(success)}"
143
+ )
144
+ return content
145
+
146
+
147
+ def _normalize_bash_for_hash(cmd: str) -> str:
148
+ """Strip volatile suffixes and truncate at the first pipe/chain boundary."""
149
+ if not cmd:
150
+ return ""
151
+ # Drop paging, line-context flags, and redirections that vary between runs.
152
+ trimmed = _BASH_VOLATILE_SUFFIX_RE.sub("", cmd).strip()
153
+ # Cut at the first pipe or && so we hash the primary command, not the tail.
154
+ for sep in (" | ", " && "):
155
+ idx = trimmed.find(sep)
156
+ if idx != -1:
157
+ trimmed = trimmed[:idx].rstrip()
158
+ break
159
+ return trimmed
160
 
161
 
162
  # =============================================================================
 
455
  Evidence counts are summed across duplicates.
456
  """
457
  by_hash: dict[str, ExtractedPattern] = {}
458
+ now = datetime.now(timezone.utc)
459
 
460
  # Persisted rows from memory.db
461
  db_path = _resolve_backend_db_path(self._backend)
 
471
  else:
472
  by_hash[p.content_hash] = p
473
 
474
+ # In-memory accumulator (patterns not yet persisted). Re-sightings in
475
+ # this session bump last_seen_at to "now" on top of the persisted
476
+ # timestamp so recency ranking reflects live activity.
477
  for pattern, count in self._pattern_counts.values():
478
  h = pattern.content_hash
479
  if h in by_hash:
480
+ existing = by_hash[h]
481
+ existing.evidence_count += count
482
+ existing.last_seen_at = now
483
  else:
484
  by_hash[h] = ExtractedPattern(
485
  category=pattern.category,
 
489
  entity_refs=list(pattern.entity_refs),
490
  metadata=dict(pattern.metadata),
491
  content_hash=pattern.content_hash,
492
+ first_seen_at=now,
493
+ last_seen_at=now,
494
  )
495
 
496
  return list(by_hash.values())
 
651
  content=content,
652
  importance=0.7,
653
  entity_refs=[success_path],
654
+ metadata={
655
+ "error_category": error_cat,
656
+ "tool": "Read",
657
+ "error_path": error_path,
658
+ "success_path": success_path,
659
+ },
660
  )
661
  elif tool in ("Grep", "Glob"):
662
  error_pattern = error_entry["input"].get("pattern", "")
 
713
  content=content,
714
  importance=importance,
715
  entity_refs=entities,
716
+ metadata={
717
+ "error_category": error_cat,
718
+ "tool": "Bash",
719
+ "failed_cmd": failed_short,
720
+ "success_cmd": success_short,
721
+ },
722
  )
723
 
724
  def _extract_environment(self, entry: dict[str, Any]) -> list[ExtractedPattern]:
 
845
  if self._backend is None:
846
  continue
847
 
848
+ now_iso = datetime.now(timezone.utc).isoformat()
849
  memory = await self._backend.save_memory(
850
  content=pattern.content,
851
  user_id=self._user_id,
 
854
  "source": "traffic_learner",
855
  "category": pattern.category.value,
856
  "evidence_count": pattern.evidence_count,
857
+ "first_seen_at": now_iso,
858
+ "last_seen_at": now_iso,
859
  **pattern.metadata,
860
  },
861
  )
 
882
  if db_path is None or not db_path.exists():
883
  return
884
 
885
+ def _read() -> list[tuple[str, str, str]]:
886
  uri = f"file:{db_path}?mode=ro"
887
  try:
888
  conn = sqlite3.connect(uri, uri=True)
 
890
  return []
891
  try:
892
  rows = conn.execute(
893
+ "SELECT id, content, metadata FROM memories "
894
  "WHERE json_extract(metadata, '$.source') = 'traffic_learner'"
895
  ).fetchall()
896
  except sqlite3.DatabaseError:
 
900
  conn.close()
901
  except Exception:
902
  pass
903
+ return [(row[0], row[1] or "", row[2] or "{}") for row in rows]
904
 
905
  try:
906
  rows = await asyncio.to_thread(_read)
 
908
  logger.debug("Traffic learner hydrate failed: %s", e)
909
  return
910
 
911
+ for memory_id, content, metadata_json in rows:
912
  if not content:
913
  continue
914
+ try:
915
+ metadata = json.loads(metadata_json) if metadata_json else {}
916
+ except json.JSONDecodeError:
917
+ metadata = {}
918
+ category_value = metadata.get("category")
919
+ try:
920
+ category = PatternCategory(category_value) if category_value else None
921
+ except ValueError:
922
+ category = None
923
+ if category is None:
924
+ # Legacy row without category — fall back to literal hash.
925
+ key = content
926
+ else:
927
+ key = _normalize_hash_key(category, content, metadata)
928
+ h = hashlib.sha256(key.encode()).hexdigest()[:16]
929
  self._saved_hashes.add(h)
930
  # If multiple rows share the same content (legacy duplicates),
931
  # last-wins — we only need one id to target the bump.
 
937
  if db_path is None or not db_path.exists():
938
  return
939
 
940
+ now_iso = datetime.now(timezone.utc).isoformat()
941
+
942
  def _bump() -> None:
943
  conn = sqlite3.connect(str(db_path))
944
  try:
945
  conn.execute(
946
  "UPDATE memories SET metadata = json_set("
947
  "metadata, '$.evidence_count', "
948
+ "COALESCE(json_extract(metadata, '$.evidence_count'), 0) + 1, "
949
+ "'$.last_seen_at', ?"
950
  ") WHERE id = ?",
951
+ (now_iso, memory_id),
952
  )
953
  conn.commit()
954
  finally:
 
1110
  try:
1111
  conn.row_factory = sqlite3.Row
1112
  rows = conn.execute(
1113
+ "SELECT content, metadata, entity_refs, importance, created_at "
1114
  "FROM memories "
1115
  "WHERE json_extract(metadata, '$.source') = 'traffic_learner'"
1116
  ).fetchall()
 
1148
  except (TypeError, ValueError):
1149
  importance = 0.5
1150
 
1151
+ first_seen = _parse_iso_timestamp(meta.get("first_seen_at")) or _parse_iso_timestamp(
1152
+ row["created_at"]
1153
+ )
1154
+ last_seen = _parse_iso_timestamp(meta.get("last_seen_at")) or first_seen
1155
+
1156
+ key = _normalize_hash_key(category, content, meta)
1157
+ h = hashlib.sha256(key.encode()).hexdigest()[:16]
1158
  if h in patterns:
1159
  existing = patterns[h]
1160
  existing.evidence_count += evidence
1161
  if importance > existing.importance:
1162
  existing.importance = importance
1163
+ if last_seen and (existing.last_seen_at is None or last_seen > existing.last_seen_at):
1164
+ existing.last_seen_at = last_seen
1165
+ if first_seen and (
1166
+ existing.first_seen_at is None or first_seen < existing.first_seen_at
1167
+ ):
1168
+ existing.first_seen_at = first_seen
1169
  else:
1170
  patterns[h] = ExtractedPattern(
1171
  category=category,
 
1175
  entity_refs=list(entity_refs),
1176
  metadata=meta,
1177
  content_hash=h,
1178
+ first_seen_at=first_seen,
1179
+ last_seen_at=last_seen,
1180
  )
1181
 
1182
  return list(patterns.values())
1183
 
1184
 
1185
+ def _parse_iso_timestamp(value: Any) -> datetime | None:
1186
+ """Parse an ISO-8601 timestamp stored as TEXT. Returns None on any failure."""
1187
+ if not value or not isinstance(value, str):
1188
+ return None
1189
+ try:
1190
+ parsed = datetime.fromisoformat(value)
1191
+ except ValueError:
1192
+ return None
1193
+ if parsed.tzinfo is None:
1194
+ parsed = parsed.replace(tzinfo=timezone.utc)
1195
+ return parsed
1196
+
1197
+
1198
  def _patterns_to_recommendations(patterns: list[ExtractedPattern]) -> list:
1199
  """Group patterns by category into one Recommendation per category.
1200
 
 
1216
  if target_str == "context_file"
1217
  else RecommendationTarget.MEMORY_FILE
1218
  )
1219
+ if category is PatternCategory.ERROR_RECOVERY:
1220
+ items = _refine_error_recovery(items)
1221
+ else:
1222
+ # Sort by evidence_count desc so the most-supported rules appear first.
1223
+ items.sort(key=lambda p: p.evidence_count, reverse=True)
1224
+ if not items:
1225
+ continue
1226
  bullets = "\n".join(f"- {p.content}" for p in items)
1227
  recs.append(
1228
  Recommendation(
 
1234
  )
1235
  )
1236
  return recs
1237
+
1238
+
1239
+ def _refine_error_recovery(patterns: list[ExtractedPattern]) -> list[ExtractedPattern]:
1240
+ """Apply the render-time pipeline for error_recovery patterns.
1241
+
1242
+ Pipeline: hard-floor drop by last_seen_at, re-validate Read success
1243
+ paths against the filesystem, collapse ambiguous error_paths into a
1244
+ single "search first" hint, rank by recency-weighted evidence, and
1245
+ cap the section at _ERROR_RECOVERY_SECTION_CAP bullets.
1246
+ """
1247
+ now = datetime.now(timezone.utc)
1248
+
1249
+ # 1. Hard floor — drop rows not re-observed in the last N days.
1250
+ alive: list[ExtractedPattern] = []
1251
+ for p in patterns:
1252
+ last_seen = p.last_seen_at or p.first_seen_at
1253
+ if last_seen is None:
1254
+ # No timestamp — treat as just-seen so it survives one render.
1255
+ alive.append(p)
1256
+ continue
1257
+ age_days = (now - last_seen).total_seconds() / 86400.0
1258
+ if age_days <= _ERROR_RECOVERY_HARD_FLOOR_DAYS:
1259
+ alive.append(p)
1260
+
1261
+ # 2. Re-validate Read recoveries — drop if success_path no longer exists.
1262
+ validated: list[ExtractedPattern] = []
1263
+ for p in alive:
1264
+ if p.metadata.get("tool") == "Read":
1265
+ success_path = p.metadata.get("success_path")
1266
+ if success_path:
1267
+ try:
1268
+ if not Path(success_path).exists():
1269
+ continue
1270
+ except OSError:
1271
+ # Path check failed (permissions, etc.) — keep the row
1272
+ # rather than drop on a transient error.
1273
+ pass
1274
+ validated.append(p)
1275
+
1276
+ # 3. Collision-collapse — same error_path with >=2 distinct success_paths
1277
+ # is an ambiguity signal, not N separate lessons. Replace the group
1278
+ # with one synthesized "search first" bullet.
1279
+ read_groups: dict[str, list[ExtractedPattern]] = {}
1280
+ others: list[ExtractedPattern] = []
1281
+ for p in validated:
1282
+ if p.metadata.get("tool") == "Read" and p.metadata.get("error_path"):
1283
+ read_groups.setdefault(p.metadata["error_path"], []).append(p)
1284
+ else:
1285
+ others.append(p)
1286
+
1287
+ collapsed: list[ExtractedPattern] = list(others)
1288
+ for error_path, group in read_groups.items():
1289
+ distinct_targets = {g.metadata.get("success_path") for g in group}
1290
+ distinct_targets.discard(None)
1291
+ if len(group) >= 2 and len(distinct_targets) >= 2:
1292
+ basename = os.path.basename(error_path) or error_path
1293
+ synth_content = (
1294
+ f"Path `{basename}` has been guessed wrong repeatedly — "
1295
+ f"use Glob/Grep to locate before reading."
1296
+ )
1297
+ max_last_seen = max(
1298
+ (g.last_seen_at for g in group if g.last_seen_at),
1299
+ default=now,
1300
+ )
1301
+ collapsed.append(
1302
+ ExtractedPattern(
1303
+ category=PatternCategory.ERROR_RECOVERY,
1304
+ content=synth_content,
1305
+ importance=max(g.importance for g in group),
1306
+ evidence_count=sum(g.evidence_count for g in group),
1307
+ metadata={
1308
+ "tool": "Read",
1309
+ "error_path": error_path,
1310
+ "collapsed": True,
1311
+ },
1312
+ last_seen_at=max_last_seen,
1313
+ first_seen_at=min(
1314
+ (g.first_seen_at for g in group if g.first_seen_at),
1315
+ default=max_last_seen,
1316
+ ),
1317
+ )
1318
+ )
1319
+ else:
1320
+ collapsed.extend(group)
1321
+
1322
+ # 4. Recency-weighted score.
1323
+ def _score(p: ExtractedPattern) -> float:
1324
+ last_seen = p.last_seen_at or p.first_seen_at or now
1325
+ age_days = max(0.0, (now - last_seen).total_seconds() / 86400.0)
1326
+ decay = float(0.5 ** (age_days / _ERROR_RECOVERY_HALF_LIFE_DAYS))
1327
+ return float(p.evidence_count) * decay
1328
+
1329
+ collapsed.sort(key=_score, reverse=True)
1330
+
1331
+ # 5. Cap the section.
1332
+ return collapsed[:_ERROR_RECOVERY_SECTION_CAP]
tests/test_memory/test_traffic_learner.py CHANGED
@@ -6,6 +6,8 @@ a real memory backend.
6
 
7
  from __future__ import annotations
8
 
 
 
9
  import pytest
10
 
11
  from headroom.memory.traffic_learner import (
@@ -15,10 +17,15 @@ from headroom.memory.traffic_learner import (
15
  _classify_error,
16
  _is_error,
17
  _load_persisted_patterns_from_sqlite,
 
 
18
  _patterns_to_recommendations,
19
  _project_for_pattern,
 
20
  )
21
 
 
 
22
  # =============================================================================
23
  # Error Classification Tests
24
  # =============================================================================
@@ -361,18 +368,21 @@ class TestLoadPersistedPatterns:
361
  "id TEXT PRIMARY KEY, content TEXT NOT NULL, "
362
  "metadata TEXT NOT NULL DEFAULT '{}', "
363
  "entity_refs TEXT NOT NULL DEFAULT '[]', "
364
- "importance REAL NOT NULL DEFAULT 0.5)"
 
365
  )
366
  for i, r in enumerate(rows):
367
  conn.execute(
368
- "INSERT INTO memories (id, content, metadata, entity_refs, importance) "
369
- "VALUES (?,?,?,?,?)",
 
370
  (
371
  str(i),
372
  r["content"],
373
  _json.dumps(r.get("metadata", {})),
374
  _json.dumps(r.get("entity_refs", [])),
375
  r.get("importance", 0.5),
 
376
  ),
377
  )
378
  conn.commit()
@@ -612,7 +622,8 @@ def _init_db(path):
612
  "id TEXT PRIMARY KEY, content TEXT NOT NULL, "
613
  "metadata TEXT NOT NULL DEFAULT '{}', "
614
  "entity_refs TEXT NOT NULL DEFAULT '[]', "
615
- "importance REAL NOT NULL DEFAULT 0.5)"
 
616
  )
617
  conn.commit()
618
  conn.close()
@@ -1076,3 +1087,760 @@ class TestStopCancels:
1076
  assert learner._flush_task is not None and not learner._flush_task.done()
1077
  await learner.stop()
1078
  assert learner._flush_task is None or learner._flush_task.done()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
  from __future__ import annotations
8
 
9
+ from datetime import datetime, timedelta, timezone
10
+
11
  import pytest
12
 
13
  from headroom.memory.traffic_learner import (
 
17
  _classify_error,
18
  _is_error,
19
  _load_persisted_patterns_from_sqlite,
20
+ _normalize_bash_for_hash,
21
+ _parse_iso_timestamp,
22
  _patterns_to_recommendations,
23
  _project_for_pattern,
24
+ _refine_error_recovery,
25
  )
26
 
27
+ UTC = timezone.utc
28
+
29
  # =============================================================================
30
  # Error Classification Tests
31
  # =============================================================================
 
368
  "id TEXT PRIMARY KEY, content TEXT NOT NULL, "
369
  "metadata TEXT NOT NULL DEFAULT '{}', "
370
  "entity_refs TEXT NOT NULL DEFAULT '[]', "
371
+ "importance REAL NOT NULL DEFAULT 0.5, "
372
+ "created_at TEXT)"
373
  )
374
  for i, r in enumerate(rows):
375
  conn.execute(
376
+ "INSERT INTO memories "
377
+ "(id, content, metadata, entity_refs, importance, created_at) "
378
+ "VALUES (?,?,?,?,?,?)",
379
  (
380
  str(i),
381
  r["content"],
382
  _json.dumps(r.get("metadata", {})),
383
  _json.dumps(r.get("entity_refs", [])),
384
  r.get("importance", 0.5),
385
+ r.get("created_at"),
386
  ),
387
  )
388
  conn.commit()
 
622
  "id TEXT PRIMARY KEY, content TEXT NOT NULL, "
623
  "metadata TEXT NOT NULL DEFAULT '{}', "
624
  "entity_refs TEXT NOT NULL DEFAULT '[]', "
625
+ "importance REAL NOT NULL DEFAULT 0.5, "
626
+ "created_at TEXT)"
627
  )
628
  conn.commit()
629
  conn.close()
 
1087
  assert learner._flush_task is not None and not learner._flush_task.done()
1088
  await learner.stop()
1089
  assert learner._flush_task is None or learner._flush_task.done()
1090
+
1091
+
1092
+ class TestNormalizedHash:
1093
+ """Error-recovery patterns hash on recovery intent, not literal text."""
1094
+
1095
+ def _mk(self, **meta) -> ExtractedPattern:
1096
+ return ExtractedPattern(
1097
+ category=PatternCategory.ERROR_RECOVERY,
1098
+ content=f"content-{meta.get('tool', 'none')}-{len(meta)}",
1099
+ importance=0.7,
1100
+ metadata=meta,
1101
+ )
1102
+
1103
+ def test_read_recovery_basename_hash(self):
1104
+ a = ExtractedPattern(
1105
+ category=PatternCategory.ERROR_RECOVERY,
1106
+ content="File `/a/state.rs` does not exist. The correct path is `/a/lib.rs`.",
1107
+ importance=0.7,
1108
+ metadata={"tool": "Read", "error_path": "/a/state.rs", "success_path": "/a/lib.rs"},
1109
+ )
1110
+ b = ExtractedPattern(
1111
+ category=PatternCategory.ERROR_RECOVERY,
1112
+ content="File `/b/state.rs` does not exist. The correct path is `/b/lib.rs`.",
1113
+ importance=0.7,
1114
+ metadata={"tool": "Read", "error_path": "/b/state.rs", "success_path": "/b/lib.rs"},
1115
+ )
1116
+ assert a.content_hash == b.content_hash
1117
+
1118
+ def test_bash_recovery_tail_count_collapse(self):
1119
+ a = ExtractedPattern(
1120
+ category=PatternCategory.ERROR_RECOVERY,
1121
+ content="Command `cargo check` fails. Use `cargo check --manifest-path src-tauri/Cargo.toml | tail -10` instead.",
1122
+ importance=0.7,
1123
+ metadata={
1124
+ "tool": "Bash",
1125
+ "failed_cmd": "cargo check",
1126
+ "success_cmd": "cargo check --manifest-path src-tauri/Cargo.toml | tail -10",
1127
+ },
1128
+ )
1129
+ b = ExtractedPattern(
1130
+ category=PatternCategory.ERROR_RECOVERY,
1131
+ content="Command `cargo check` fails. Use `cargo check --manifest-path src-tauri/Cargo.toml | tail -50` instead.",
1132
+ importance=0.7,
1133
+ metadata={
1134
+ "tool": "Bash",
1135
+ "failed_cmd": "cargo check",
1136
+ "success_cmd": "cargo check --manifest-path src-tauri/Cargo.toml | tail -50",
1137
+ },
1138
+ )
1139
+ assert a.content_hash == b.content_hash
1140
+
1141
+ def test_bash_recovery_pipe_boundary(self):
1142
+ a = ExtractedPattern(
1143
+ category=PatternCategory.ERROR_RECOVERY,
1144
+ content="x",
1145
+ importance=0.7,
1146
+ metadata={
1147
+ "tool": "Bash",
1148
+ "failed_cmd": "grep foo bar.txt",
1149
+ "success_cmd": "grep -n foo bar.txt | head -5",
1150
+ },
1151
+ )
1152
+ b = ExtractedPattern(
1153
+ category=PatternCategory.ERROR_RECOVERY,
1154
+ content="y",
1155
+ importance=0.7,
1156
+ metadata={
1157
+ "tool": "Bash",
1158
+ "failed_cmd": "grep foo bar.txt",
1159
+ "success_cmd": "grep -n foo bar.txt | wc -l",
1160
+ },
1161
+ )
1162
+ assert a.content_hash == b.content_hash
1163
+
1164
+ def test_bash_recovery_different_primary_cmd_different_hash(self):
1165
+ a = ExtractedPattern(
1166
+ category=PatternCategory.ERROR_RECOVERY,
1167
+ content="x",
1168
+ importance=0.7,
1169
+ metadata={
1170
+ "tool": "Bash",
1171
+ "failed_cmd": "cargo check",
1172
+ "success_cmd": "cargo build",
1173
+ },
1174
+ )
1175
+ b = ExtractedPattern(
1176
+ category=PatternCategory.ERROR_RECOVERY,
1177
+ content="y",
1178
+ importance=0.7,
1179
+ metadata={
1180
+ "tool": "Bash",
1181
+ "failed_cmd": "cargo check",
1182
+ "success_cmd": "cargo test",
1183
+ },
1184
+ )
1185
+ assert a.content_hash != b.content_hash
1186
+
1187
+ def test_non_error_recovery_unchanged(self):
1188
+ a = ExtractedPattern(
1189
+ category=PatternCategory.ENVIRONMENT,
1190
+ content="Use /usr/bin/python3.",
1191
+ importance=0.7,
1192
+ )
1193
+ b = ExtractedPattern(
1194
+ category=PatternCategory.ENVIRONMENT,
1195
+ content="Use /opt/bin/python3.",
1196
+ importance=0.7,
1197
+ )
1198
+ assert a.content_hash != b.content_hash
1199
+
1200
+ def test_error_recovery_without_tool_falls_back_to_content(self):
1201
+ """Legacy error_recovery rows without a `tool` metadata key still work."""
1202
+ a = ExtractedPattern(
1203
+ category=PatternCategory.ERROR_RECOVERY,
1204
+ content="Some legacy bullet.",
1205
+ importance=0.7,
1206
+ )
1207
+ b = ExtractedPattern(
1208
+ category=PatternCategory.ERROR_RECOVERY,
1209
+ content="Some legacy bullet.",
1210
+ importance=0.7,
1211
+ )
1212
+ assert a.content_hash == b.content_hash
1213
+
1214
+
1215
+ class TestRefineErrorRecovery:
1216
+ """Render-time pipeline: hard floor, re-validate, collapse, rank, cap."""
1217
+
1218
+ def _mk_read(
1219
+ self,
1220
+ *,
1221
+ error_path: str,
1222
+ success_path: str,
1223
+ evidence: int = 1,
1224
+ last_seen: datetime | None = None,
1225
+ ) -> ExtractedPattern:
1226
+ now = datetime.now(UTC)
1227
+ return ExtractedPattern(
1228
+ category=PatternCategory.ERROR_RECOVERY,
1229
+ content=f"File `{error_path}` does not exist. The correct path is `{success_path}`.",
1230
+ importance=0.7,
1231
+ evidence_count=evidence,
1232
+ metadata={
1233
+ "tool": "Read",
1234
+ "error_path": error_path,
1235
+ "success_path": success_path,
1236
+ },
1237
+ last_seen_at=last_seen or now,
1238
+ first_seen_at=last_seen or now,
1239
+ )
1240
+
1241
+ def test_drops_patterns_beyond_hard_floor(self, tmp_path):
1242
+ target = tmp_path / "lib.rs"
1243
+ target.write_text("pub fn x() {}")
1244
+ old = self._mk_read(
1245
+ error_path=str(tmp_path / "state.rs"),
1246
+ success_path=str(target),
1247
+ last_seen=datetime.now(UTC) - timedelta(days=22),
1248
+ )
1249
+ fresh = self._mk_read(
1250
+ error_path=str(tmp_path / "other.rs"),
1251
+ success_path=str(target),
1252
+ )
1253
+ refined = _refine_error_recovery([old, fresh])
1254
+ assert fresh in refined
1255
+ assert old not in refined
1256
+
1257
+ def test_revalidates_read_success_path(self, tmp_path):
1258
+ present = tmp_path / "present.rs"
1259
+ present.write_text("x")
1260
+ p_ok = self._mk_read(
1261
+ error_path=str(tmp_path / "miss.rs"),
1262
+ success_path=str(present),
1263
+ )
1264
+ p_missing = self._mk_read(
1265
+ error_path=str(tmp_path / "other.rs"),
1266
+ success_path=str(tmp_path / "gone.rs"),
1267
+ )
1268
+ refined = _refine_error_recovery([p_ok, p_missing])
1269
+ assert p_ok in refined
1270
+ assert p_missing not in refined
1271
+
1272
+ def test_collapses_ambiguous_error_path(self, tmp_path):
1273
+ a = tmp_path / "a.rs"
1274
+ a.write_text("x")
1275
+ b = tmp_path / "b.rs"
1276
+ b.write_text("y")
1277
+ c = tmp_path / "c.rs"
1278
+ c.write_text("z")
1279
+ error_path = str(tmp_path / "ambiguous.rs")
1280
+ group = [
1281
+ self._mk_read(error_path=error_path, success_path=str(a), evidence=3),
1282
+ self._mk_read(error_path=error_path, success_path=str(b), evidence=2),
1283
+ self._mk_read(error_path=error_path, success_path=str(c), evidence=1),
1284
+ ]
1285
+ refined = _refine_error_recovery(group)
1286
+ assert len(refined) == 1
1287
+ collapsed = refined[0]
1288
+ assert collapsed.metadata.get("collapsed") is True
1289
+ assert collapsed.evidence_count == 6
1290
+ assert "ambiguous.rs" in collapsed.content
1291
+ assert "Glob/Grep" in collapsed.content
1292
+
1293
+ def test_single_success_path_not_collapsed(self, tmp_path):
1294
+ a = tmp_path / "a.rs"
1295
+ a.write_text("x")
1296
+ error_path = str(tmp_path / "only-one-target.rs")
1297
+ patterns = [
1298
+ self._mk_read(error_path=error_path, success_path=str(a), evidence=3),
1299
+ self._mk_read(error_path=error_path, success_path=str(a), evidence=2),
1300
+ ]
1301
+ refined = _refine_error_recovery(patterns)
1302
+ # Not collapsed — only one distinct success_path.
1303
+ assert all(p.metadata.get("collapsed") is not True for p in refined)
1304
+ assert len(refined) == 2
1305
+
1306
+ def test_recency_ranking_prefers_fresh_over_stale_heavy(self, tmp_path):
1307
+ target = tmp_path / "lib.rs"
1308
+ target.write_text("x")
1309
+ # Heavy but old: evidence=10, seen 10 days ago → score ~10 * 0.5**2 = 2.5
1310
+ heavy_old = self._mk_read(
1311
+ error_path=str(tmp_path / "old.rs"),
1312
+ success_path=str(target),
1313
+ evidence=10,
1314
+ last_seen=datetime.now(UTC) - timedelta(days=10),
1315
+ )
1316
+ # Light but fresh: evidence=3, seen now → score ~3
1317
+ light_fresh = self._mk_read(
1318
+ error_path=str(tmp_path / "fresh.rs"),
1319
+ success_path=str(target),
1320
+ evidence=3,
1321
+ )
1322
+ refined = _refine_error_recovery([heavy_old, light_fresh])
1323
+ assert refined[0] is light_fresh
1324
+ assert refined[1] is heavy_old
1325
+
1326
+ def test_section_cap_enforced(self, tmp_path):
1327
+ target = tmp_path / "lib.rs"
1328
+ target.write_text("x")
1329
+ patterns = [
1330
+ self._mk_read(
1331
+ error_path=str(tmp_path / f"miss_{i}.rs"),
1332
+ success_path=str(target),
1333
+ evidence=i + 1,
1334
+ )
1335
+ for i in range(25)
1336
+ ]
1337
+ refined = _refine_error_recovery(patterns)
1338
+ assert len(refined) == 15
1339
+ # Highest-evidence ones kept (all are equally fresh, so evidence wins).
1340
+ kept_evidence = sorted(p.evidence_count for p in refined)
1341
+ assert kept_evidence[0] >= 11 # Bottom of top-15 out of 1..25
1342
+
1343
+ def test_read_recovery_without_success_path_not_revalidated(self):
1344
+ """Read patterns lacking `success_path` in metadata skip re-validation cleanly."""
1345
+ p = ExtractedPattern(
1346
+ category=PatternCategory.ERROR_RECOVERY,
1347
+ content="Some legacy Read bullet",
1348
+ importance=0.7,
1349
+ metadata={"tool": "Read", "error_path": "/something.rs"},
1350
+ last_seen_at=datetime.now(UTC),
1351
+ )
1352
+ refined = _refine_error_recovery([p])
1353
+ assert p in refined
1354
+
1355
+ def test_bash_recoveries_not_revalidated(self, tmp_path):
1356
+ """Bash patterns pass through re-validation regardless of command content."""
1357
+ bash_pat = ExtractedPattern(
1358
+ category=PatternCategory.ERROR_RECOVERY,
1359
+ content="Command `x` fails. Use `y` instead.",
1360
+ importance=0.7,
1361
+ evidence_count=1,
1362
+ metadata={
1363
+ "tool": "Bash",
1364
+ "failed_cmd": "x",
1365
+ "success_cmd": "y",
1366
+ },
1367
+ last_seen_at=datetime.now(UTC),
1368
+ )
1369
+ refined = _refine_error_recovery([bash_pat])
1370
+ assert bash_pat in refined
1371
+
1372
+ def test_empty_input_returns_empty(self):
1373
+ assert _refine_error_recovery([]) == []
1374
+
1375
+ def test_missing_timestamps_survive_one_render(self):
1376
+ """Patterns without timestamps are kept rather than silently dropped."""
1377
+ p = ExtractedPattern(
1378
+ category=PatternCategory.ERROR_RECOVERY,
1379
+ content="legacy bullet",
1380
+ importance=0.7,
1381
+ )
1382
+ assert p.first_seen_at is None
1383
+ assert p.last_seen_at is None
1384
+ refined = _refine_error_recovery([p])
1385
+ assert p in refined
1386
+
1387
+ def test_refined_empty_skips_section_in_recommendations(self, tmp_path):
1388
+ """If all error_recovery patterns fail re-validation, no recommendation is emitted."""
1389
+ # Only pattern is a Read recovery pointing at a nonexistent success_path.
1390
+ stale = ExtractedPattern(
1391
+ category=PatternCategory.ERROR_RECOVERY,
1392
+ content="File `/a.rs` does not exist. The correct path is `/gone.rs`.",
1393
+ importance=0.7,
1394
+ metadata={
1395
+ "tool": "Read",
1396
+ "error_path": "/a.rs",
1397
+ "success_path": str(tmp_path / "does-not-exist.rs"),
1398
+ },
1399
+ last_seen_at=datetime.now(UTC),
1400
+ )
1401
+ recs = _patterns_to_recommendations([stale])
1402
+ # Section should be skipped entirely — no recommendation produced.
1403
+ assert recs == []
1404
+
1405
+ def test_oserror_during_revalidation_keeps_row(self, monkeypatch):
1406
+ """Transient OS errors during path checks should not drop the row."""
1407
+ p = ExtractedPattern(
1408
+ category=PatternCategory.ERROR_RECOVERY,
1409
+ content="File `/a.rs` does not exist. The correct path is `/b.rs`.",
1410
+ importance=0.7,
1411
+ metadata={"tool": "Read", "error_path": "/a.rs", "success_path": "/b.rs"},
1412
+ last_seen_at=datetime.now(UTC),
1413
+ )
1414
+
1415
+ def _raise(self):
1416
+ raise OSError("simulated permission error")
1417
+
1418
+ monkeypatch.setattr("pathlib.Path.exists", _raise)
1419
+ refined = _refine_error_recovery([p])
1420
+ assert p in refined
1421
+
1422
+
1423
+ class TestNormalizeBashForHash:
1424
+ """Bash command normalization for hash-key collapse."""
1425
+
1426
+ def test_empty_string_returns_empty(self):
1427
+ assert _normalize_bash_for_hash("") == ""
1428
+
1429
+ def test_no_volatile_suffix_unchanged(self):
1430
+ assert _normalize_bash_for_hash("cargo check") == "cargo check"
1431
+
1432
+ def test_strips_head_suffix(self):
1433
+ assert _normalize_bash_for_hash("grep foo bar | head -20") == "grep foo bar"
1434
+
1435
+ def test_strips_tail_suffix(self):
1436
+ assert _normalize_bash_for_hash("cargo check | tail -5") == "cargo check"
1437
+
1438
+ def test_strips_trailing_context_flags(self):
1439
+ # The regex is anchored to end-of-string; context flags must be trailing.
1440
+ assert _normalize_bash_for_hash("grep foo bar -A 3") == "grep foo bar"
1441
+
1442
+ def test_strips_stderr_redirect(self):
1443
+ assert _normalize_bash_for_hash("cargo check 2>&1") == "cargo check"
1444
+
1445
+ def test_cuts_at_first_chain(self):
1446
+ # && boundary collapses to just the primary command
1447
+ assert _normalize_bash_for_hash("cd /tmp && ls") == "cd /tmp"
1448
+
1449
+
1450
+ class TestParseIsoTimestamp:
1451
+ """Edge-case coverage for _parse_iso_timestamp."""
1452
+
1453
+ def test_none_returns_none(self):
1454
+ assert _parse_iso_timestamp(None) is None
1455
+
1456
+ def test_empty_string_returns_none(self):
1457
+ assert _parse_iso_timestamp("") is None
1458
+
1459
+ def test_non_string_returns_none(self):
1460
+ assert _parse_iso_timestamp(12345) is None
1461
+ assert _parse_iso_timestamp(3.14) is None
1462
+
1463
+ def test_invalid_format_returns_none(self):
1464
+ assert _parse_iso_timestamp("not an iso string") is None
1465
+
1466
+ def test_naive_timestamp_assumed_utc(self):
1467
+ parsed = _parse_iso_timestamp("2026-04-20T12:00:00")
1468
+ assert parsed is not None
1469
+ assert parsed.tzinfo == UTC
1470
+
1471
+ def test_aware_timestamp_preserved(self):
1472
+ parsed = _parse_iso_timestamp("2026-04-20T12:00:00+00:00")
1473
+ assert parsed is not None
1474
+ assert parsed.tzinfo is not None
1475
+
1476
+
1477
+ class TestLoadPersistedPatternsTimestamps:
1478
+ """The sqlite load path reads first_seen_at / last_seen_at correctly."""
1479
+
1480
+ def _make_db(self, tmp_path, rows: list[dict]):
1481
+ import json as _json
1482
+ import sqlite3 as _sql
1483
+
1484
+ db = tmp_path / "memory.db"
1485
+ conn = _sql.connect(db)
1486
+ conn.execute(
1487
+ "CREATE TABLE memories ("
1488
+ "id TEXT PRIMARY KEY, content TEXT NOT NULL, "
1489
+ "metadata TEXT NOT NULL DEFAULT '{}', "
1490
+ "entity_refs TEXT NOT NULL DEFAULT '[]', "
1491
+ "importance REAL NOT NULL DEFAULT 0.5, "
1492
+ "created_at TEXT)"
1493
+ )
1494
+ for i, r in enumerate(rows):
1495
+ conn.execute(
1496
+ "INSERT INTO memories "
1497
+ "(id, content, metadata, entity_refs, importance, created_at) "
1498
+ "VALUES (?,?,?,?,?,?)",
1499
+ (
1500
+ str(i),
1501
+ r["content"],
1502
+ _json.dumps(r.get("metadata", {})),
1503
+ _json.dumps(r.get("entity_refs", [])),
1504
+ r.get("importance", 0.5),
1505
+ r.get("created_at"),
1506
+ ),
1507
+ )
1508
+ conn.commit()
1509
+ conn.close()
1510
+ return db
1511
+
1512
+ def test_reads_timestamps_from_metadata(self, tmp_path):
1513
+ db = self._make_db(
1514
+ tmp_path,
1515
+ [
1516
+ {
1517
+ "content": "env bullet",
1518
+ "metadata": {
1519
+ "source": "traffic_learner",
1520
+ "category": "environment",
1521
+ "evidence_count": 3,
1522
+ "first_seen_at": "2026-04-10T10:00:00+00:00",
1523
+ "last_seen_at": "2026-04-20T15:00:00+00:00",
1524
+ },
1525
+ }
1526
+ ],
1527
+ )
1528
+ patterns = _load_persisted_patterns_from_sqlite(db)
1529
+ assert len(patterns) == 1
1530
+ p = patterns[0]
1531
+ assert p.first_seen_at is not None
1532
+ assert p.first_seen_at.year == 2026 and p.first_seen_at.month == 4
1533
+ assert p.last_seen_at is not None
1534
+ assert p.last_seen_at.day == 20
1535
+
1536
+ def test_falls_back_to_created_at(self, tmp_path):
1537
+ """When metadata has no timestamps, `created_at` is used."""
1538
+ db = self._make_db(
1539
+ tmp_path,
1540
+ [
1541
+ {
1542
+ "content": "env bullet",
1543
+ "metadata": {
1544
+ "source": "traffic_learner",
1545
+ "category": "environment",
1546
+ "evidence_count": 1,
1547
+ },
1548
+ "created_at": "2026-03-01T09:00:00+00:00",
1549
+ }
1550
+ ],
1551
+ )
1552
+ patterns = _load_persisted_patterns_from_sqlite(db)
1553
+ assert len(patterns) == 1
1554
+ assert patterns[0].first_seen_at is not None
1555
+ assert patterns[0].first_seen_at.month == 3
1556
+ # last_seen defaults to first_seen when metadata lacks both.
1557
+ assert patterns[0].last_seen_at == patterns[0].first_seen_at
1558
+
1559
+ def test_collision_merges_timestamps_max_last_min_first(self, tmp_path):
1560
+ """Two rows collapsing to the same hash keep the widest timestamp range."""
1561
+ db = self._make_db(
1562
+ tmp_path,
1563
+ [
1564
+ {
1565
+ "content": "dup bullet",
1566
+ "importance": 0.4,
1567
+ "metadata": {
1568
+ "source": "traffic_learner",
1569
+ "category": "preference",
1570
+ "evidence_count": 2,
1571
+ "first_seen_at": "2026-04-10T00:00:00+00:00",
1572
+ "last_seen_at": "2026-04-15T00:00:00+00:00",
1573
+ },
1574
+ },
1575
+ {
1576
+ "content": "dup bullet",
1577
+ "importance": 0.9,
1578
+ "metadata": {
1579
+ "source": "traffic_learner",
1580
+ "category": "preference",
1581
+ "evidence_count": 3,
1582
+ "first_seen_at": "2026-04-01T00:00:00+00:00",
1583
+ "last_seen_at": "2026-04-20T00:00:00+00:00",
1584
+ },
1585
+ },
1586
+ ],
1587
+ )
1588
+ patterns = _load_persisted_patterns_from_sqlite(db)
1589
+ assert len(patterns) == 1
1590
+ p = patterns[0]
1591
+ assert p.evidence_count == 5
1592
+ # Higher importance wins when collision merges.
1593
+ assert p.importance == 0.9
1594
+ assert p.first_seen_at is not None and p.first_seen_at.day == 1
1595
+ assert p.last_seen_at is not None and p.last_seen_at.day == 20
1596
+
1597
+ def test_non_numeric_importance_falls_back_to_default(self, tmp_path):
1598
+ """Rows with an unparseable importance value use 0.5."""
1599
+ import json as _json
1600
+ import sqlite3 as _sql
1601
+
1602
+ db = tmp_path / "memory.db"
1603
+ conn = _sql.connect(db)
1604
+ conn.execute(
1605
+ "CREATE TABLE memories ("
1606
+ "id TEXT PRIMARY KEY, content TEXT NOT NULL, "
1607
+ "metadata TEXT NOT NULL DEFAULT '{}', "
1608
+ "entity_refs TEXT NOT NULL DEFAULT '[]', "
1609
+ "importance TEXT, "
1610
+ "created_at TEXT)"
1611
+ )
1612
+ conn.execute(
1613
+ "INSERT INTO memories (id, content, metadata, importance) VALUES (?,?,?,?)",
1614
+ (
1615
+ "0",
1616
+ "bullet",
1617
+ _json.dumps(
1618
+ {
1619
+ "source": "traffic_learner",
1620
+ "category": "environment",
1621
+ "evidence_count": 1,
1622
+ }
1623
+ ),
1624
+ "not-a-number",
1625
+ ),
1626
+ )
1627
+ conn.commit()
1628
+ conn.close()
1629
+ patterns = _load_persisted_patterns_from_sqlite(db)
1630
+ assert len(patterns) == 1
1631
+ assert patterns[0].importance == 0.5
1632
+
1633
+ def test_malformed_metadata_json_skipped_gracefully(self, tmp_path):
1634
+ """Rows with invalid JSON metadata don't crash the load."""
1635
+ import sqlite3 as _sql
1636
+
1637
+ db = tmp_path / "memory.db"
1638
+ conn = _sql.connect(db)
1639
+ conn.execute(
1640
+ "CREATE TABLE memories ("
1641
+ "id TEXT PRIMARY KEY, content TEXT NOT NULL, "
1642
+ "metadata TEXT NOT NULL DEFAULT '{}', "
1643
+ "entity_refs TEXT NOT NULL DEFAULT '[]', "
1644
+ "importance REAL NOT NULL DEFAULT 0.5, "
1645
+ "created_at TEXT)"
1646
+ )
1647
+ # Invalid JSON in metadata
1648
+ conn.execute(
1649
+ "INSERT INTO memories VALUES (?,?,?,?,?,?)",
1650
+ ("0", "bullet", "{not json", "[]", 0.5, None),
1651
+ )
1652
+ conn.commit()
1653
+ conn.close()
1654
+ # Should not raise — the row is simply skipped (no recognizable category).
1655
+ patterns = _load_persisted_patterns_from_sqlite(db)
1656
+ assert patterns == []
1657
+
1658
+
1659
+ class TestBumpPersistsLastSeenAt:
1660
+ """_bump_persisted_evidence sets $.last_seen_at on every bump."""
1661
+
1662
+ @pytest.mark.asyncio
1663
+ async def test_bump_sets_last_seen_at_in_metadata(self, tmp_path):
1664
+ import sqlite3 as _sql
1665
+
1666
+ db = tmp_path / "memory.db"
1667
+ _init_db(db)
1668
+ # Seed a traffic_learner row with no last_seen_at.
1669
+ import json as _json
1670
+
1671
+ conn = _sql.connect(db)
1672
+ conn.execute(
1673
+ "INSERT INTO memories (id, content, metadata) VALUES (?,?,?)",
1674
+ (
1675
+ "row-1",
1676
+ "bullet",
1677
+ _json.dumps(
1678
+ {
1679
+ "source": "traffic_learner",
1680
+ "category": "environment",
1681
+ "evidence_count": 1,
1682
+ }
1683
+ ),
1684
+ ),
1685
+ )
1686
+ conn.commit()
1687
+ conn.close()
1688
+
1689
+ backend = _FakeBackend(db)
1690
+ learner = TrafficLearner(backend=backend, min_evidence=1)
1691
+ await learner._bump_persisted_evidence("row-1")
1692
+
1693
+ conn = _sql.connect(db)
1694
+ row = conn.execute("SELECT metadata FROM memories WHERE id='row-1'").fetchone()
1695
+ conn.close()
1696
+ meta = _json.loads(row[0])
1697
+ assert meta["evidence_count"] == 2
1698
+ assert "last_seen_at" in meta
1699
+ # Should be parseable back.
1700
+ parsed = _parse_iso_timestamp(meta["last_seen_at"])
1701
+ assert parsed is not None
1702
+
1703
+
1704
+ class TestHydrateLegacyRow:
1705
+ """Legacy rows without `category` metadata fall back to literal-content hashing."""
1706
+
1707
+ @pytest.mark.asyncio
1708
+ async def test_hydrate_legacy_row_without_category(self, tmp_path):
1709
+ import sqlite3 as _sql
1710
+
1711
+ db = tmp_path / "memory.db"
1712
+ _init_db(db)
1713
+ import json as _json
1714
+
1715
+ conn = _sql.connect(db)
1716
+ # No `category` key in metadata — must still hydrate.
1717
+ conn.execute(
1718
+ "INSERT INTO memories (id, content, metadata) VALUES (?,?,?)",
1719
+ (
1720
+ "legacy-1",
1721
+ "legacy bullet",
1722
+ _json.dumps({"source": "traffic_learner"}),
1723
+ ),
1724
+ )
1725
+ conn.commit()
1726
+ conn.close()
1727
+
1728
+ backend = _FakeBackend(db)
1729
+ learner = TrafficLearner(backend=backend, min_evidence=1)
1730
+ await learner._hydrate_persisted_state()
1731
+
1732
+ # Falls back to sha256(content) for the hash key.
1733
+ import hashlib as _h
1734
+
1735
+ expected = _h.sha256(b"legacy bullet").hexdigest()[:16]
1736
+ assert expected in learner._saved_hashes
1737
+ assert learner._persisted_ids[expected] == "legacy-1"
1738
+
1739
+ @pytest.mark.asyncio
1740
+ async def test_hydrate_skips_empty_content(self, tmp_path):
1741
+ """Rows with empty content are skipped during hydration."""
1742
+ import json as _json
1743
+ import sqlite3 as _sql
1744
+
1745
+ db = tmp_path / "memory.db"
1746
+ _init_db(db)
1747
+ conn = _sql.connect(db)
1748
+ conn.execute(
1749
+ "INSERT INTO memories (id, content, metadata) VALUES (?,?,?)",
1750
+ ("empty", "", _json.dumps({"source": "traffic_learner"})),
1751
+ )
1752
+ conn.execute(
1753
+ "INSERT INTO memories (id, content, metadata) VALUES (?,?,?)",
1754
+ (
1755
+ "ok",
1756
+ "normal bullet",
1757
+ _json.dumps({"source": "traffic_learner", "category": "environment"}),
1758
+ ),
1759
+ )
1760
+ conn.commit()
1761
+ conn.close()
1762
+
1763
+ backend = _FakeBackend(db)
1764
+ learner = TrafficLearner(backend=backend, min_evidence=1)
1765
+ await learner._hydrate_persisted_state()
1766
+
1767
+ assert "empty" not in learner._persisted_ids.values()
1768
+ assert "ok" in learner._persisted_ids.values()
1769
+
1770
+ @pytest.mark.asyncio
1771
+ async def test_hydrate_invalid_category_falls_back(self, tmp_path):
1772
+ """Unknown category values (e.g., typos) are handled as legacy rows."""
1773
+ import sqlite3 as _sql
1774
+
1775
+ db = tmp_path / "memory.db"
1776
+ _init_db(db)
1777
+ import json as _json
1778
+
1779
+ conn = _sql.connect(db)
1780
+ conn.execute(
1781
+ "INSERT INTO memories (id, content, metadata) VALUES (?,?,?)",
1782
+ (
1783
+ "bad-cat",
1784
+ "mystery bullet",
1785
+ _json.dumps({"source": "traffic_learner", "category": "mystery_type"}),
1786
+ ),
1787
+ )
1788
+ conn.commit()
1789
+ conn.close()
1790
+
1791
+ backend = _FakeBackend(db)
1792
+ learner = TrafficLearner(backend=backend, min_evidence=1)
1793
+ # Must not raise.
1794
+ await learner._hydrate_persisted_state()
1795
+
1796
+
1797
+ class TestCollectAllPatternsTimestamps:
1798
+ """_collect_all_patterns bumps last_seen_at on in-session re-sightings."""
1799
+
1800
+ @pytest.mark.asyncio
1801
+ async def test_re_sighting_bumps_last_seen_at(self, tmp_path):
1802
+ """A persisted pattern re-observed in this session gets last_seen_at=now."""
1803
+ import json as _json
1804
+ import sqlite3 as _sql
1805
+
1806
+ db = tmp_path / "memory.db"
1807
+ _init_db(db)
1808
+ old_last_seen = "2026-01-01T00:00:00+00:00"
1809
+ conn = _sql.connect(db)
1810
+ conn.execute(
1811
+ "INSERT INTO memories (id, content, metadata) VALUES (?,?,?)",
1812
+ (
1813
+ "seed-1",
1814
+ "some env bullet",
1815
+ _json.dumps(
1816
+ {
1817
+ "source": "traffic_learner",
1818
+ "category": "environment",
1819
+ "evidence_count": 1,
1820
+ "first_seen_at": old_last_seen,
1821
+ "last_seen_at": old_last_seen,
1822
+ }
1823
+ ),
1824
+ ),
1825
+ )
1826
+ conn.commit()
1827
+ conn.close()
1828
+
1829
+ backend = _FakeBackend(db)
1830
+ learner = TrafficLearner(backend=backend, min_evidence=1)
1831
+
1832
+ # Simulate in-session accumulation of the same pattern.
1833
+ pattern = ExtractedPattern(
1834
+ category=PatternCategory.ENVIRONMENT,
1835
+ content="some env bullet",
1836
+ importance=0.7,
1837
+ )
1838
+ learner._pattern_counts[pattern.content_hash] = (pattern, 1)
1839
+
1840
+ merged = learner._collect_all_patterns()
1841
+ assert len(merged) == 1
1842
+ m = merged[0]
1843
+ assert m.last_seen_at is not None
1844
+ # last_seen_at should be bumped past the stale 2026-01 timestamp.
1845
+ assert m.last_seen_at.year == datetime.now(UTC).year
1846
+ assert m.last_seen_at > _parse_iso_timestamp(old_last_seen)