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

1"""ll-history-context: render a Historical Context block for an issue. 

2 

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. 

7 

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). 

11 

12Usage: 

13 ll-history-context <issue_id> [--file <path>] [--db <path>] 

14 ll-history-context --project [--db <path>] 

15""" 

16 

17from __future__ import annotations 

18 

19import argparse 

20import hashlib 

21import logging 

22import sys 

23from datetime import UTC, datetime, timedelta 

24from pathlib import Path 

25 

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 

41 

42logger = logging.getLogger(__name__) 

43 

44_MAX_ROWS = 5 

45 

46 

47def _content_hash(content: str) -> str: 

48 return hashlib.sha256(content.encode()).hexdigest()[:16] 

49 

50 

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 

97 

98 

99def main_history_context() -> int: 

100 """Entry point for ll-history-context command. 

101 

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 

108 

109 parser = _build_parser() 

110 args = parser.parse_args() 

111 

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") 

117 

118 # --project: print the project-wide digest dry-run and exit. 

119 if args.project: 

120 from little_loops.config.features import HistoryConfig 

121 

122 cwd = Path.cwd() 

123 config_path = resolve_config_path(cwd) 

124 if config_path is not None: 

125 import json 

126 

127 try: 

128 merged = json.loads(config_path.read_text(encoding="utf-8")) 

129 except (OSError, json.JSONDecodeError): 

130 merged = {} 

131 else: 

132 merged = {} 

133 

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 

142 

143 # --effort: print effort/velocity context and exit. 

144 if args.effort: 

145 from little_loops.config import BRConfig 

146 

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 

165 

166 cutoff = (datetime.now(UTC) - timedelta(days=STALE_DAYS_DEFAULT)).strftime( 

167 "%Y-%m-%dT%H:%M:%SZ" 

168 ) 

169 

170 corrections: list[UserCorrection] = find_user_corrections( 

171 topic=args.issue_id, limit=20, db=args.db 

172 ) 

173 

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] 

178 

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}") 

183 

184 seen: set[str] = set() 

185 rows: list[str] = [] 

186 

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) 

192 

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) 

198 

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) 

204 

205 rows = rows[:_MAX_ROWS] 

206 

207 if not rows: 

208 return 0 

209 

210 print("## Historical Context") 

211 print() 

212 for row in rows: 

213 print(f"- {row}") 

214 

215 return 0