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.
Files changed (95) hide show
  1. devmemory/__about__.py +3 -0
  2. devmemory/__init__.py +14 -0
  3. devmemory/__main__.py +6 -0
  4. devmemory/adapters/__init__.py +6 -0
  5. devmemory/adapters/databricks.py +346 -0
  6. devmemory/adapters/entire.py +444 -0
  7. devmemory/adapters/git.py +408 -0
  8. devmemory/adapters/graph.py +251 -0
  9. devmemory/adapters/metrics.py +150 -0
  10. devmemory/adapters/tests.py +227 -0
  11. devmemory/analysis/__init__.py +19 -0
  12. devmemory/analysis/base.py +128 -0
  13. devmemory/analysis/chain.py +53 -0
  14. devmemory/analysis/llm.py +236 -0
  15. devmemory/analysis/rules.py +110 -0
  16. devmemory/api/__init__.py +10 -0
  17. devmemory/api/app.py +390 -0
  18. devmemory/api/mappers.py +187 -0
  19. devmemory/api/schemas.py +201 -0
  20. devmemory/cli/__init__.py +1 -0
  21. devmemory/cli/_errors.py +36 -0
  22. devmemory/cli/_render.py +79 -0
  23. devmemory/cli/analytics.py +136 -0
  24. devmemory/cli/analyze.py +58 -0
  25. devmemory/cli/app.py +163 -0
  26. devmemory/cli/checkpoint.py +199 -0
  27. devmemory/cli/compare.py +104 -0
  28. devmemory/cli/doctor.py +151 -0
  29. devmemory/cli/history.py +56 -0
  30. devmemory/cli/impact.py +95 -0
  31. devmemory/cli/init.py +91 -0
  32. devmemory/cli/mcp.py +66 -0
  33. devmemory/cli/memory.py +70 -0
  34. devmemory/cli/restore.py +91 -0
  35. devmemory/cli/search.py +48 -0
  36. devmemory/cli/serve.py +64 -0
  37. devmemory/cli/show.py +139 -0
  38. devmemory/cli/status.py +72 -0
  39. devmemory/cli/task.py +333 -0
  40. devmemory/config.py +302 -0
  41. devmemory/domain/__init__.py +5 -0
  42. devmemory/domain/enums.py +151 -0
  43. devmemory/domain/errors.py +188 -0
  44. devmemory/domain/models.py +452 -0
  45. devmemory/domain/taskloop.py +212 -0
  46. devmemory/environment.py +67 -0
  47. devmemory/logging.py +148 -0
  48. devmemory/mcp/__init__.py +12 -0
  49. devmemory/mcp/server.py +225 -0
  50. devmemory/paths.py +112 -0
  51. devmemory/pipeline/__init__.py +7 -0
  52. devmemory/pipeline/checkpoint.py +443 -0
  53. devmemory/pipeline/feature_detect.py +53 -0
  54. devmemory/pipeline/regression.py +141 -0
  55. devmemory/pipeline/runlog.py +73 -0
  56. devmemory/pipeline/status_rules.py +44 -0
  57. devmemory/py.typed +0 -0
  58. devmemory/services/__init__.py +9 -0
  59. devmemory/services/agent_context.py +287 -0
  60. devmemory/services/analysis.py +116 -0
  61. devmemory/services/analytics.py +328 -0
  62. devmemory/services/brief.py +53 -0
  63. devmemory/services/context.py +88 -0
  64. devmemory/services/databricks_sync.py +121 -0
  65. devmemory/services/features.py +85 -0
  66. devmemory/services/impact.py +47 -0
  67. devmemory/services/memory.py +212 -0
  68. devmemory/services/projects.py +226 -0
  69. devmemory/services/restore.py +194 -0
  70. devmemory/services/taskloop/__init__.py +39 -0
  71. devmemory/services/taskloop/collectors.py +263 -0
  72. devmemory/services/taskloop/engine.py +426 -0
  73. devmemory/services/taskloop/requirements.py +358 -0
  74. devmemory/services/trace.py +152 -0
  75. devmemory/services/versions.py +287 -0
  76. devmemory/storage/__init__.py +9 -0
  77. devmemory/storage/artifacts.py +113 -0
  78. devmemory/storage/db.py +205 -0
  79. devmemory/storage/graph_impacts.py +63 -0
  80. devmemory/storage/migrations/0001_init.sql +15 -0
  81. devmemory/storage/migrations/0002_versions.sql +210 -0
  82. devmemory/storage/migrations/0003_graph.sql +14 -0
  83. devmemory/storage/migrations/0004_taskloop.sql +82 -0
  84. devmemory/storage/migrations/0005_project_brief.sql +12 -0
  85. devmemory/storage/repositories.py +286 -0
  86. devmemory/storage/tasks.py +342 -0
  87. devmemory/storage/versions.py +604 -0
  88. devmemory/web/static/assets/index-CbV5njRH.js +78 -0
  89. devmemory/web/static/assets/index-DD-7ceZx.css +1 -0
  90. devmemory/web/static/index.html +18 -0
  91. devmemory_cli-0.1.0.dev0.dist-info/METADATA +174 -0
  92. devmemory_cli-0.1.0.dev0.dist-info/RECORD +95 -0
  93. devmemory_cli-0.1.0.dev0.dist-info/WHEEL +4 -0
  94. devmemory_cli-0.1.0.dev0.dist-info/entry_points.txt +3 -0
  95. 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"]