devmemory-cli 0.1.0.dev0__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.
- devmemory/__about__.py +3 -0
- devmemory/__init__.py +14 -0
- devmemory/__main__.py +6 -0
- devmemory/adapters/__init__.py +6 -0
- devmemory/adapters/databricks.py +346 -0
- devmemory/adapters/entire.py +444 -0
- devmemory/adapters/git.py +408 -0
- devmemory/adapters/graph.py +251 -0
- devmemory/adapters/metrics.py +150 -0
- devmemory/adapters/tests.py +227 -0
- devmemory/analysis/__init__.py +19 -0
- devmemory/analysis/base.py +128 -0
- devmemory/analysis/chain.py +53 -0
- devmemory/analysis/llm.py +236 -0
- devmemory/analysis/rules.py +110 -0
- devmemory/api/__init__.py +10 -0
- devmemory/api/app.py +390 -0
- devmemory/api/mappers.py +187 -0
- devmemory/api/schemas.py +201 -0
- devmemory/cli/__init__.py +1 -0
- devmemory/cli/_errors.py +36 -0
- devmemory/cli/_render.py +79 -0
- devmemory/cli/analytics.py +136 -0
- devmemory/cli/analyze.py +58 -0
- devmemory/cli/app.py +163 -0
- devmemory/cli/checkpoint.py +199 -0
- devmemory/cli/compare.py +104 -0
- devmemory/cli/doctor.py +151 -0
- devmemory/cli/history.py +56 -0
- devmemory/cli/impact.py +95 -0
- devmemory/cli/init.py +91 -0
- devmemory/cli/mcp.py +66 -0
- devmemory/cli/memory.py +70 -0
- devmemory/cli/restore.py +91 -0
- devmemory/cli/search.py +48 -0
- devmemory/cli/serve.py +64 -0
- devmemory/cli/show.py +139 -0
- devmemory/cli/status.py +72 -0
- devmemory/cli/task.py +333 -0
- devmemory/config.py +302 -0
- devmemory/domain/__init__.py +5 -0
- devmemory/domain/enums.py +151 -0
- devmemory/domain/errors.py +188 -0
- devmemory/domain/models.py +452 -0
- devmemory/domain/taskloop.py +212 -0
- devmemory/environment.py +67 -0
- devmemory/logging.py +148 -0
- devmemory/mcp/__init__.py +12 -0
- devmemory/mcp/server.py +225 -0
- devmemory/paths.py +112 -0
- devmemory/pipeline/__init__.py +7 -0
- devmemory/pipeline/checkpoint.py +443 -0
- devmemory/pipeline/feature_detect.py +53 -0
- devmemory/pipeline/regression.py +141 -0
- devmemory/pipeline/runlog.py +73 -0
- devmemory/pipeline/status_rules.py +44 -0
- devmemory/py.typed +0 -0
- devmemory/services/__init__.py +9 -0
- devmemory/services/agent_context.py +287 -0
- devmemory/services/analysis.py +116 -0
- devmemory/services/analytics.py +328 -0
- devmemory/services/brief.py +53 -0
- devmemory/services/context.py +88 -0
- devmemory/services/databricks_sync.py +121 -0
- devmemory/services/features.py +85 -0
- devmemory/services/impact.py +47 -0
- devmemory/services/memory.py +212 -0
- devmemory/services/projects.py +226 -0
- devmemory/services/restore.py +194 -0
- devmemory/services/taskloop/__init__.py +39 -0
- devmemory/services/taskloop/collectors.py +263 -0
- devmemory/services/taskloop/engine.py +426 -0
- devmemory/services/taskloop/requirements.py +358 -0
- devmemory/services/trace.py +152 -0
- devmemory/services/versions.py +287 -0
- devmemory/storage/__init__.py +9 -0
- devmemory/storage/artifacts.py +113 -0
- devmemory/storage/db.py +205 -0
- devmemory/storage/graph_impacts.py +63 -0
- devmemory/storage/migrations/0001_init.sql +15 -0
- devmemory/storage/migrations/0002_versions.sql +210 -0
- devmemory/storage/migrations/0003_graph.sql +14 -0
- devmemory/storage/migrations/0004_taskloop.sql +82 -0
- devmemory/storage/migrations/0005_project_brief.sql +12 -0
- devmemory/storage/repositories.py +286 -0
- devmemory/storage/tasks.py +342 -0
- devmemory/storage/versions.py +604 -0
- devmemory/web/static/assets/index-CbV5njRH.js +78 -0
- devmemory/web/static/assets/index-DD-7ceZx.css +1 -0
- devmemory/web/static/index.html +18 -0
- devmemory_cli-0.1.0.dev0.dist-info/METADATA +174 -0
- devmemory_cli-0.1.0.dev0.dist-info/RECORD +95 -0
- devmemory_cli-0.1.0.dev0.dist-info/WHEEL +4 -0
- devmemory_cli-0.1.0.dev0.dist-info/entry_points.txt +3 -0
- devmemory_cli-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"""Entire adapter - the only place DevMemory invokes the ``entire`` CLI or reads
|
|
2
|
+
its checkpoint git refs.
|
|
3
|
+
|
|
4
|
+
Checkpoint resolution follows the ladder in docs/IMPLEMENTATION_STRATEGY.md sec. 3:
|
|
5
|
+
|
|
6
|
+
1. ``Entire-Checkpoint`` git trailer on the commit (method=trailer, conf 1.0)
|
|
7
|
+
2. ``entire checkpoint explain --commit <sha> --json`` (session metadata)
|
|
8
|
+
3. direct read of ``refs/entire/checkpoints/<shard>/<id>`` (offline, + intent)
|
|
9
|
+
4. time/branch heuristic against ``entire checkpoint list --json`` (conf <= 0.5)
|
|
10
|
+
5. nothing - never fabricated
|
|
11
|
+
|
|
12
|
+
The CLI is authoritative for what it exposes; the git-ref read is the resilience
|
|
13
|
+
path when the CLI is absent or its output shape drifts.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
22
|
+
from datetime import datetime, timedelta
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import TYPE_CHECKING
|
|
25
|
+
|
|
26
|
+
from devmemory.domain.enums import AssociationMethod
|
|
27
|
+
from devmemory.domain.models import (
|
|
28
|
+
CheckpointReference,
|
|
29
|
+
CheckpointSession,
|
|
30
|
+
EntireStatus,
|
|
31
|
+
TokenUsage,
|
|
32
|
+
)
|
|
33
|
+
from devmemory.logging import get_logger
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from devmemory.adapters.git import GitAdapter
|
|
37
|
+
|
|
38
|
+
_log = get_logger(__name__)
|
|
39
|
+
|
|
40
|
+
_CHECKPOINT_REF_PREFIX = "refs/entire/checkpoints/"
|
|
41
|
+
_INTENT_MAX_CHARS = 2000
|
|
42
|
+
_HEURISTIC_WINDOW = timedelta(hours=6)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class EntireAdapter:
|
|
46
|
+
"""Wraps the installed Entire CLI. Every method degrades gracefully when the
|
|
47
|
+
CLI is absent, disabled, or returns something unexpected - it never raises for
|
|
48
|
+
"Entire is just not here" and never fabricates checkpoint data.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
repo_path: Path | str,
|
|
54
|
+
*,
|
|
55
|
+
binary: str | None = None,
|
|
56
|
+
repo: str | None = None,
|
|
57
|
+
git: GitAdapter | None = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
self._cwd = Path(repo_path).resolve()
|
|
60
|
+
self._repo = repo
|
|
61
|
+
self._binary = binary or _find_binary()
|
|
62
|
+
self._git = git
|
|
63
|
+
|
|
64
|
+
# -- process plumbing --------------------------------------------------
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def binary_path(self) -> str | None:
|
|
68
|
+
return self._binary
|
|
69
|
+
|
|
70
|
+
def _run(self, *args: str, timeout: int = 30) -> subprocess.CompletedProcess[str] | None:
|
|
71
|
+
if self._binary is None:
|
|
72
|
+
return None
|
|
73
|
+
cmd = [self._binary, *args]
|
|
74
|
+
if self._repo and "--repo" not in args:
|
|
75
|
+
cmd += ["--repo", self._repo]
|
|
76
|
+
env = {**os.environ, "ENTIRE_TOKEN_STORE": os.environ.get("ENTIRE_TOKEN_STORE", "file")}
|
|
77
|
+
try:
|
|
78
|
+
return subprocess.run( # noqa: S603 - resolved binary, arg list, no shell
|
|
79
|
+
cmd,
|
|
80
|
+
cwd=self._cwd,
|
|
81
|
+
env=env,
|
|
82
|
+
capture_output=True,
|
|
83
|
+
text=True,
|
|
84
|
+
encoding="utf-8",
|
|
85
|
+
errors="replace",
|
|
86
|
+
timeout=timeout,
|
|
87
|
+
check=False,
|
|
88
|
+
)
|
|
89
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
90
|
+
_log.warning("entire.run_failed", args=list(args), error=str(exc))
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
def _run_json(self, *args: str, timeout: int = 30) -> object | None:
|
|
94
|
+
proc = self._run(*args, timeout=timeout)
|
|
95
|
+
if proc is None or proc.returncode != 0 or not proc.stdout.strip():
|
|
96
|
+
return None
|
|
97
|
+
try:
|
|
98
|
+
parsed: object = json.loads(proc.stdout)
|
|
99
|
+
except json.JSONDecodeError:
|
|
100
|
+
_log.warning("entire.bad_json", args=list(args))
|
|
101
|
+
return None
|
|
102
|
+
return parsed
|
|
103
|
+
|
|
104
|
+
# -- detection -------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def is_installed(self) -> bool:
|
|
107
|
+
return self._binary is not None
|
|
108
|
+
|
|
109
|
+
def cli_version(self) -> str | None:
|
|
110
|
+
proc = self._run("version")
|
|
111
|
+
if proc is None or proc.returncode != 0:
|
|
112
|
+
return None
|
|
113
|
+
for line in proc.stdout.splitlines():
|
|
114
|
+
if line.lower().startswith("entire cli"):
|
|
115
|
+
return line.split("Entire CLI", 1)[-1].strip() or line.strip()
|
|
116
|
+
return proc.stdout.splitlines()[0].strip() if proc.stdout.strip() else None
|
|
117
|
+
|
|
118
|
+
def probe(self) -> EntireStatus:
|
|
119
|
+
"""Full detection snapshot for ``init`` / ``status`` / ``doctor``."""
|
|
120
|
+
if self._binary is None:
|
|
121
|
+
return EntireStatus(installed=False, detail="entire CLI not found on PATH")
|
|
122
|
+
|
|
123
|
+
status = EntireStatus(
|
|
124
|
+
installed=True,
|
|
125
|
+
binary_path=self._binary,
|
|
126
|
+
cli_version=self.cli_version(),
|
|
127
|
+
)
|
|
128
|
+
data = self._run_json("status", "--json")
|
|
129
|
+
if isinstance(data, dict):
|
|
130
|
+
status.enabled = bool(data.get("enabled"))
|
|
131
|
+
agents = data.get("agents")
|
|
132
|
+
if isinstance(agents, list):
|
|
133
|
+
status.agents = [str(a) for a in agents]
|
|
134
|
+
if not status.enabled:
|
|
135
|
+
status.detail = "Entire is installed but not enabled in this repository"
|
|
136
|
+
else:
|
|
137
|
+
status.detail = "could not read `entire status --json`"
|
|
138
|
+
return status
|
|
139
|
+
|
|
140
|
+
# -- checkpoint resolution ------------------------------------------
|
|
141
|
+
|
|
142
|
+
def resolve_for_commit(
|
|
143
|
+
self,
|
|
144
|
+
commit_sha: str,
|
|
145
|
+
*,
|
|
146
|
+
committed_at: datetime | None = None,
|
|
147
|
+
branch: str | None = None,
|
|
148
|
+
) -> CheckpointReference | None:
|
|
149
|
+
"""Best available checkpoint for a commit, or ``None`` (never fabricated)."""
|
|
150
|
+
checkpoint_id = self._git.entire_checkpoint_trailer(commit_sha) if self._git else None
|
|
151
|
+
|
|
152
|
+
if checkpoint_id:
|
|
153
|
+
ref = self._assemble(checkpoint_id, commit_sha=commit_sha)
|
|
154
|
+
if ref is None:
|
|
155
|
+
# Trailer names a checkpoint we cannot read yet (e.g. not fetched).
|
|
156
|
+
ref = CheckpointReference(checkpoint_id=checkpoint_id, commit_sha=commit_sha)
|
|
157
|
+
ref.association_method = AssociationMethod.TRAILER
|
|
158
|
+
ref.association_confidence = AssociationMethod.TRAILER.default_confidence
|
|
159
|
+
return ref
|
|
160
|
+
|
|
161
|
+
heuristic = self._heuristic_for_commit(commit_sha, committed_at, branch)
|
|
162
|
+
if heuristic is not None:
|
|
163
|
+
return heuristic
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
def get_checkpoint(self, checkpoint_id: str) -> CheckpointReference | None:
|
|
167
|
+
"""A checkpoint by id, with no commit association claim."""
|
|
168
|
+
return self._assemble(checkpoint_id, commit_sha=None)
|
|
169
|
+
|
|
170
|
+
def list_checkpoints(self, *, limit: int = 100) -> list[dict[str, object]]:
|
|
171
|
+
data = self._run_json("checkpoint", "list", "--json")
|
|
172
|
+
if not isinstance(data, list):
|
|
173
|
+
return []
|
|
174
|
+
return [d for d in data if isinstance(d, dict)][:limit]
|
|
175
|
+
|
|
176
|
+
def list_sessions(self, *, limit: int = 50) -> list[dict[str, object]]:
|
|
177
|
+
"""Raw ``entire session list --json`` rows (session_id, agent, status, …).
|
|
178
|
+
|
|
179
|
+
Empty list when the CLI is absent, disabled, or the subcommand changed -
|
|
180
|
+
never raises. A session can span many checkpoints and commits (§7).
|
|
181
|
+
"""
|
|
182
|
+
data = self._run_json("session", "list", "--json")
|
|
183
|
+
if not isinstance(data, list):
|
|
184
|
+
return []
|
|
185
|
+
return [d for d in data if isinstance(d, dict)][:limit]
|
|
186
|
+
|
|
187
|
+
def transcript(self, checkpoint_id: str, *, session_index: int = 0) -> str | None:
|
|
188
|
+
"""Compact JSONL transcript for a checkpoint session (CLI, then git ref)."""
|
|
189
|
+
proc = self._run(
|
|
190
|
+
"checkpoint",
|
|
191
|
+
"explain",
|
|
192
|
+
checkpoint_id,
|
|
193
|
+
"--transcript",
|
|
194
|
+
"--session-index",
|
|
195
|
+
str(session_index),
|
|
196
|
+
)
|
|
197
|
+
if proc is not None and proc.returncode == 0 and proc.stdout.strip():
|
|
198
|
+
return proc.stdout
|
|
199
|
+
if self._git is not None:
|
|
200
|
+
ref = self._find_ref(checkpoint_id)
|
|
201
|
+
if ref is not None:
|
|
202
|
+
return self._git.cat_ref_blob(ref, f"{session_index}/transcript.jsonl")
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
# -- assembly -----------------------------------------------------
|
|
206
|
+
|
|
207
|
+
def _assemble(
|
|
208
|
+
self, checkpoint_id: str, *, commit_sha: str | None
|
|
209
|
+
) -> CheckpointReference | None:
|
|
210
|
+
"""Merge CLI ``explain --json`` (session metadata) with the git-ref tree
|
|
211
|
+
(intent, strategy, commit) into one normalized reference.
|
|
212
|
+
"""
|
|
213
|
+
ref = CheckpointReference(checkpoint_id=checkpoint_id, commit_sha=commit_sha)
|
|
214
|
+
found_anything = False
|
|
215
|
+
|
|
216
|
+
envelope = self._explain_json(checkpoint_id=checkpoint_id, commit_sha=None)
|
|
217
|
+
if envelope is not None:
|
|
218
|
+
_merge_envelope(ref, envelope)
|
|
219
|
+
found_anything = True
|
|
220
|
+
|
|
221
|
+
git_ref = self._find_ref(checkpoint_id)
|
|
222
|
+
if git_ref is not None and self._git is not None:
|
|
223
|
+
ref.ref = git_ref
|
|
224
|
+
if self._merge_git_ref(ref, git_ref):
|
|
225
|
+
found_anything = True
|
|
226
|
+
|
|
227
|
+
if not found_anything:
|
|
228
|
+
return None
|
|
229
|
+
if commit_sha and not ref.commit_sha:
|
|
230
|
+
ref.commit_sha = commit_sha
|
|
231
|
+
return ref
|
|
232
|
+
|
|
233
|
+
def _explain_json(
|
|
234
|
+
self, *, checkpoint_id: str | None = None, commit_sha: str | None = None
|
|
235
|
+
) -> dict[str, object] | None:
|
|
236
|
+
if commit_sha:
|
|
237
|
+
data = self._run_json("checkpoint", "explain", "--commit", commit_sha, "--json")
|
|
238
|
+
elif checkpoint_id:
|
|
239
|
+
data = self._run_json("checkpoint", "explain", checkpoint_id, "--json")
|
|
240
|
+
else: # pragma: no cover - guarded by callers
|
|
241
|
+
return None
|
|
242
|
+
return data if isinstance(data, dict) else None
|
|
243
|
+
|
|
244
|
+
def _merge_git_ref(self, ref: CheckpointReference, git_ref: str) -> bool:
|
|
245
|
+
assert self._git is not None # noqa: S101 - callers guard
|
|
246
|
+
root = self._git.cat_ref_blob(git_ref, "metadata.json")
|
|
247
|
+
if root is None:
|
|
248
|
+
return False
|
|
249
|
+
try:
|
|
250
|
+
meta = json.loads(root)
|
|
251
|
+
except json.JSONDecodeError:
|
|
252
|
+
return False
|
|
253
|
+
if not isinstance(meta, dict):
|
|
254
|
+
return False
|
|
255
|
+
|
|
256
|
+
ref.strategy = ref.strategy or _str_or_none(meta.get("strategy"))
|
|
257
|
+
ref.commit_sha = ref.commit_sha or _str_or_none(meta.get("commit_sha"))
|
|
258
|
+
ref.imported = bool(meta.get("imported", ref.imported))
|
|
259
|
+
_merge_token_usage(ref.tokens, meta.get("token_usage"))
|
|
260
|
+
|
|
261
|
+
sessions = meta.get("sessions")
|
|
262
|
+
if isinstance(sessions, list):
|
|
263
|
+
for idx, session in enumerate(sessions):
|
|
264
|
+
if not isinstance(session, dict):
|
|
265
|
+
continue
|
|
266
|
+
self._merge_session_from_ref(ref, git_ref, idx, session)
|
|
267
|
+
if ref.intent is None:
|
|
268
|
+
ref.intent = self._read_intent(git_ref, 0)
|
|
269
|
+
if ref.sessions and ref.agent is None:
|
|
270
|
+
ref.agent = ref.sessions[0].agent
|
|
271
|
+
ref.model = ref.model or ref.sessions[0].model
|
|
272
|
+
ref.created_at = ref.created_at or ref.sessions[0].created_at
|
|
273
|
+
return True
|
|
274
|
+
|
|
275
|
+
def _merge_session_from_ref(
|
|
276
|
+
self,
|
|
277
|
+
ref: CheckpointReference,
|
|
278
|
+
git_ref: str,
|
|
279
|
+
idx: int,
|
|
280
|
+
session_ptr: dict[str, object],
|
|
281
|
+
) -> None:
|
|
282
|
+
assert self._git is not None # noqa: S101
|
|
283
|
+
meta_path = _strip_slash(session_ptr.get("metadata")) or f"{idx}/metadata.json"
|
|
284
|
+
blob = self._git.cat_ref_blob(git_ref, meta_path)
|
|
285
|
+
session = _session_at(ref, idx)
|
|
286
|
+
if blob:
|
|
287
|
+
try:
|
|
288
|
+
sm = json.loads(blob)
|
|
289
|
+
except json.JSONDecodeError:
|
|
290
|
+
sm = {}
|
|
291
|
+
if isinstance(sm, dict):
|
|
292
|
+
session.session_id = session.session_id or _str_or_none(sm.get("session_id"))
|
|
293
|
+
session.agent = session.agent or _str_or_none(sm.get("agent"))
|
|
294
|
+
session.model = session.model or _str_or_none(sm.get("model"))
|
|
295
|
+
session.kind = session.kind or _str_or_none(sm.get("kind"))
|
|
296
|
+
session.created_at = session.created_at or _parse_dt(sm.get("created_at"))
|
|
297
|
+
_merge_token_usage(session.tokens, sm.get("token_usage"))
|
|
298
|
+
if idx == 0 and ref.intent is None:
|
|
299
|
+
ref.intent = self._read_intent(git_ref, idx, session_ptr.get("prompt"))
|
|
300
|
+
|
|
301
|
+
def _read_intent(self, git_ref: str, idx: int, prompt_ptr: object | None = None) -> str | None:
|
|
302
|
+
if self._git is None:
|
|
303
|
+
return None
|
|
304
|
+
path = _strip_slash(prompt_ptr) or f"{idx}/prompt.txt"
|
|
305
|
+
text = self._git.cat_ref_blob(git_ref, path)
|
|
306
|
+
if not text:
|
|
307
|
+
return None
|
|
308
|
+
return text.strip()[:_INTENT_MAX_CHARS] or None
|
|
309
|
+
|
|
310
|
+
def _find_ref(self, checkpoint_id: str) -> str | None:
|
|
311
|
+
if self._git is None:
|
|
312
|
+
return None
|
|
313
|
+
for _sha, name in self._git.list_refs(_CHECKPOINT_REF_PREFIX):
|
|
314
|
+
if name.rsplit("/", 1)[-1] == checkpoint_id:
|
|
315
|
+
return name
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
def _heuristic_for_commit(
|
|
319
|
+
self,
|
|
320
|
+
commit_sha: str,
|
|
321
|
+
committed_at: datetime | None,
|
|
322
|
+
branch: str | None,
|
|
323
|
+
) -> CheckpointReference | None:
|
|
324
|
+
if committed_at is None:
|
|
325
|
+
return None
|
|
326
|
+
best: tuple[float, dict[str, object]] | None = None
|
|
327
|
+
for entry in self.list_checkpoints():
|
|
328
|
+
when = _parse_dt(entry.get("date"))
|
|
329
|
+
if when is None:
|
|
330
|
+
continue
|
|
331
|
+
delta = abs((when - committed_at).total_seconds())
|
|
332
|
+
if delta > _HEURISTIC_WINDOW.total_seconds():
|
|
333
|
+
continue
|
|
334
|
+
if best is None or delta < best[0]:
|
|
335
|
+
best = (delta, entry)
|
|
336
|
+
if best is None:
|
|
337
|
+
return None
|
|
338
|
+
|
|
339
|
+
_, entry = best
|
|
340
|
+
checkpoint_id = _str_or_none(entry.get("checkpoint_id")) or _str_or_none(entry.get("id"))
|
|
341
|
+
if not checkpoint_id:
|
|
342
|
+
return None
|
|
343
|
+
ref = self._assemble(checkpoint_id, commit_sha=commit_sha) or CheckpointReference(
|
|
344
|
+
checkpoint_id=checkpoint_id, commit_sha=commit_sha
|
|
345
|
+
)
|
|
346
|
+
ref.association_method = AssociationMethod.HEURISTIC_TIME
|
|
347
|
+
ref.association_confidence = round(
|
|
348
|
+
max(0.2, 0.5 - best[0] / _HEURISTIC_WINDOW.total_seconds() * 0.3), 2
|
|
349
|
+
)
|
|
350
|
+
_log.info(
|
|
351
|
+
"entire.heuristic_match",
|
|
352
|
+
commit=commit_sha[:12],
|
|
353
|
+
checkpoint=checkpoint_id,
|
|
354
|
+
confidence=ref.association_confidence,
|
|
355
|
+
)
|
|
356
|
+
return ref
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# --- envelope parsing -------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _merge_envelope(ref: CheckpointReference, envelope: dict[str, object]) -> None:
|
|
363
|
+
ref.strategy = ref.strategy or _str_or_none(envelope.get("strategy"))
|
|
364
|
+
sessions = envelope.get("sessions")
|
|
365
|
+
if not isinstance(sessions, list):
|
|
366
|
+
return
|
|
367
|
+
for entry in sessions:
|
|
368
|
+
if not isinstance(entry, dict):
|
|
369
|
+
continue
|
|
370
|
+
idx = int(entry.get("index", 0)) if isinstance(entry.get("index"), int) else 0
|
|
371
|
+
session = _session_at(ref, idx)
|
|
372
|
+
session.session_id = session.session_id or _str_or_none(entry.get("session_id"))
|
|
373
|
+
session.agent = session.agent or _str_or_none(entry.get("agent"))
|
|
374
|
+
session.model = session.model or _str_or_none(entry.get("model"))
|
|
375
|
+
session.kind = session.kind or _str_or_none(entry.get("kind"))
|
|
376
|
+
session.created_at = session.created_at or _parse_dt(entry.get("created_at"))
|
|
377
|
+
_merge_token_usage(session.tokens, entry.get("token_usage"))
|
|
378
|
+
if ref.sessions:
|
|
379
|
+
first = ref.sessions[0]
|
|
380
|
+
ref.agent = ref.agent or first.agent
|
|
381
|
+
ref.model = ref.model or first.model
|
|
382
|
+
ref.created_at = ref.created_at or first.created_at
|
|
383
|
+
for session in ref.sessions:
|
|
384
|
+
_merge_token_usage(ref.tokens, session.tokens.model_dump())
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _session_at(ref: CheckpointReference, idx: int) -> CheckpointSession:
|
|
388
|
+
while len(ref.sessions) <= idx:
|
|
389
|
+
ref.sessions.append(CheckpointSession())
|
|
390
|
+
return ref.sessions[idx]
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _merge_token_usage(target: TokenUsage, raw: object) -> None:
|
|
394
|
+
if not isinstance(raw, dict):
|
|
395
|
+
return
|
|
396
|
+
for field in ("input_tokens", "output_tokens", "cache_read_tokens", "cache_creation_tokens"):
|
|
397
|
+
value = raw.get(field)
|
|
398
|
+
if isinstance(value, (int, float)) and value > getattr(target, field):
|
|
399
|
+
setattr(target, field, int(value))
|
|
400
|
+
calls = raw.get("api_call_count")
|
|
401
|
+
if isinstance(calls, (int, float)) and calls > target.api_call_count:
|
|
402
|
+
target.api_call_count = int(calls)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
# --- small helpers --------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _str_or_none(value: object) -> str | None:
|
|
409
|
+
if isinstance(value, str) and value.strip():
|
|
410
|
+
return value.strip()
|
|
411
|
+
return None
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _strip_slash(value: object) -> str | None:
|
|
415
|
+
text = _str_or_none(value)
|
|
416
|
+
return text.lstrip("/") if text else None
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _parse_dt(value: object) -> datetime | None:
|
|
420
|
+
text = _str_or_none(value)
|
|
421
|
+
if text is None:
|
|
422
|
+
return None
|
|
423
|
+
try:
|
|
424
|
+
return datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
425
|
+
except ValueError:
|
|
426
|
+
return None
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _find_binary() -> str | None:
|
|
430
|
+
found = shutil.which("entire")
|
|
431
|
+
if found:
|
|
432
|
+
return found
|
|
433
|
+
for candidate in (
|
|
434
|
+
Path.home() / ".local" / "bin" / "entire.exe",
|
|
435
|
+
Path.home() / ".local" / "bin" / "entire",
|
|
436
|
+
Path.home() / "go" / "bin" / "entire.exe",
|
|
437
|
+
Path.home() / "go" / "bin" / "entire",
|
|
438
|
+
):
|
|
439
|
+
if candidate.is_file():
|
|
440
|
+
return str(candidate)
|
|
441
|
+
return None
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
__all__ = ["EntireAdapter"]
|