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

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

104 

105 

106def main_history_context() -> int: 

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

108 

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 

115 

116 parser = _build_parser() 

117 args = parser.parse_args() 

118 

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

124 

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

126 if args.project: 

127 from little_loops.config.features import HistoryConfig 

128 

129 cwd = Path.cwd() 

130 config_path = resolve_config_path(cwd) 

131 if config_path is not None: 

132 import json 

133 

134 try: 

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

136 except (OSError, json.JSONDecodeError): 

137 merged = {} 

138 else: 

139 merged = {} 

140 

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 

149 

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 

153 

154 cfg = BRConfig(Path.cwd()) 

155 if args.for_skill not in cfg.history.planning_skills: 

156 return 0 

157 

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

159 if args.effort: 

160 from little_loops.config import BRConfig 

161 

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 

180 

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

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

183 ) 

184 

185 corrections: list[UserCorrection] = find_user_corrections( 

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

187 ) 

188 

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] 

193 

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

198 

199 seen: set[str] = set() 

200 rows: list[str] = [] 

201 

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) 

207 

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) 

213 

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) 

219 

220 rows = rows[:_MAX_ROWS] 

221 

222 if not rows: 

223 return 0 

224 

225 print("## Historical Context") 

226 print() 

227 for row in rows: 

228 print(f"- {row}") 

229 

230 return 0