cherry-docs 0.2.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.
- app/__init__.py +0 -0
- app/repo_scope.py +24 -0
- app/services/__init__.py +0 -0
- app/services/agent_protocol.py +59 -0
- app/services/auto_promote_sessions.py +245 -0
- app/services/capture_adapters.py +89 -0
- app/services/capture_core.py +164 -0
- app/services/internal_memory_agent.py +214 -0
- app/services/memory_evidence.py +89 -0
- app/services/memory_extraction_normalize.py +134 -0
- app/services/memory_lifecycle.py +258 -0
- app/services/memory_profiles.py +88 -0
- app/services/memory_providers.py +113 -0
- app/services/memory_retrieval.py +327 -0
- app/services/memory_retrieval_scoring.py +106 -0
- app/services/memory_retrieval_text.py +113 -0
- app/services/memory_similarity.py +135 -0
- app/services/privacy.py +72 -0
- app/services/promoted_memory_answer.py +157 -0
- app/services/promoted_memory_pipeline.py +194 -0
- app/services/promoted_memory_store.py +57 -0
- cherry_docs-0.2.0.dist-info/METADATA +143 -0
- cherry_docs-0.2.0.dist-info/RECORD +42 -0
- cherry_docs-0.2.0.dist-info/WHEEL +5 -0
- cherry_docs-0.2.0.dist-info/entry_points.txt +4 -0
- cherry_docs-0.2.0.dist-info/top_level.txt +3 -0
- cherrydocs/__init__.py +3 -0
- cherrydocs/cli.py +213 -0
- cherrydocs/hook.py +27 -0
- cherrydocs/mcp.py +22 -0
- scripts/__init__.py +0 -0
- scripts/auto_promote_capture.py +63 -0
- scripts/check_size_limits.py +115 -0
- scripts/ci_auto_capture.py +289 -0
- scripts/claude_hooks/__init__.py +0 -0
- scripts/claude_hooks/state_manager.py +526 -0
- scripts/coverage_regression_gate.py +121 -0
- scripts/eval_projects.py +247 -0
- scripts/install.py +212 -0
- scripts/pr_gate_report.py +282 -0
- scripts/promptfoo_regression_gate.py +176 -0
- scripts/render_agent_prompts.py +57 -0
app/__init__.py
ADDED
|
File without changes
|
app/repo_scope.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def normalize_project_id(project_id: Optional[str], default: str = "default-project") -> str:
|
|
5
|
+
"""
|
|
6
|
+
Normalize repository/project identifiers to the internal dashed format.
|
|
7
|
+
Examples:
|
|
8
|
+
- github.com/owner/repo -> owner-repo
|
|
9
|
+
- https://github.com/owner/repo.git -> owner-repo
|
|
10
|
+
- git@github.com:owner/repo.git -> owner-repo
|
|
11
|
+
"""
|
|
12
|
+
if not project_id:
|
|
13
|
+
return default
|
|
14
|
+
|
|
15
|
+
normalized = project_id.strip()
|
|
16
|
+
if not normalized:
|
|
17
|
+
return default
|
|
18
|
+
|
|
19
|
+
normalized = normalized.replace("https://", "").replace("http://", "")
|
|
20
|
+
normalized = normalized.replace("git@github.com:", "").replace("github.com/", "")
|
|
21
|
+
normalized = normalized.removesuffix(".git")
|
|
22
|
+
normalized = normalized.strip("/")
|
|
23
|
+
normalized = normalized.replace("/", "-")
|
|
24
|
+
return normalized.lower() or default
|
app/services/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Canonical agent protocol rendering for all supported client rule files."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import tomllib
|
|
5
|
+
from hashlib import sha256
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
ROOT_DIR = Path(__file__).resolve().parents[2]
|
|
9
|
+
PROTOCOL_PATH = ROOT_DIR / "docs" / "agent_protocol.toml"
|
|
10
|
+
PROTOCOL_SOURCE = "docs/agent_protocol.toml"
|
|
11
|
+
PROMPT_OUTPUTS = {
|
|
12
|
+
".claude/CLAUDE.md": "claude",
|
|
13
|
+
"AGENTS.md": "agents",
|
|
14
|
+
"GEMINI.md": "gemini",
|
|
15
|
+
".cursorrules": "cursorrules",
|
|
16
|
+
".cursor/rules/cherrydocs.mdc": "cursor_mdc",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _load_protocol() -> tuple[dict, str]:
|
|
21
|
+
raw = PROTOCOL_PATH.read_text(encoding="utf-8")
|
|
22
|
+
return tomllib.loads(raw), raw
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _protocol_metadata(protocol: dict, raw: str) -> dict[str, str]:
|
|
26
|
+
return {
|
|
27
|
+
"source": PROTOCOL_SOURCE,
|
|
28
|
+
"version": str(protocol["meta"]["version"]),
|
|
29
|
+
"hash": sha256(raw.encode("utf-8")).hexdigest()[:12],
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _generated_comment(version: str, protocol_hash: str) -> str:
|
|
34
|
+
return f"<!-- Generated from {PROTOCOL_SOURCE} version={version} hash={protocol_hash}; do not edit by hand. -->"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _render_body(protocol: dict, version: str, protocol_hash: str) -> str:
|
|
38
|
+
meta = protocol["meta"]
|
|
39
|
+
shared = protocol["shared"]
|
|
40
|
+
lines = [_generated_comment(version, protocol_hash), meta["title"], ""]
|
|
41
|
+
lines.extend(f"- {bullet}" for bullet in shared["bullets"])
|
|
42
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def render_platform_prompt(platform: str) -> str:
|
|
46
|
+
protocol, raw = _load_protocol()
|
|
47
|
+
meta = _protocol_metadata(protocol, raw)
|
|
48
|
+
body = _render_body(protocol, meta["version"], meta["hash"])
|
|
49
|
+
if platform == "claude":
|
|
50
|
+
return body
|
|
51
|
+
if platform in {"agents", "gemini", "cursorrules"}:
|
|
52
|
+
return f"# Project Rules\n\n{body}"
|
|
53
|
+
if platform == "cursor_mdc":
|
|
54
|
+
return "---\ndescription: CherryDocs project protocol\nalwaysApply: true\n---\n\n" + body
|
|
55
|
+
raise ValueError(f"Unsupported platform: {platform}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_prompt_file_map() -> dict[str, str]:
|
|
59
|
+
return {path: render_platform_prompt(platform) for path, platform in PROMPT_OUTPUTS.items()}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Background-friendly auto-promotion for captured AI sessions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
11
|
+
|
|
12
|
+
from app.repo_scope import normalize_project_id
|
|
13
|
+
from app.services.capture_core import LocalCaptureBuffer
|
|
14
|
+
from app.services.internal_memory_agent import MemoryModelProvider
|
|
15
|
+
from app.services.memory_providers import resolve_provider
|
|
16
|
+
from app.services.promoted_memory_pipeline import run_session_promotion
|
|
17
|
+
from app.services.promoted_memory_store import DEFAULT_PROMOTED_ROOT, LocalPromotedMemoryStore
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AutoPromotionPolicy(BaseModel):
|
|
21
|
+
model_config = ConfigDict(extra="ignore")
|
|
22
|
+
|
|
23
|
+
min_event_count: int = 3
|
|
24
|
+
min_candidate_confidence: float = 0.8
|
|
25
|
+
max_sessions: int = 10
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AutoPromotionState(BaseModel):
|
|
29
|
+
model_config = ConfigDict(extra="ignore")
|
|
30
|
+
|
|
31
|
+
session_id: str
|
|
32
|
+
project_id: str
|
|
33
|
+
signature: str
|
|
34
|
+
event_count: int
|
|
35
|
+
last_event_timestamp: str | None = None
|
|
36
|
+
last_promoted_at: str = Field(default_factory=lambda: datetime.now(UTC).isoformat())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AutoPromotionSessionResult(BaseModel):
|
|
40
|
+
model_config = ConfigDict(extra="ignore")
|
|
41
|
+
|
|
42
|
+
session_id: str
|
|
43
|
+
action: str
|
|
44
|
+
reason: str = ""
|
|
45
|
+
promoted_count: int = 0
|
|
46
|
+
highlights: list[str] = Field(default_factory=list)
|
|
47
|
+
distillation_trace: dict[str, object] | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AutoPromotionRunReport(BaseModel):
|
|
51
|
+
model_config = ConfigDict(extra="ignore")
|
|
52
|
+
|
|
53
|
+
project_id: str
|
|
54
|
+
processed: list[AutoPromotionSessionResult] = Field(default_factory=list)
|
|
55
|
+
skipped: list[AutoPromotionSessionResult] = Field(default_factory=list)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _state_dir(buffer_dir: str | Path) -> Path:
|
|
59
|
+
return Path(buffer_dir).expanduser().resolve() / ".promotion-state"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _state_path(buffer_dir: str | Path, session_id: str) -> Path:
|
|
63
|
+
safe = session_id.replace("/", "_").replace("\\", "_")
|
|
64
|
+
return _state_dir(buffer_dir) / f"{safe}.json"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _load_state(buffer_dir: str | Path, session_id: str) -> AutoPromotionState | None:
|
|
68
|
+
path = _state_path(buffer_dir, session_id)
|
|
69
|
+
if not path.exists():
|
|
70
|
+
return None
|
|
71
|
+
try:
|
|
72
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
73
|
+
return AutoPromotionState.model_validate(payload)
|
|
74
|
+
except Exception:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _save_state(buffer_dir: str | Path, state: AutoPromotionState) -> None:
|
|
79
|
+
path = _state_path(buffer_dir, state.session_id)
|
|
80
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
path.write_text(json.dumps(state.model_dump(mode="json"), indent=2), encoding="utf-8")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def list_capture_sessions(buffer_dir: str | Path) -> list[str]:
|
|
85
|
+
root = Path(buffer_dir).expanduser().resolve()
|
|
86
|
+
if not root.exists():
|
|
87
|
+
return []
|
|
88
|
+
return [
|
|
89
|
+
path.stem
|
|
90
|
+
for path in sorted(root.glob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _session_signature(events: list[dict]) -> str:
|
|
95
|
+
relevant = [
|
|
96
|
+
{
|
|
97
|
+
"event_type": event.get("event_type"),
|
|
98
|
+
"timestamp": event.get("timestamp"),
|
|
99
|
+
"text": str(event.get("text") or "")[:400],
|
|
100
|
+
"command": event.get("command"),
|
|
101
|
+
"exit_code": event.get("exit_code"),
|
|
102
|
+
}
|
|
103
|
+
for event in events
|
|
104
|
+
]
|
|
105
|
+
payload = json.dumps(relevant, sort_keys=True, ensure_ascii=False)
|
|
106
|
+
return hashlib.sha1(payload.encode("utf-8"), usedforsecurity=False).hexdigest()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _new_state(project_id: str, session_id: str, events: list[dict]) -> AutoPromotionState:
|
|
110
|
+
timestamps = [
|
|
111
|
+
str(event.get("timestamp") or "").strip()
|
|
112
|
+
for event in events
|
|
113
|
+
if str(event.get("timestamp") or "").strip()
|
|
114
|
+
]
|
|
115
|
+
return AutoPromotionState(
|
|
116
|
+
session_id=session_id,
|
|
117
|
+
project_id=project_id,
|
|
118
|
+
signature=_session_signature(events),
|
|
119
|
+
event_count=len(events),
|
|
120
|
+
last_event_timestamp=timestamps[-1] if timestamps else None,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _session_matches_scope(events: list[dict], *, project_id: str, branch: str | None) -> bool:
|
|
125
|
+
if not events:
|
|
126
|
+
return False
|
|
127
|
+
normalized_project_id = normalize_project_id(project_id)
|
|
128
|
+
repos = {
|
|
129
|
+
normalize_project_id(str(event.get("repo") or ""))
|
|
130
|
+
for event in events
|
|
131
|
+
if str(event.get("repo") or "").strip()
|
|
132
|
+
}
|
|
133
|
+
if repos:
|
|
134
|
+
if normalized_project_id not in repos:
|
|
135
|
+
return False
|
|
136
|
+
else:
|
|
137
|
+
# No repo field — fall back to cwd directory name match
|
|
138
|
+
cwds = {
|
|
139
|
+
normalize_project_id(Path(str(event.get("cwd") or "")).name)
|
|
140
|
+
for event in events
|
|
141
|
+
if str(event.get("cwd") or "").strip()
|
|
142
|
+
}
|
|
143
|
+
if cwds and normalized_project_id not in cwds:
|
|
144
|
+
return False
|
|
145
|
+
if branch:
|
|
146
|
+
branches = {
|
|
147
|
+
str(event.get("branch") or "").strip()
|
|
148
|
+
for event in events
|
|
149
|
+
if str(event.get("branch") or "").strip()
|
|
150
|
+
}
|
|
151
|
+
if branches and branch not in branches:
|
|
152
|
+
return False
|
|
153
|
+
return True
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def auto_promote_captured_sessions(
|
|
157
|
+
*,
|
|
158
|
+
project_id: str,
|
|
159
|
+
buffer_dir: str | Path = ".cherrydocs/capture",
|
|
160
|
+
promoted_root: str | Path = DEFAULT_PROMOTED_ROOT,
|
|
161
|
+
provider: MemoryModelProvider | None = None,
|
|
162
|
+
project_hint: str | None = None,
|
|
163
|
+
branch: str | None = None,
|
|
164
|
+
commit: str | None = None,
|
|
165
|
+
policy: AutoPromotionPolicy | None = None,
|
|
166
|
+
memory_profile: str | None = None,
|
|
167
|
+
) -> AutoPromotionRunReport:
|
|
168
|
+
resolved_policy = policy or AutoPromotionPolicy()
|
|
169
|
+
resolved_provider = provider or resolve_provider()
|
|
170
|
+
buffer = LocalCaptureBuffer(buffer_dir)
|
|
171
|
+
store = LocalPromotedMemoryStore(promoted_root)
|
|
172
|
+
sessions = list_capture_sessions(buffer_dir)[: resolved_policy.max_sessions]
|
|
173
|
+
|
|
174
|
+
existing_records = [
|
|
175
|
+
r for r in store.load_records(project_id)
|
|
176
|
+
if not branch or not r.branch or r.branch == branch
|
|
177
|
+
]
|
|
178
|
+
processed: list[AutoPromotionSessionResult] = []
|
|
179
|
+
skipped: list[AutoPromotionSessionResult] = []
|
|
180
|
+
|
|
181
|
+
for session_id in sessions:
|
|
182
|
+
events = buffer.read(session_id)
|
|
183
|
+
if not _session_matches_scope(events, project_id=project_id, branch=branch):
|
|
184
|
+
skipped.append(AutoPromotionSessionResult(
|
|
185
|
+
session_id=session_id, action="skip",
|
|
186
|
+
reason="session outside requested project/branch scope",
|
|
187
|
+
))
|
|
188
|
+
continue
|
|
189
|
+
if len(events) < resolved_policy.min_event_count:
|
|
190
|
+
skipped.append(AutoPromotionSessionResult(
|
|
191
|
+
session_id=session_id, action="skip",
|
|
192
|
+
reason=f"too few events ({len(events)} < {resolved_policy.min_event_count})",
|
|
193
|
+
))
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
state = _load_state(buffer_dir, session_id)
|
|
197
|
+
current_state = _new_state(project_id, session_id, events)
|
|
198
|
+
if state and state.project_id == project_id and state.signature == current_state.signature:
|
|
199
|
+
skipped.append(AutoPromotionSessionResult(
|
|
200
|
+
session_id=session_id, action="skip",
|
|
201
|
+
reason="no new captured evidence since last promotion",
|
|
202
|
+
))
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
report = run_session_promotion(
|
|
206
|
+
events=events,
|
|
207
|
+
session_id=session_id,
|
|
208
|
+
project_id=project_id,
|
|
209
|
+
provider=resolved_provider,
|
|
210
|
+
project_hint=project_hint,
|
|
211
|
+
branch=branch,
|
|
212
|
+
commit=commit,
|
|
213
|
+
existing_records=existing_records,
|
|
214
|
+
min_confidence=resolved_policy.min_candidate_confidence,
|
|
215
|
+
memory_profile=memory_profile,
|
|
216
|
+
)
|
|
217
|
+
session_records = [r for r in report.session_records if r.memory_type != "noise"]
|
|
218
|
+
if not session_records:
|
|
219
|
+
skipped.append(AutoPromotionSessionResult(
|
|
220
|
+
session_id=session_id, action="skip",
|
|
221
|
+
reason="no high-confidence durable memory candidates",
|
|
222
|
+
))
|
|
223
|
+
_save_state(buffer_dir, current_state)
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
existing_records = store.upsert_records(project_id, report.promotion.records)
|
|
227
|
+
_save_state(buffer_dir, current_state)
|
|
228
|
+
processed.append(AutoPromotionSessionResult(
|
|
229
|
+
session_id=session_id,
|
|
230
|
+
action="promote",
|
|
231
|
+
promoted_count=len(session_records),
|
|
232
|
+
highlights=[r.summary for r in session_records[:3]],
|
|
233
|
+
distillation_trace=report.distillation_trace.model_dump(mode="json"),
|
|
234
|
+
))
|
|
235
|
+
|
|
236
|
+
return AutoPromotionRunReport(project_id=project_id, processed=processed, skipped=skipped)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
__all__ = [
|
|
240
|
+
"AutoPromotionPolicy",
|
|
241
|
+
"AutoPromotionRunReport",
|
|
242
|
+
"AutoPromotionSessionResult",
|
|
243
|
+
"auto_promote_captured_sessions",
|
|
244
|
+
"list_capture_sessions",
|
|
245
|
+
]
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Shared append helpers for capture integrations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from app.services.capture_core import (
|
|
9
|
+
CaptureEvent,
|
|
10
|
+
CaptureEventType,
|
|
11
|
+
LocalCaptureBuffer,
|
|
12
|
+
build_capture_event,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
_TEST_COMMAND_MARKERS = (
|
|
16
|
+
"pytest",
|
|
17
|
+
"pnpm test",
|
|
18
|
+
"npm test",
|
|
19
|
+
"yarn test",
|
|
20
|
+
"bun test",
|
|
21
|
+
"vitest",
|
|
22
|
+
"jest",
|
|
23
|
+
"go test",
|
|
24
|
+
"cargo test",
|
|
25
|
+
"mix test",
|
|
26
|
+
"rspec",
|
|
27
|
+
"phpunit",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def infer_capture_event_type(*, tool_name: str | None = None, command: str | None = None) -> CaptureEventType:
|
|
32
|
+
normalized_command = " ".join(str(command or "").split()).lower()
|
|
33
|
+
if normalized_command and any(marker in normalized_command for marker in _TEST_COMMAND_MARKERS):
|
|
34
|
+
return CaptureEventType.TEST_RESULT
|
|
35
|
+
if str(tool_name or "").strip() == "Bash":
|
|
36
|
+
return CaptureEventType.SHELL_RESULT
|
|
37
|
+
return CaptureEventType.TOOL_RESULT
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def enrich_capture_metadata(
|
|
41
|
+
*,
|
|
42
|
+
event_type: CaptureEventType,
|
|
43
|
+
command: str | None = None,
|
|
44
|
+
exit_code: int | None = None,
|
|
45
|
+
metadata: dict[str, Any] | None = None,
|
|
46
|
+
) -> dict[str, Any]:
|
|
47
|
+
enriched = dict(metadata or {})
|
|
48
|
+
if event_type == CaptureEventType.TEST_RESULT:
|
|
49
|
+
enriched.setdefault("capture_kind", "verification")
|
|
50
|
+
enriched.setdefault("verification_kind", "test")
|
|
51
|
+
if exit_code is not None:
|
|
52
|
+
enriched.setdefault("verification_status", "passed" if exit_code == 0 else "failed")
|
|
53
|
+
elif event_type == CaptureEventType.SHELL_RESULT and command:
|
|
54
|
+
enriched.setdefault("capture_kind", "command")
|
|
55
|
+
return enriched
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def append_capture_event(
|
|
59
|
+
*,
|
|
60
|
+
buffer_dir: str | Path,
|
|
61
|
+
source: str,
|
|
62
|
+
event_type: CaptureEventType,
|
|
63
|
+
session_id: str,
|
|
64
|
+
cwd: str,
|
|
65
|
+
text: str | None = None,
|
|
66
|
+
files: list[str] | None = None,
|
|
67
|
+
command: str | None = None,
|
|
68
|
+
exit_code: int | None = None,
|
|
69
|
+
metadata: dict[str, Any] | None = None,
|
|
70
|
+
) -> CaptureEvent:
|
|
71
|
+
merged_metadata = enrich_capture_metadata(
|
|
72
|
+
event_type=event_type,
|
|
73
|
+
command=command,
|
|
74
|
+
exit_code=exit_code,
|
|
75
|
+
metadata=metadata,
|
|
76
|
+
)
|
|
77
|
+
event = build_capture_event(
|
|
78
|
+
source=source,
|
|
79
|
+
event_type=event_type,
|
|
80
|
+
session_id=session_id,
|
|
81
|
+
cwd=cwd,
|
|
82
|
+
text=text,
|
|
83
|
+
files=files,
|
|
84
|
+
command=command,
|
|
85
|
+
exit_code=exit_code,
|
|
86
|
+
metadata=merged_metadata,
|
|
87
|
+
)
|
|
88
|
+
LocalCaptureBuffer(buffer_dir).append(event)
|
|
89
|
+
return event
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Shared capture-core primitives for CLI and hook-based adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, List
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
13
|
+
|
|
14
|
+
from app.repo_scope import normalize_project_id
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CaptureEventType(StrEnum):
|
|
18
|
+
SESSION_START = "session_start"
|
|
19
|
+
SESSION_END = "session_end"
|
|
20
|
+
USER_PROMPT = "user_prompt"
|
|
21
|
+
ASSISTANT_OUTPUT = "assistant_output"
|
|
22
|
+
TOOL_RESULT = "tool_result"
|
|
23
|
+
SHELL_RESULT = "shell_result"
|
|
24
|
+
TEST_RESULT = "test_result"
|
|
25
|
+
REMEMBER = "remember"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CaptureEvent(BaseModel):
|
|
29
|
+
"""Normalized event emitted by any capture adapter."""
|
|
30
|
+
|
|
31
|
+
model_config = ConfigDict(use_enum_values=True)
|
|
32
|
+
|
|
33
|
+
source: str
|
|
34
|
+
session_id: str = "unknown-session"
|
|
35
|
+
event_type: CaptureEventType
|
|
36
|
+
timestamp: str
|
|
37
|
+
cwd: str
|
|
38
|
+
repo: str | None = None
|
|
39
|
+
branch: str | None = None
|
|
40
|
+
text: str | None = None
|
|
41
|
+
files: List[str] = Field(default_factory=list)
|
|
42
|
+
command: str | None = None
|
|
43
|
+
exit_code: int | None = None
|
|
44
|
+
commit: str | None = None
|
|
45
|
+
metadata: Dict[str, Any] = Field(default_factory=dict)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def now_iso() -> str:
|
|
49
|
+
return datetime.now(UTC).isoformat()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _run_git(args: List[str], cwd: str) -> str | None:
|
|
53
|
+
try:
|
|
54
|
+
proc = subprocess.run(
|
|
55
|
+
["git", *args],
|
|
56
|
+
cwd=cwd,
|
|
57
|
+
capture_output=True,
|
|
58
|
+
text=True,
|
|
59
|
+
check=True,
|
|
60
|
+
timeout=2,
|
|
61
|
+
)
|
|
62
|
+
except (OSError, subprocess.SubprocessError):
|
|
63
|
+
return None
|
|
64
|
+
return proc.stdout.strip() or None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def capture_repo_context(cwd: str | None = None) -> Dict[str, Any]:
|
|
68
|
+
"""Best-effort git context for a local capture event."""
|
|
69
|
+
|
|
70
|
+
resolved_cwd = str(Path(cwd or ".").resolve())
|
|
71
|
+
repo_root = _run_git(["rev-parse", "--show-toplevel"], resolved_cwd)
|
|
72
|
+
if not repo_root:
|
|
73
|
+
return {
|
|
74
|
+
"cwd": resolved_cwd,
|
|
75
|
+
"repo": None,
|
|
76
|
+
"branch": None,
|
|
77
|
+
"commit": None,
|
|
78
|
+
"files": [],
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
branch = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], repo_root)
|
|
82
|
+
commit = _run_git(["rev-parse", "HEAD"], repo_root)
|
|
83
|
+
remote = _run_git(["remote", "get-url", "origin"], repo_root) or _run_git(
|
|
84
|
+
["config", "--get", "remote.origin.url"],
|
|
85
|
+
repo_root,
|
|
86
|
+
)
|
|
87
|
+
changed = _run_git(["status", "--short"], repo_root) or ""
|
|
88
|
+
files: List[str] = []
|
|
89
|
+
for line in changed.splitlines():
|
|
90
|
+
candidate = line[3:].strip() if len(line) >= 4 else line.strip()
|
|
91
|
+
if candidate:
|
|
92
|
+
files.append(candidate)
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
"cwd": resolved_cwd,
|
|
96
|
+
"repo": normalize_project_id(remote or Path(repo_root).name),
|
|
97
|
+
"branch": branch,
|
|
98
|
+
"commit": commit,
|
|
99
|
+
"files": files,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def build_capture_event(
|
|
104
|
+
*,
|
|
105
|
+
source: str,
|
|
106
|
+
event_type: CaptureEventType,
|
|
107
|
+
session_id: str | None = None,
|
|
108
|
+
cwd: str | None = None,
|
|
109
|
+
text: str | None = None,
|
|
110
|
+
files: List[str] | None = None,
|
|
111
|
+
command: str | None = None,
|
|
112
|
+
exit_code: int | None = None,
|
|
113
|
+
metadata: Dict[str, Any] | None = None,
|
|
114
|
+
) -> CaptureEvent:
|
|
115
|
+
"""Create a normalized capture event with best-effort repo context."""
|
|
116
|
+
|
|
117
|
+
repo_context = capture_repo_context(cwd)
|
|
118
|
+
merged_files = list(files or repo_context.get("files") or [])
|
|
119
|
+
return CaptureEvent(
|
|
120
|
+
source=source,
|
|
121
|
+
session_id=(session_id or "unknown-session"),
|
|
122
|
+
event_type=event_type,
|
|
123
|
+
timestamp=now_iso(),
|
|
124
|
+
cwd=repo_context["cwd"],
|
|
125
|
+
repo=repo_context.get("repo"),
|
|
126
|
+
branch=repo_context.get("branch"),
|
|
127
|
+
text=text,
|
|
128
|
+
files=merged_files,
|
|
129
|
+
command=command,
|
|
130
|
+
exit_code=exit_code,
|
|
131
|
+
commit=repo_context.get("commit"),
|
|
132
|
+
metadata=dict(metadata or {}),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class LocalCaptureBuffer:
|
|
137
|
+
"""Simple JSONL-backed local event buffer for the capture POC."""
|
|
138
|
+
|
|
139
|
+
def __init__(self, root: str | Path):
|
|
140
|
+
self.root = Path(root)
|
|
141
|
+
|
|
142
|
+
def path_for(self, session_id: str) -> Path:
|
|
143
|
+
safe = (session_id or "unknown-session").replace("/", "_").replace("\\", "_")
|
|
144
|
+
return self.root / f"{safe}.jsonl"
|
|
145
|
+
|
|
146
|
+
def append(self, event: CaptureEvent) -> Path:
|
|
147
|
+
path = self.path_for(event.session_id)
|
|
148
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
150
|
+
handle.write(json.dumps(event.model_dump(mode="json"), ensure_ascii=False))
|
|
151
|
+
handle.write("\n")
|
|
152
|
+
return path
|
|
153
|
+
|
|
154
|
+
def read(self, session_id: str) -> List[Dict[str, Any]]:
|
|
155
|
+
path = self.path_for(session_id)
|
|
156
|
+
if not path.exists():
|
|
157
|
+
return []
|
|
158
|
+
rows: List[Dict[str, Any]] = []
|
|
159
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
160
|
+
line = line.strip()
|
|
161
|
+
if not line:
|
|
162
|
+
continue
|
|
163
|
+
rows.append(json.loads(line))
|
|
164
|
+
return rows
|