vardoger 0.1.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.
- vardoger/__init__.py +5 -0
- vardoger/__main__.py +7 -0
- vardoger/analyze.py +50 -0
- vardoger/checkpoint.py +236 -0
- vardoger/cli.py +792 -0
- vardoger/digest.py +56 -0
- vardoger/feedback.py +162 -0
- vardoger/history/__init__.py +16 -0
- vardoger/history/claude_code.py +190 -0
- vardoger/history/codex.py +128 -0
- vardoger/history/cursor.py +128 -0
- vardoger/history/models.py +69 -0
- vardoger/history/openclaw.py +122 -0
- vardoger/mcp_server.py +352 -0
- vardoger/models.py +263 -0
- vardoger/personalization.py +134 -0
- vardoger/prompts/__init__.py +62 -0
- vardoger/prompts/analyze_skill_body.md +107 -0
- vardoger/prompts/feedback_context.md +30 -0
- vardoger/prompts/summarize.md +62 -0
- vardoger/prompts/synthesize.md +52 -0
- vardoger/quality.py +317 -0
- vardoger/setup.py +231 -0
- vardoger/staleness.py +125 -0
- vardoger/writers/__init__.py +13 -0
- vardoger/writers/claude_code.py +68 -0
- vardoger/writers/codex.py +119 -0
- vardoger/writers/cursor.py +67 -0
- vardoger/writers/openclaw.py +85 -0
- vardoger-0.1.0.dist-info/METADATA +182 -0
- vardoger-0.1.0.dist-info/RECORD +34 -0
- vardoger-0.1.0.dist-info/WHEEL +4 -0
- vardoger-0.1.0.dist-info/entry_points.txt +2 -0
- vardoger-0.1.0.dist-info/licenses/LICENSE +190 -0
vardoger/__init__.py
ADDED
vardoger/__main__.py
ADDED
vardoger/analyze.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Copyright 2026 David Strupl
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Placeholder analysis step.
|
|
4
|
+
|
|
5
|
+
In Phase 2 this will use AI to extract behavioral patterns from conversation
|
|
6
|
+
history. For now it produces a stub prompt addition with real statistics to
|
|
7
|
+
prove the pipeline works end-to-end.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
|
|
14
|
+
from vardoger.history.models import Conversation
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def analyze(conversations: list[Conversation]) -> str:
|
|
18
|
+
"""Analyze conversations and return a system prompt addition.
|
|
19
|
+
|
|
20
|
+
Currently a placeholder that reports statistics. The actual AI-powered
|
|
21
|
+
analysis will be implemented in Phase 2.
|
|
22
|
+
"""
|
|
23
|
+
total_messages = sum(c.message_count for c in conversations)
|
|
24
|
+
user_messages = sum(c.user_message_count for c in conversations)
|
|
25
|
+
assistant_messages = sum(c.assistant_message_count for c in conversations)
|
|
26
|
+
|
|
27
|
+
projects = {c.project for c in conversations if c.project}
|
|
28
|
+
|
|
29
|
+
now = datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC")
|
|
30
|
+
|
|
31
|
+
lines = [
|
|
32
|
+
"# vardoger personalization",
|
|
33
|
+
"",
|
|
34
|
+
f"Generated by vardoger v0.1.0 on {now}.",
|
|
35
|
+
"",
|
|
36
|
+
"This is a placeholder. In a future version, this section will contain",
|
|
37
|
+
"personalized instructions derived from your conversation history.",
|
|
38
|
+
"",
|
|
39
|
+
"## Statistics",
|
|
40
|
+
"",
|
|
41
|
+
f"- Conversations analyzed: {len(conversations)}",
|
|
42
|
+
f"- Total messages: {total_messages}",
|
|
43
|
+
f" - User messages: {user_messages}",
|
|
44
|
+
f" - Assistant messages: {assistant_messages}",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
if projects:
|
|
48
|
+
lines.append(f"- Projects seen: {len(projects)}")
|
|
49
|
+
|
|
50
|
+
return "\n".join(lines) + "\n"
|
vardoger/checkpoint.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# Copyright 2026 David Strupl
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Checkpoint store for incremental conversation processing.
|
|
4
|
+
|
|
5
|
+
Tracks which conversation files have already been analyzed by storing
|
|
6
|
+
a SHA-256 content hash per file. On subsequent runs, files whose hash
|
|
7
|
+
hasn't changed are skipped.
|
|
8
|
+
|
|
9
|
+
Also records per-platform generation metadata (timestamp, conversation
|
|
10
|
+
count, output path, output content + hash, confidence metadata) and
|
|
11
|
+
user feedback (accept / reject / edit events) so staleness detection,
|
|
12
|
+
feedback diffing, and the reject-revert flow can all work off the same
|
|
13
|
+
state file.
|
|
14
|
+
|
|
15
|
+
Schema versions:
|
|
16
|
+
v1 — checkpoints only
|
|
17
|
+
v2 — adds ``generations`` as a single ``GenerationRecord`` per platform
|
|
18
|
+
v3 — promotes ``generations`` to a list (history), adds ``feedback``,
|
|
19
|
+
and extends ``GenerationRecord`` with ``output_hash`` / ``content``
|
|
20
|
+
/ ``confidence`` / ``min_confidence_written``.
|
|
21
|
+
|
|
22
|
+
State is persisted to ``~/.vardoger/state.json``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import hashlib
|
|
28
|
+
import json
|
|
29
|
+
import logging
|
|
30
|
+
from datetime import UTC, datetime
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
from pydantic import ValidationError
|
|
34
|
+
|
|
35
|
+
from vardoger.models import (
|
|
36
|
+
CheckpointState,
|
|
37
|
+
ConfidenceLevel,
|
|
38
|
+
FeedbackEvent,
|
|
39
|
+
FeedbackRecord,
|
|
40
|
+
FileCheckpoint,
|
|
41
|
+
GenerationRecord,
|
|
42
|
+
RuleConfidence,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
DEFAULT_STATE_DIR = Path.home() / ".vardoger"
|
|
48
|
+
STATE_VERSION = 3
|
|
49
|
+
HASH_ALGORITHM = "sha256"
|
|
50
|
+
READ_CHUNK_SIZE = 65536
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def file_hash(path: Path) -> str:
|
|
54
|
+
"""Compute the SHA-256 hex digest of a file's contents."""
|
|
55
|
+
h = hashlib.new(HASH_ALGORITHM)
|
|
56
|
+
with open(path, "rb") as f:
|
|
57
|
+
while chunk := f.read(READ_CHUNK_SIZE):
|
|
58
|
+
h.update(chunk)
|
|
59
|
+
return h.hexdigest()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def content_hash(text: str) -> str:
|
|
63
|
+
"""Compute the SHA-256 hex digest of a string."""
|
|
64
|
+
return hashlib.new(HASH_ALGORITHM, text.encode("utf-8")).hexdigest()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class CheckpointStore:
|
|
68
|
+
"""Manages per-platform checkpoint state for incremental processing."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, state_dir: Path | None = None) -> None:
|
|
71
|
+
self._state_dir = state_dir or DEFAULT_STATE_DIR
|
|
72
|
+
self._state_path = self._state_dir / "state.json"
|
|
73
|
+
self._state: CheckpointState = self._load()
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def _migrate(data: dict) -> dict:
|
|
77
|
+
"""Migrate older state versions to the current schema."""
|
|
78
|
+
version = data.get("version", 1)
|
|
79
|
+
if version == 1:
|
|
80
|
+
data["generations"] = {}
|
|
81
|
+
version = 2
|
|
82
|
+
if version == 2:
|
|
83
|
+
legacy = data.get("generations", {})
|
|
84
|
+
migrated: dict[str, list[dict]] = {}
|
|
85
|
+
if isinstance(legacy, dict):
|
|
86
|
+
for platform, record in legacy.items():
|
|
87
|
+
if isinstance(record, dict):
|
|
88
|
+
migrated[platform] = [record]
|
|
89
|
+
data["generations"] = migrated
|
|
90
|
+
data.setdefault("feedback", {})
|
|
91
|
+
version = 3
|
|
92
|
+
data["version"] = version
|
|
93
|
+
return data
|
|
94
|
+
|
|
95
|
+
def _load(self) -> CheckpointState:
|
|
96
|
+
if not self._state_path.is_file():
|
|
97
|
+
return CheckpointState()
|
|
98
|
+
try:
|
|
99
|
+
with open(self._state_path, encoding="utf-8") as f:
|
|
100
|
+
data = json.load(f)
|
|
101
|
+
if not isinstance(data, dict):
|
|
102
|
+
logger.warning("Invalid checkpoint data in %s — starting fresh", self._state_path)
|
|
103
|
+
return CheckpointState()
|
|
104
|
+
version = data.get("version")
|
|
105
|
+
if isinstance(version, int) and version < STATE_VERSION:
|
|
106
|
+
logger.info("Migrating checkpoint state from v%d to v%d", version, STATE_VERSION)
|
|
107
|
+
data = self._migrate(data)
|
|
108
|
+
elif version != STATE_VERSION:
|
|
109
|
+
logger.warning(
|
|
110
|
+
"Unrecognized checkpoint version %s in %s — starting fresh",
|
|
111
|
+
version,
|
|
112
|
+
self._state_path,
|
|
113
|
+
)
|
|
114
|
+
return CheckpointState()
|
|
115
|
+
return CheckpointState.model_validate(data)
|
|
116
|
+
except ValidationError as exc:
|
|
117
|
+
logger.warning(
|
|
118
|
+
"Invalid checkpoint structure in %s: %s — starting fresh",
|
|
119
|
+
self._state_path,
|
|
120
|
+
exc,
|
|
121
|
+
)
|
|
122
|
+
return CheckpointState()
|
|
123
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
124
|
+
logger.warning(
|
|
125
|
+
"Could not read checkpoint state %s: %s — starting fresh",
|
|
126
|
+
self._state_path,
|
|
127
|
+
exc,
|
|
128
|
+
)
|
|
129
|
+
return CheckpointState()
|
|
130
|
+
|
|
131
|
+
def save(self) -> None:
|
|
132
|
+
"""Persist current checkpoint state to disk.
|
|
133
|
+
|
|
134
|
+
If the state directory cannot be written — e.g. when vardoger is run
|
|
135
|
+
inside a sandboxed shell (Codex, Claude Code) whose write access is
|
|
136
|
+
restricted to the workspace — we log a warning instead of raising.
|
|
137
|
+
The caller still sees the in-memory state, and the next run recomputes
|
|
138
|
+
whatever was lost, which is always safe (at most, a few files get
|
|
139
|
+
re-analyzed).
|
|
140
|
+
"""
|
|
141
|
+
try:
|
|
142
|
+
self._state_dir.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
tmp_path = self._state_path.with_suffix(".tmp")
|
|
144
|
+
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
145
|
+
f.write(self._state.model_dump_json(indent=2))
|
|
146
|
+
f.write("\n")
|
|
147
|
+
tmp_path.replace(self._state_path)
|
|
148
|
+
except OSError as exc:
|
|
149
|
+
logger.warning(
|
|
150
|
+
"Could not persist checkpoint state to %s: %s. "
|
|
151
|
+
"vardoger will continue, but the next run may re-analyze "
|
|
152
|
+
"already-processed conversations. If you're running under a "
|
|
153
|
+
"sandboxed shell (Codex/Claude), approve write access beyond "
|
|
154
|
+
"the workspace.",
|
|
155
|
+
self._state_path,
|
|
156
|
+
exc,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# -- per-file checkpoints --
|
|
160
|
+
|
|
161
|
+
def is_changed(self, platform: str, rel_path: str, abs_path: Path) -> bool:
|
|
162
|
+
"""Return True if the file is new or its content has changed."""
|
|
163
|
+
current_hash = file_hash(abs_path)
|
|
164
|
+
platform_ckpts = self._state.checkpoints.get(platform, {})
|
|
165
|
+
stored = platform_ckpts.get(rel_path)
|
|
166
|
+
return not (stored and stored.sha256 == current_hash)
|
|
167
|
+
|
|
168
|
+
def record(self, platform: str, rel_path: str, abs_path: Path) -> None:
|
|
169
|
+
"""Record a file as processed with its current content hash."""
|
|
170
|
+
if platform not in self._state.checkpoints:
|
|
171
|
+
self._state.checkpoints[platform] = {}
|
|
172
|
+
self._state.checkpoints[platform][rel_path] = FileCheckpoint(
|
|
173
|
+
sha256=file_hash(abs_path),
|
|
174
|
+
processed_at=datetime.now(UTC).isoformat(),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# -- generation history --
|
|
178
|
+
|
|
179
|
+
def record_generation(
|
|
180
|
+
self,
|
|
181
|
+
platform: str,
|
|
182
|
+
conversations_analyzed: int,
|
|
183
|
+
output_path: str,
|
|
184
|
+
content: str = "",
|
|
185
|
+
output_hash: str | None = None,
|
|
186
|
+
confidence: list[RuleConfidence] | None = None,
|
|
187
|
+
min_confidence_written: ConfidenceLevel = "low",
|
|
188
|
+
) -> None:
|
|
189
|
+
"""Append a new generation record for a platform (newest-last)."""
|
|
190
|
+
record = GenerationRecord(
|
|
191
|
+
generated_at=datetime.now(UTC).isoformat(),
|
|
192
|
+
conversations_analyzed=conversations_analyzed,
|
|
193
|
+
output_path=output_path,
|
|
194
|
+
output_hash=output_hash if output_hash is not None else content_hash(content),
|
|
195
|
+
content=content,
|
|
196
|
+
confidence=confidence or [],
|
|
197
|
+
min_confidence_written=min_confidence_written,
|
|
198
|
+
)
|
|
199
|
+
self._state.generations.setdefault(platform, []).append(record)
|
|
200
|
+
|
|
201
|
+
def get_generation(self, platform: str) -> GenerationRecord | None:
|
|
202
|
+
"""Return the most recent generation record for a platform, or None."""
|
|
203
|
+
history = self._state.generations.get(platform, [])
|
|
204
|
+
return history[-1] if history else None
|
|
205
|
+
|
|
206
|
+
def get_generation_history(self, platform: str) -> list[GenerationRecord]:
|
|
207
|
+
"""Return the full (newest-last) generation history for a platform."""
|
|
208
|
+
return list(self._state.generations.get(platform, []))
|
|
209
|
+
|
|
210
|
+
def pop_generation(self, platform: str) -> GenerationRecord | None:
|
|
211
|
+
"""Remove and return the most recent generation record, or None."""
|
|
212
|
+
history = self._state.generations.get(platform)
|
|
213
|
+
if not history:
|
|
214
|
+
return None
|
|
215
|
+
return history.pop()
|
|
216
|
+
|
|
217
|
+
def get_checkpoint(self, platform: str, rel_path: str) -> FileCheckpoint | None:
|
|
218
|
+
"""Return checkpoint for a specific file, or None if not tracked."""
|
|
219
|
+
return self._state.checkpoints.get(platform, {}).get(rel_path)
|
|
220
|
+
|
|
221
|
+
def clear(self, platform: str | None = None) -> None:
|
|
222
|
+
"""Remove checkpoint data, optionally for a single platform."""
|
|
223
|
+
if platform:
|
|
224
|
+
self._state.checkpoints.pop(platform, None)
|
|
225
|
+
else:
|
|
226
|
+
self._state.checkpoints = {}
|
|
227
|
+
|
|
228
|
+
# -- feedback --
|
|
229
|
+
|
|
230
|
+
def get_feedback(self, platform: str) -> FeedbackRecord:
|
|
231
|
+
"""Return the feedback record for a platform, creating one if needed."""
|
|
232
|
+
return self._state.feedback.setdefault(platform, FeedbackRecord())
|
|
233
|
+
|
|
234
|
+
def record_feedback_event(self, platform: str, event: FeedbackEvent) -> None:
|
|
235
|
+
"""Append a feedback event for a platform."""
|
|
236
|
+
self.get_feedback(platform).events.append(event)
|