Coverage for little_loops / cli / history_context.py: 15%
111 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-08 15:34 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-08 15:34 -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 parser.add_argument(
97 "--for-skill",
98 type=str,
99 default=None,
100 metavar="NAME",
101 help="Exit 0 with no output if NAME is not in history.planning_skills.",
102 )
103 return parser
106def main_history_context() -> int:
107 """Entry point for ll-history-context command.
109 Returns:
110 0 on success (including empty output when no matches or DB absent), 1 on argument error.
111 """
112 with cli_event_context(DEFAULT_DB_PATH, "ll-history-context", sys.argv[1:]):
113 configure_output()
114 logger = Logger(use_color=use_color_enabled()) # noqa: F841
116 parser = _build_parser()
117 args = parser.parse_args()
119 # Mutual-exclusion guard: require exactly one of issue_id or --project.
120 if args.project and args.issue_id:
121 parser.error("--project and ISSUE_ID are mutually exclusive")
122 if not args.project and not args.issue_id:
123 parser.error("one of ISSUE_ID or --project is required")
125 # --project: print the project-wide digest dry-run and exit.
126 if args.project:
127 from little_loops.config.features import HistoryConfig
129 cwd = Path.cwd()
130 config_path = resolve_config_path(cwd)
131 if config_path is not None:
132 import json
134 try:
135 merged = json.loads(config_path.read_text(encoding="utf-8"))
136 except (OSError, json.JSONDecodeError):
137 merged = {}
138 else:
139 merged = {}
141 _hist = HistoryConfig.from_dict(merged.get("history", {}))
142 _sd = _hist.session_digest
143 _sections = _sd.sections if _sd.sections else None
144 digest = project_digest(args.db, days=_sd.days, sections=_sections)
145 block = render_project_context(digest, char_cap=_sd.char_cap, days=_sd.days)
146 if block:
147 print(block)
148 return 0
150 # --for-skill guard: exit 0 with no output if skill is not in history.planning_skills.
151 if args.for_skill is not None:
152 from little_loops.config import BRConfig
154 cfg = BRConfig(Path.cwd())
155 if args.for_skill not in cfg.history.planning_skills:
156 return 0
158 # --effort: print effort/velocity context and exit.
159 if args.effort:
160 from little_loops.config import BRConfig
162 cfg = BRConfig(Path.cwd())
163 fields = cfg.history.effort_fields
164 effort = issue_effort(args.issue_id, db=args.db)
165 if effort is None:
166 return 0
167 valid_fields = {"session_count", "cycle_time_days"}
168 print("## Effort Context")
169 print()
170 for f in fields:
171 if f not in valid_fields:
172 logger.warning(f"history_context: unknown effort field {f!r} — skipping")
173 continue
174 val = effort.get(f)
175 if val is None:
176 print(f"- {f}: n/a")
177 else:
178 print(f"- {f}: {val}")
179 return 0
181 cutoff = (datetime.now(UTC) - timedelta(days=STALE_DAYS_DEFAULT)).strftime(
182 "%Y-%m-%dT%H:%M:%SZ"
183 )
185 corrections: list[UserCorrection] = find_user_corrections(
186 topic=args.issue_id, limit=20, db=args.db
187 )
189 search_results: list[SearchResult] = search(
190 query=args.issue_id, kind="correction", limit=20, db=args.db
191 )
192 fresh_search = [r for r in search_results if r.ts >= cutoff]
194 file_contents: list[str] = []
195 if args.file:
196 for fe in recent_file_events(path=args.file, limit=5, db=args.db):
197 file_contents.append(f"file:{fe.path}:{fe.op}")
199 seen: set[str] = set()
200 rows: list[str] = []
202 for uc in corrections:
203 h = _content_hash(uc.content)
204 if h not in seen:
205 seen.add(h)
206 rows.append(uc.content)
208 for sr in fresh_search:
209 h = _content_hash(sr.content)
210 if h not in seen:
211 seen.add(h)
212 rows.append(sr.content)
214 for fc in file_contents:
215 h = _content_hash(fc)
216 if h not in seen:
217 seen.add(h)
218 rows.append(fc)
220 rows = rows[:_MAX_ROWS]
222 if not rows:
223 return 0
225 print("## Historical Context")
226 print()
227 for row in rows:
228 print(f"- {row}")
230 return 0