git-aware-coding-agent 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. avos_cli/__init__.py +3 -0
  2. avos_cli/agents/avos_ask_agent.md +47 -0
  3. avos_cli/agents/avos_ask_agent_JSON_converter.md +78 -0
  4. avos_cli/agents/avos_hisotry_agent_JSON_converter.md +92 -0
  5. avos_cli/agents/avos_history_agent.md +58 -0
  6. avos_cli/agents/git_diff_agent.md +63 -0
  7. avos_cli/artifacts/__init__.py +17 -0
  8. avos_cli/artifacts/base.py +47 -0
  9. avos_cli/artifacts/commit_builder.py +35 -0
  10. avos_cli/artifacts/doc_builder.py +30 -0
  11. avos_cli/artifacts/issue_builder.py +37 -0
  12. avos_cli/artifacts/pr_builder.py +50 -0
  13. avos_cli/cli/__init__.py +1 -0
  14. avos_cli/cli/main.py +504 -0
  15. avos_cli/commands/__init__.py +1 -0
  16. avos_cli/commands/ask.py +541 -0
  17. avos_cli/commands/connect.py +363 -0
  18. avos_cli/commands/history.py +549 -0
  19. avos_cli/commands/hook_install.py +260 -0
  20. avos_cli/commands/hook_sync.py +231 -0
  21. avos_cli/commands/ingest.py +506 -0
  22. avos_cli/commands/ingest_pr.py +239 -0
  23. avos_cli/config/__init__.py +1 -0
  24. avos_cli/config/hash_store.py +93 -0
  25. avos_cli/config/lock.py +122 -0
  26. avos_cli/config/manager.py +180 -0
  27. avos_cli/config/state.py +90 -0
  28. avos_cli/exceptions.py +272 -0
  29. avos_cli/models/__init__.py +58 -0
  30. avos_cli/models/api.py +75 -0
  31. avos_cli/models/artifacts.py +99 -0
  32. avos_cli/models/config.py +56 -0
  33. avos_cli/models/diff.py +117 -0
  34. avos_cli/models/query.py +234 -0
  35. avos_cli/parsers/__init__.py +21 -0
  36. avos_cli/parsers/artifact_ref_extractor.py +173 -0
  37. avos_cli/parsers/reference_parser.py +117 -0
  38. avos_cli/services/__init__.py +1 -0
  39. avos_cli/services/chronology_service.py +68 -0
  40. avos_cli/services/citation_validator.py +134 -0
  41. avos_cli/services/context_budget_service.py +104 -0
  42. avos_cli/services/diff_resolver.py +398 -0
  43. avos_cli/services/diff_summary_service.py +141 -0
  44. avos_cli/services/git_client.py +351 -0
  45. avos_cli/services/github_client.py +443 -0
  46. avos_cli/services/llm_client.py +312 -0
  47. avos_cli/services/memory_client.py +323 -0
  48. avos_cli/services/query_fallback_formatter.py +108 -0
  49. avos_cli/services/reply_output_service.py +341 -0
  50. avos_cli/services/sanitization_service.py +218 -0
  51. avos_cli/utils/__init__.py +1 -0
  52. avos_cli/utils/dotenv_load.py +50 -0
  53. avos_cli/utils/hashing.py +22 -0
  54. avos_cli/utils/logger.py +77 -0
  55. avos_cli/utils/output.py +232 -0
  56. avos_cli/utils/sanitization_diagnostics.py +81 -0
  57. avos_cli/utils/time_helpers.py +56 -0
  58. git_aware_coding_agent-1.0.0.dist-info/METADATA +390 -0
  59. git_aware_coding_agent-1.0.0.dist-info/RECORD +62 -0
  60. git_aware_coding_agent-1.0.0.dist-info/WHEEL +4 -0
  61. git_aware_coding_agent-1.0.0.dist-info/entry_points.txt +2 -0
  62. git_aware_coding_agent-1.0.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,549 @@
1
+ """History command orchestrator for AVOS CLI.
2
+
3
+ Implements the `avos history "subject"` flow: retrieves relevant memory
4
+ artifacts via hybrid search, enriches with git diff summaries, sorts
5
+ chronologically, sanitizes, packs within budget, synthesizes timeline via LLM,
6
+ validates citation grounding, and renders timeline or deterministic
7
+ chronological fallback.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json as json_module
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING
15
+
16
+ from avos_cli.config.manager import load_config
17
+ from avos_cli.exceptions import (
18
+ AvosError,
19
+ ConfigurationNotInitializedError,
20
+ LLMSynthesisError,
21
+ )
22
+ from avos_cli.models.api import SearchHit
23
+ from avos_cli.models.diff import DiffStatus
24
+ from avos_cli.models.query import (
25
+ FallbackReason,
26
+ QueryMode,
27
+ RetrievedArtifact,
28
+ SanitizedArtifact,
29
+ SynthesisRequest,
30
+ )
31
+ from avos_cli.parsers import ReferenceParser, extract_refs_by_note
32
+ from avos_cli.services.chronology_service import ChronologyService
33
+ from avos_cli.services.citation_validator import CitationValidator
34
+ from avos_cli.services.context_budget_service import ContextBudgetService
35
+ from avos_cli.services.diff_resolver import DiffResolver
36
+ from avos_cli.services.diff_summary_service import DiffSummaryService
37
+ from avos_cli.services.llm_client import LLMClient
38
+ from avos_cli.services.memory_client import AvosMemoryClient
39
+ from avos_cli.services.query_fallback_formatter import QueryFallbackFormatter
40
+ from avos_cli.services.reply_output_service import (
41
+ ReplyOutputService,
42
+ dumb_format_history,
43
+ parse_history_response,
44
+ )
45
+ from avos_cli.services.sanitization_service import SanitizationService
46
+ from avos_cli.utils.logger import get_logger
47
+ from avos_cli.utils.output import (
48
+ print_error,
49
+ print_info,
50
+ print_json,
51
+ print_warning,
52
+ render_panel,
53
+ render_table,
54
+ )
55
+ from avos_cli.utils.sanitization_diagnostics import explain_sanitization_gate
56
+
57
+ if TYPE_CHECKING:
58
+ from avos_cli.services.github_client import GitHubClient
59
+
60
+ _log = get_logger("commands.history")
61
+
62
+ _HISTORY_K = 20
63
+
64
+
65
+ def _build_raw_output(artifacts: list[SanitizedArtifact]) -> str:
66
+ """Build raw artifact string for reply layer."""
67
+ lines: list[str] = []
68
+ for art in artifacts:
69
+ lines.append(f"[{art.note_id}] ({art.created_at})\n{art.content}")
70
+ lines.append("---")
71
+ return "\n".join(lines)
72
+
73
+
74
+ def _render_reply_output(
75
+ subject: str,
76
+ raw_output: str,
77
+ reply_service: ReplyOutputService | None,
78
+ json_output: bool = False,
79
+ json_merge: dict[str, object] | None = None,
80
+ ) -> None:
81
+ """Render history output via reply layer or raw.
82
+
83
+ Args:
84
+ subject: The subject/topic for timeline.
85
+ raw_output: Raw artifact content string.
86
+ reply_service: Optional reply output service for decorated terminal output.
87
+ json_output: If True, emit JSON via converter agent instead of human UI.
88
+ json_merge: Optional top-level keys merged into successful JSON ``data`` objects.
89
+ """
90
+ if reply_service:
91
+ decorated = reply_service.format_history(subject, raw_output)
92
+ output = decorated if decorated else dumb_format_history(raw_output)
93
+
94
+ if json_output:
95
+ json_str = reply_service.format_history_json(output)
96
+ if json_str:
97
+ try:
98
+ parsed = json_module.loads(json_str)
99
+ if isinstance(parsed, dict) and json_merge:
100
+ for key, value in json_merge.items():
101
+ parsed[key] = value
102
+ print_json(success=True, data=parsed, error=None)
103
+ return
104
+ except json_module.JSONDecodeError:
105
+ _log.warning("JSON converter returned invalid JSON")
106
+ print_json(
107
+ success=False,
108
+ data=None,
109
+ error={
110
+ "code": "JSON_CONVERSION_FAILED",
111
+ "message": "Failed to convert history output to JSON",
112
+ "hint": "Check REPLY_MODEL configuration",
113
+ "retryable": True,
114
+ },
115
+ )
116
+ return
117
+
118
+ timeline, summary = parse_history_response(output)
119
+ render_panel("Timeline", timeline, style="success")
120
+ if summary:
121
+ render_panel("Summary", summary, style="info")
122
+ else:
123
+ if json_output:
124
+ print_json(
125
+ success=False,
126
+ data=None,
127
+ error={
128
+ "code": "REPLY_SERVICE_UNAVAILABLE",
129
+ "message": "JSON output requires REPLY_MODEL configuration",
130
+ "hint": "Set REPLY_MODEL, REPLY_MODEL_URL, REPLY_MODEL_API_KEY environment variables",
131
+ "retryable": False,
132
+ },
133
+ )
134
+ return
135
+ print_info(raw_output)
136
+
137
+
138
+ _HISTORY_SEARCH_MODE = "hybrid"
139
+ _MIN_GROUNDED_CITATIONS = 2
140
+ _SANITIZATION_CONFIDENCE_THRESHOLD = 70
141
+
142
+
143
+ class HistoryOrchestrator:
144
+ """Orchestrates the `avos history` command.
145
+
146
+ Pipeline: search -> enrich with diffs -> chronology -> sanitize -> budget -> synthesize -> ground -> render/fallback.
147
+ Exit codes: 0=success, 1=precondition, 2=hard external error.
148
+
149
+ Args:
150
+ memory_client: Avos Memory API client.
151
+ llm_client: LLM synthesis client.
152
+ repo_root: Path to the repository root.
153
+ reply_service: Optional reply output service for decorated terminal output.
154
+ github_client: Optional GitHub client for diff enrichment.
155
+ diff_summary_service: Optional service for summarizing diffs via LLM.
156
+ """
157
+
158
+ def __init__(
159
+ self,
160
+ memory_client: AvosMemoryClient,
161
+ llm_client: LLMClient,
162
+ repo_root: Path,
163
+ reply_service: ReplyOutputService | None = None,
164
+ github_client: GitHubClient | None = None,
165
+ diff_summary_service: DiffSummaryService | None = None,
166
+ ) -> None:
167
+ self._memory = memory_client
168
+ self._llm = llm_client
169
+ self._repo_root = repo_root
170
+ self._reply_service = reply_service
171
+ self._github_client = github_client
172
+ self._diff_summary_service = diff_summary_service
173
+ self._chronology = ChronologyService()
174
+ self._sanitizer = SanitizationService()
175
+ self._budget = ContextBudgetService()
176
+ self._citation_validator = CitationValidator()
177
+ self._fallback_formatter = QueryFallbackFormatter()
178
+
179
+ def run(self, repo_slug: str, subject: str, json_output: bool = False) -> int:
180
+ """Execute the history flow.
181
+
182
+ Args:
183
+ repo_slug: Repository identifier in 'org/repo' format.
184
+ subject: Subject/topic for chronological history.
185
+ json_output: If True, emit JSON output instead of human UI.
186
+
187
+ Returns:
188
+ Exit code: 0 (success/fallback), 1 (precondition), 2 (hard error).
189
+ """
190
+ if "/" not in repo_slug:
191
+ if json_output:
192
+ print_json(
193
+ success=False,
194
+ data=None,
195
+ error={
196
+ "code": "REPOSITORY_CONTEXT_ERROR",
197
+ "message": "Invalid repo slug. Expected 'org/repo'.",
198
+ "hint": None,
199
+ "retryable": False,
200
+ },
201
+ )
202
+ else:
203
+ print_error("[REPOSITORY_CONTEXT_ERROR] Invalid repo slug. Expected 'org/repo'.")
204
+ return 1
205
+
206
+ try:
207
+ config = load_config(self._repo_root)
208
+ except ConfigurationNotInitializedError as e:
209
+ if json_output:
210
+ print_json(
211
+ success=False,
212
+ data=None,
213
+ error={
214
+ "code": "CONFIG_NOT_INITIALIZED",
215
+ "message": str(e),
216
+ "hint": "Run 'avos connect org/repo' first.",
217
+ "retryable": False,
218
+ },
219
+ )
220
+ else:
221
+ print_error(f"[CONFIG_NOT_INITIALIZED] {e}")
222
+ return 1
223
+ except AvosError as e:
224
+ if json_output:
225
+ print_json(
226
+ success=False,
227
+ data=None,
228
+ error={
229
+ "code": e.code,
230
+ "message": str(e),
231
+ "hint": getattr(e, "hint", None),
232
+ "retryable": getattr(e, "retryable", False),
233
+ },
234
+ )
235
+ else:
236
+ print_error(f"[{e.code}] {e}")
237
+ return 1
238
+
239
+ memory_id = config.memory_id
240
+
241
+ # Stage 1: Retrieve (hybrid, k=20)
242
+ try:
243
+ search_result = self._memory.search(
244
+ memory_id=memory_id, query=subject, k=_HISTORY_K, mode=_HISTORY_SEARCH_MODE
245
+ )
246
+ except AvosError as e:
247
+ if json_output:
248
+ print_json(
249
+ success=False,
250
+ data=None,
251
+ error={
252
+ "code": e.code,
253
+ "message": f"Memory search failed: {e}",
254
+ "hint": getattr(e, "hint", None),
255
+ "retryable": getattr(e, "retryable", True),
256
+ },
257
+ )
258
+ else:
259
+ print_error(f"[{e.code}] Memory search failed: {e}")
260
+ return 2
261
+
262
+ # Stage 2: Empty check
263
+ if not search_result.results:
264
+ if json_output:
265
+ print_json(
266
+ success=True,
267
+ data={
268
+ "format": "avos.history.v1",
269
+ "raw_text": "",
270
+ "timeline": {
271
+ "is_empty_history": True,
272
+ "months": [],
273
+ "unparsed_timeline_lines": [],
274
+ },
275
+ "summary": {"text": f'No engineering history found for "{subject}".'},
276
+ "parse_warnings": [],
277
+ },
278
+ error=None,
279
+ )
280
+ else:
281
+ print_info(
282
+ "No matching evidence found in repository memory. Try a different subject or ingest more data."
283
+ )
284
+ return 0
285
+
286
+ # Convert to internal model
287
+ artifacts = [
288
+ RetrievedArtifact(
289
+ note_id=hit.note_id,
290
+ content=hit.content,
291
+ created_at=hit.created_at,
292
+ rank=hit.rank,
293
+ )
294
+ for hit in search_result.results
295
+ ]
296
+
297
+ # Stage 2.5: Diff enrichment (graceful skip)
298
+ enriched_artifacts = self._enrich_with_diffs(
299
+ search_result.results, artifacts, repo_slug
300
+ )
301
+ if enriched_artifacts is not None:
302
+ artifacts = enriched_artifacts
303
+
304
+ # Stage 3: Chronological sort
305
+ sorted_artifacts = self._chronology.sort(artifacts)
306
+
307
+ # Stage 4: Sanitize
308
+ sanitization_result = self._sanitizer.sanitize(sorted_artifacts)
309
+
310
+ if sanitization_result.confidence_score < _SANITIZATION_CONFIDENCE_THRESHOLD:
311
+ _log.warning(
312
+ "Sanitization confidence %d below threshold %d",
313
+ sanitization_result.confidence_score,
314
+ _SANITIZATION_CONFIDENCE_THRESHOLD,
315
+ )
316
+ fallback_output = self._fallback_formatter.format_history_fallback(
317
+ sanitization_result.artifacts, FallbackReason.SAFETY_BLOCK
318
+ )
319
+ headline, detail_lines, json_merge = explain_sanitization_gate(
320
+ sanitization_result, _SANITIZATION_CONFIDENCE_THRESHOLD
321
+ )
322
+ if not json_output:
323
+ print_warning(headline)
324
+ for line in detail_lines:
325
+ print_info(line)
326
+ _render_reply_output(
327
+ subject,
328
+ fallback_output,
329
+ self._reply_service,
330
+ json_output,
331
+ json_merge=json_merge,
332
+ )
333
+ return 0
334
+
335
+ # Stage 5: Budget pack
336
+ budget_result = self._budget.pack(sanitization_result.artifacts, mode="history")
337
+
338
+ if budget_result.included_count < _MIN_GROUNDED_CITATIONS:
339
+ fallback_output = self._fallback_formatter.format_history_fallback(
340
+ sanitization_result.artifacts, FallbackReason.BUDGET_EXHAUSTED
341
+ )
342
+ if not json_output:
343
+ print_warning("Insufficient evidence for synthesis.")
344
+ _render_reply_output(subject, fallback_output, self._reply_service, json_output)
345
+ return 0
346
+
347
+ # Stage 6: Synthesize
348
+ try:
349
+ synthesis_request = SynthesisRequest(
350
+ mode=QueryMode.HISTORY,
351
+ query=subject,
352
+ provider=config.llm.provider,
353
+ model=config.llm.model,
354
+ prompt_template_version="history_v1",
355
+ artifacts=budget_result.included,
356
+ )
357
+ synthesis_response = self._llm.synthesize(synthesis_request)
358
+ except LLMSynthesisError as e:
359
+ _log.warning("LLM synthesis failed: %s", e)
360
+ fallback_output = self._fallback_formatter.format_history_fallback(
361
+ sanitization_result.artifacts, FallbackReason.LLM_UNAVAILABLE
362
+ )
363
+ if not json_output:
364
+ print_warning("LLM synthesis unavailable. Showing chronological evidence.")
365
+ _render_reply_output(subject, fallback_output, self._reply_service, json_output)
366
+ return 0
367
+
368
+ # Stage 7: Validate citations
369
+ grounded, dropped, warnings = self._citation_validator.validate(
370
+ synthesis_response.answer_text, budget_result.included
371
+ )
372
+
373
+ if len(grounded) < _MIN_GROUNDED_CITATIONS:
374
+ _log.warning(
375
+ "Grounding failed: %d/%d citations grounded",
376
+ len(grounded),
377
+ len(grounded) + len(dropped),
378
+ )
379
+ fallback_output = self._fallback_formatter.format_history_fallback(
380
+ sanitization_result.artifacts, FallbackReason.GROUNDING_FAILED
381
+ )
382
+ if not json_output:
383
+ print_warning("Citation grounding insufficient. Showing chronological evidence.")
384
+ _render_reply_output(subject, fallback_output, self._reply_service, json_output)
385
+ return 0
386
+
387
+ # Stage 8: Render
388
+ if not json_output:
389
+ for w in warnings:
390
+ print_warning(w)
391
+
392
+ if self._reply_service:
393
+ raw_output = _build_raw_output(budget_result.included)
394
+ _render_reply_output(subject, raw_output, self._reply_service, json_output)
395
+ else:
396
+ if json_output:
397
+ print_json(
398
+ success=False,
399
+ data=None,
400
+ error={
401
+ "code": "REPLY_SERVICE_UNAVAILABLE",
402
+ "message": "JSON output requires REPLY_MODEL configuration",
403
+ "hint": "Set REPLY_MODEL, REPLY_MODEL_URL, REPLY_MODEL_API_KEY environment variables",
404
+ "retryable": False,
405
+ },
406
+ )
407
+ return 0
408
+
409
+ answer_text = synthesis_response.answer_text
410
+ try:
411
+ parsed = json_module.loads(answer_text)
412
+ if isinstance(parsed, dict) and "answer" in parsed:
413
+ answer_text = parsed["answer"]
414
+ except (json_module.JSONDecodeError, TypeError):
415
+ pass
416
+
417
+ render_panel("Timeline", answer_text, style="success")
418
+
419
+ if grounded:
420
+ evidence_rows: list[list[str]] = []
421
+ for cit in grounded:
422
+ note_id_short = (
423
+ cit.note_id[:10] + ".." if len(cit.note_id) > 12 else cit.note_id
424
+ )
425
+ evidence_rows.append([note_id_short, cit.display_label])
426
+ render_table(
427
+ f"Evidence ({len(grounded)} citations)",
428
+ [("Note ID", "dim"), ("Label", "")],
429
+ evidence_rows,
430
+ )
431
+
432
+ return 0
433
+
434
+ def _enrich_with_diffs(
435
+ self,
436
+ hits: list[SearchHit],
437
+ artifacts: list[RetrievedArtifact],
438
+ repo_slug: str,
439
+ ) -> list[RetrievedArtifact] | None:
440
+ """Enrich artifacts with git diff summaries.
441
+
442
+ Extracts PR/commit references from search hits, fetches diffs via GitHub API,
443
+ summarizes them via the diff summary service, and injects summaries into
444
+ artifact content.
445
+
446
+ Args:
447
+ hits: Original search hits from memory API.
448
+ artifacts: Converted RetrievedArtifact list.
449
+ repo_slug: Repository slug for reference resolution.
450
+
451
+ Returns:
452
+ Enriched artifacts list, or None if enrichment should be skipped.
453
+ """
454
+ if self._github_client is None or self._diff_summary_service is None:
455
+ _log.debug("Diff enrichment skipped: missing github_client or diff_summary_service")
456
+ return None
457
+
458
+ try:
459
+ note_refs_list = extract_refs_by_note(hits)
460
+
461
+ all_refs: list[str] = []
462
+ note_id_to_refs: dict[str, list[str]] = {}
463
+ for note_refs in note_refs_list:
464
+ note_id_to_refs[note_refs.note_id] = note_refs.references
465
+ all_refs.extend(note_refs.references)
466
+
467
+ if not all_refs:
468
+ _log.debug("No PR/commit references found in artifacts")
469
+ return None
470
+
471
+ parser = ReferenceParser()
472
+ parsed_refs = parser.parse_all(all_refs, repo_slug)
473
+
474
+ if not parsed_refs:
475
+ _log.debug("No valid references parsed")
476
+ return None
477
+
478
+ resolver = DiffResolver(self._github_client)
479
+ diff_results = resolver.resolve(parsed_refs)
480
+
481
+ resolved_diffs = [r for r in diff_results if r.status == DiffStatus.RESOLVED]
482
+ if not resolved_diffs:
483
+ _log.debug("No diffs resolved successfully")
484
+ return None
485
+
486
+ summaries = self._diff_summary_service.summarize_diffs(resolved_diffs)
487
+ if not summaries:
488
+ _log.debug("No diff summaries generated")
489
+ return None
490
+
491
+ canonical_to_summary: dict[str, str] = summaries
492
+
493
+ enriched: list[RetrievedArtifact] = []
494
+ for artifact in artifacts:
495
+ refs_for_note = note_id_to_refs.get(artifact.note_id, [])
496
+ summary_parts: list[str] = []
497
+
498
+ for ref_str in refs_for_note:
499
+ parsed = parser.parse(ref_str, repo_slug)
500
+ if parsed is None:
501
+ continue
502
+ for canonical_id, summary in canonical_to_summary.items():
503
+ if self._ref_matches_canonical(parsed, canonical_id):
504
+ summary_parts.append(summary)
505
+ break
506
+
507
+ if summary_parts:
508
+ combined_summary = "\n\n".join(summary_parts)
509
+ new_content = (
510
+ f"{artifact.content}\n\n--- Diff Summary ---\n{combined_summary}"
511
+ )
512
+ enriched.append(
513
+ RetrievedArtifact(
514
+ note_id=artifact.note_id,
515
+ content=new_content,
516
+ created_at=artifact.created_at,
517
+ rank=artifact.rank,
518
+ )
519
+ )
520
+ else:
521
+ enriched.append(artifact)
522
+
523
+ return enriched
524
+
525
+ except Exception as e:
526
+ _log.warning("Diff enrichment failed: %s", e)
527
+ return None
528
+
529
+ def _ref_matches_canonical(self, parsed: object, canonical_id: str) -> bool:
530
+ """Check if a parsed reference matches a canonical ID.
531
+
532
+ Args:
533
+ parsed: ParsedReference object.
534
+ canonical_id: Canonical ID like 'PR #123' or full SHA.
535
+
536
+ Returns:
537
+ True if the reference matches.
538
+ """
539
+ from avos_cli.models.diff import DiffReferenceType, ParsedReference
540
+
541
+ if not isinstance(parsed, ParsedReference):
542
+ return False
543
+
544
+ if parsed.reference_type == DiffReferenceType.PR:
545
+ return canonical_id == f"PR #{parsed.raw_id}"
546
+ else:
547
+ return canonical_id.startswith(parsed.raw_id) or parsed.raw_id.startswith(
548
+ canonical_id[:7]
549
+ )