Coverage for little_loops / cli / history_context.py: 16%
105 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-04 12:21 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-04 12:21 -0500
1"""ll-history-context: render a Historical Context block for an issue.
3Queries .ll/history.db for user corrections and FTS5 matches relevant to an
4issue ID and prints a ready-to-inject ``## Historical Context`` markdown block.
5Returns empty output when the DB is missing, has no matches, or all rows are
6stale.
8Pass ``--project`` instead of an issue ID to print the project-wide context
9digest that the session-start hook would inject (inspection / config-tuning
10dry-run, ENH-1907).
12Usage:
13 ll-history-context <issue_id> [--file <path>] [--db <path>]
14 ll-history-context --project [--db <path>]
15"""
17from __future__ import annotations
19import argparse
20import hashlib
21import logging
22import sys
23from datetime import UTC, datetime, timedelta
24from pathlib import Path
26from little_loops.cli.output import configure_output, use_color_enabled
27from little_loops.config.core import resolve_config_path
28from little_loops.history_reader import (
29 STALE_DAYS_DEFAULT,
30 SearchResult,
31 UserCorrection,
32 find_user_corrections,
33 issue_effort,
34 project_digest,
35 recent_file_events,
36 render_project_context,
37 search,
38)
39from little_loops.logger import Logger
40from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context
42logger = logging.getLogger(__name__)
44_MAX_ROWS = 5
47def _content_hash(content: str) -> str:
48 return hashlib.sha256(content.encode()).hexdigest()[:16]
51def _build_parser() -> argparse.ArgumentParser:
52 parser = argparse.ArgumentParser(
53 prog="ll-history-context",
54 description="Render a ## Historical Context block for an issue from .ll/history.db",
55 formatter_class=argparse.RawDescriptionHelpFormatter,
56 epilog="""
57Examples:
58 %(prog)s ENH-1708 # Corrections matching the issue ID
59 %(prog)s ENH-1708 --file src/foo.py # Also include recent file events
60 %(prog)s ENH-1708 --db custom/history.db # Use a non-default database
61 %(prog)s --project # Print project-wide context digest
62""",
63 )
64 parser.add_argument(
65 "issue_id",
66 metavar="ISSUE_ID",
67 nargs="?",
68 default=None,
69 help="Issue ID to query (e.g. ENH-1708). Mutually exclusive with --project.",
70 )
71 parser.add_argument(
72 "--project",
73 action="store_true",
74 default=False,
75 help="Print the project-wide context digest (dry-run of session-start injection).",
76 )
77 parser.add_argument(
78 "--file",
79 metavar="PATH",
80 default=None,
81 help="Also include recent file events for this path",
82 )
83 parser.add_argument(
84 "--db",
85 type=Path,
86 default=DEFAULT_DB_PATH,
87 metavar="PATH",
88 help="Path to the session database (default: .ll/history.db)",
89 )
90 parser.add_argument(
91 "--effort",
92 action="store_true",
93 default=False,
94 help="Include effort/velocity context (session count and cycle time)",
95 )
96 return parser
99def main_history_context() -> int:
100 """Entry point for ll-history-context command.
102 Returns:
103 0 on success (including empty output when no matches or DB absent), 1 on argument error.
104 """
105 with cli_event_context(DEFAULT_DB_PATH, "ll-history-context", sys.argv[1:]):
106 configure_output()
107 logger = Logger(use_color=use_color_enabled()) # noqa: F841
109 parser = _build_parser()
110 args = parser.parse_args()
112 # Mutual-exclusion guard: require exactly one of issue_id or --project.
113 if args.project and args.issue_id:
114 parser.error("--project and ISSUE_ID are mutually exclusive")
115 if not args.project and not args.issue_id:
116 parser.error("one of ISSUE_ID or --project is required")
118 # --project: print the project-wide digest dry-run and exit.
119 if args.project:
120 from little_loops.config.features import HistoryConfig
122 cwd = Path.cwd()
123 config_path = resolve_config_path(cwd)
124 if config_path is not None:
125 import json
127 try:
128 merged = json.loads(config_path.read_text(encoding="utf-8"))
129 except (OSError, json.JSONDecodeError):
130 merged = {}
131 else:
132 merged = {}
134 _hist = HistoryConfig.from_dict(merged.get("history", {}))
135 _sd = _hist.session_digest
136 _sections = _sd.sections if _sd.sections else None
137 digest = project_digest(args.db, days=_sd.days, sections=_sections)
138 block = render_project_context(digest, char_cap=_sd.char_cap, days=_sd.days)
139 if block:
140 print(block)
141 return 0
143 # --effort: print effort/velocity context and exit.
144 if args.effort:
145 from little_loops.config import BRConfig
147 cfg = BRConfig(Path.cwd())
148 fields = cfg.history.effort_fields
149 effort = issue_effort(args.issue_id, db=args.db)
150 if effort is None:
151 return 0
152 valid_fields = {"session_count", "cycle_time_days"}
153 print("## Effort Context")
154 print()
155 for f in fields:
156 if f not in valid_fields:
157 logger.warning(f"history_context: unknown effort field {f!r} — skipping")
158 continue
159 val = effort.get(f)
160 if val is None:
161 print(f"- {f}: n/a")
162 else:
163 print(f"- {f}: {val}")
164 return 0
166 cutoff = (datetime.now(UTC) - timedelta(days=STALE_DAYS_DEFAULT)).strftime(
167 "%Y-%m-%dT%H:%M:%SZ"
168 )
170 corrections: list[UserCorrection] = find_user_corrections(
171 topic=args.issue_id, limit=20, db=args.db
172 )
174 search_results: list[SearchResult] = search(
175 query=args.issue_id, kind="correction", limit=20, db=args.db
176 )
177 fresh_search = [r for r in search_results if r.ts >= cutoff]
179 file_contents: list[str] = []
180 if args.file:
181 for fe in recent_file_events(path=args.file, limit=5, db=args.db):
182 file_contents.append(f"file:{fe.path}:{fe.op}")
184 seen: set[str] = set()
185 rows: list[str] = []
187 for uc in corrections:
188 h = _content_hash(uc.content)
189 if h not in seen:
190 seen.add(h)
191 rows.append(uc.content)
193 for sr in fresh_search:
194 h = _content_hash(sr.content)
195 if h not in seen:
196 seen.add(h)
197 rows.append(sr.content)
199 for fc in file_contents:
200 h = _content_hash(fc)
201 if h not in seen:
202 seen.add(h)
203 rows.append(fc)
205 rows = rows[:_MAX_ROWS]
207 if not rows:
208 return 0
210 print("## Historical Context")
211 print()
212 for row in rows:
213 print(f"- {row}")
215 return 0