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,236 @@
1
+ """LLM-backed analysis providers (Anthropic / OpenAI / Gemini).
2
+
3
+ One class, three back ends, selected by name. Each call sends only the normalized
4
+ :class:`AnalysisInput` (plus a diff excerpt *iff* explicitly enabled in config) -
5
+ never raw source, never a transcript. The API key comes from the environment.
6
+ Any failure returns ``None`` so the fallback chain moves on; ``rules`` is always
7
+ the tail.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from datetime import UTC, datetime
14
+ from typing import Any
15
+
16
+ from devmemory.analysis.base import AnalysisInput, AnalysisProvider
17
+ from devmemory.config import resolve_llm_api_key
18
+ from devmemory.domain.models import Analysis
19
+ from devmemory.logging import get_logger
20
+
21
+ _log = get_logger(__name__)
22
+
23
+ _DEFAULT_MODEL = {
24
+ "anthropic": "claude-opus-5",
25
+ "openai": "gpt-5",
26
+ "gemini": "gemini-2.5-pro",
27
+ }
28
+
29
+ _SYSTEM = (
30
+ "You are a senior engineer reviewing one AI-assisted change. You are given "
31
+ "NORMALIZED FACTS (Git stats, test results, metric deltas, regressions, prior "
32
+ "attempts, change-impact). Treat every number as exact and correct - never "
33
+ "hedge it, never restate it as approximate, never contradict it. Your job is "
34
+ "interpretation only.\n\n"
35
+ "Reply with ONLY a JSON object, no prose around it:\n"
36
+ '{"summary": str (<= 3 sentences), "reasoning": str|null, '
37
+ '"recommendation": str|null, "warnings": [str], '
38
+ '"risk": "low"|"medium"|"high"}\n'
39
+ "If the facts show a regression or failing tests, risk is 'high'."
40
+ )
41
+
42
+
43
+ class LLMProvider(AnalysisProvider):
44
+ def __init__(self, provider: str, model: str | None = None) -> None:
45
+ self.name = provider.lower()
46
+ self._model = model or _DEFAULT_MODEL.get(self.name)
47
+
48
+ def analyze(self, data: AnalysisInput) -> Analysis | None:
49
+ key = resolve_llm_api_key(self.name)
50
+ if not key or self._model is None:
51
+ return None
52
+ prompt = _prompt(data)
53
+ try:
54
+ if self.name == "anthropic":
55
+ raw = _call_anthropic(key, self._model, prompt)
56
+ elif self.name == "openai":
57
+ raw = _call_openai(key, self._model, prompt)
58
+ elif self.name == "gemini":
59
+ raw = _call_gemini(key, self._model, prompt)
60
+ else:
61
+ return None
62
+ except Exception as exc:
63
+ _log.warning("analysis.llm_failed", provider=self.name, error=str(exc))
64
+ return None
65
+
66
+ parsed = _parse(raw)
67
+ if parsed is None:
68
+ _log.warning("analysis.llm_unparseable", provider=self.name)
69
+ return None
70
+ return Analysis(
71
+ version_id=data.version_id,
72
+ summary=str(parsed.get("summary", "")).strip(),
73
+ reasoning=_opt(parsed.get("reasoning")),
74
+ recommendation=_opt(parsed.get("recommendation")),
75
+ warnings=[str(w) for w in parsed.get("warnings", []) if str(w).strip()],
76
+ risk=_opt(parsed.get("risk")),
77
+ provider=self.name,
78
+ model=self._model,
79
+ generated_at=datetime.now(UTC),
80
+ )
81
+
82
+
83
+ # --- prompt -------------------------------------------------------------------
84
+
85
+
86
+ def _prompt(d: AnalysisInput) -> str:
87
+ facts: dict[str, Any] = {
88
+ "intent": d.intent,
89
+ "feature": d.feature,
90
+ "agent": d.agent,
91
+ "status": d.status,
92
+ "is_adverse": d.is_adverse,
93
+ "files_changed": d.files_changed,
94
+ "lines_added": d.lines_added,
95
+ "lines_removed": d.lines_removed,
96
+ "changed_paths": d.changed_paths[:30],
97
+ "tests": d.test_summary,
98
+ "metric_deltas": [m.model_dump() for m in d.metric_deltas],
99
+ "regressions": d.regressions,
100
+ "previous_attempts": [a.model_dump() for a in d.previous_attempts],
101
+ "impact_hotspots": [h.model_dump() for h in d.impact_hotspots],
102
+ }
103
+ text = "NORMALIZED FACTS:\n" + json.dumps(facts, indent=2, default=str)
104
+ if d.diff_excerpt:
105
+ text += f"\n\nDIFF EXCERPT (truncated):\n{d.diff_excerpt}"
106
+ return text
107
+
108
+
109
+ def _parse(raw: str) -> dict[str, Any] | None:
110
+ raw = raw.strip()
111
+ if raw.startswith("```"):
112
+ raw = raw.split("```", 2)[1].removeprefix("json").strip()
113
+ start, end = raw.find("{"), raw.rfind("}")
114
+ if start == -1 or end <= start:
115
+ return None
116
+ try:
117
+ value = json.loads(raw[start : end + 1])
118
+ except json.JSONDecodeError:
119
+ return None
120
+ return value if isinstance(value, dict) else None
121
+
122
+
123
+ def _opt(value: object) -> str | None:
124
+ text = str(value).strip() if value is not None else ""
125
+ return text or None
126
+
127
+
128
+ # --- back ends (lazy SDK imports) -------------------------------------------
129
+
130
+
131
+ def _call_anthropic(key: str, model: str, prompt: str) -> str:
132
+ import anthropic
133
+
134
+ client = anthropic.Anthropic(api_key=key)
135
+ message = client.messages.create(
136
+ model=model,
137
+ max_tokens=1500,
138
+ system=_SYSTEM,
139
+ messages=[{"role": "user", "content": prompt}],
140
+ output_config={"effort": "low"},
141
+ )
142
+ return "".join(b.text for b in message.content if b.type == "text")
143
+
144
+
145
+ def _call_openai(key: str, model: str, prompt: str) -> str:
146
+ import openai
147
+
148
+ client = openai.OpenAI(api_key=key)
149
+ resp = client.responses.create(
150
+ model=model,
151
+ instructions=_SYSTEM,
152
+ input=prompt,
153
+ )
154
+ return resp.output_text or ""
155
+
156
+
157
+ def _call_gemini(key: str, model: str, prompt: str) -> str:
158
+ from google import genai
159
+ from google.genai import types
160
+
161
+ client = genai.Client(api_key=key)
162
+ resp = client.models.generate_content(
163
+ model=model,
164
+ contents=prompt,
165
+ config=types.GenerateContentConfig(system_instruction=_SYSTEM),
166
+ )
167
+ return resp.text or ""
168
+
169
+
170
+ def call_llm(
171
+ prompt: str, *, system: str, providers: list[str], model: str | None = None
172
+ ) -> str | None:
173
+ """Try each provider in order; return the first non-empty completion, or None.
174
+
175
+ Used by callers outside the analysis chain (e.g. the task-loop requirement
176
+ evaluator). Keys come from the environment; any error falls through.
177
+ """
178
+ for raw_name in providers:
179
+ name = raw_name.strip().lower()
180
+ if name not in ("anthropic", "openai", "gemini"):
181
+ continue
182
+ key = resolve_llm_api_key(name)
183
+ chosen = model or _DEFAULT_MODEL.get(name)
184
+ if not key or chosen is None:
185
+ continue
186
+ try:
187
+ if name == "anthropic":
188
+ text = _call_anthropic_with_system(key, chosen, prompt, system)
189
+ elif name == "openai":
190
+ text = _call_openai_with_system(key, chosen, prompt, system)
191
+ else:
192
+ text = _call_gemini_with_system(key, chosen, prompt, system)
193
+ except Exception as exc:
194
+ _log.warning("llm.call_failed", provider=name, error=str(exc))
195
+ continue
196
+ if text and text.strip():
197
+ return text
198
+ return None
199
+
200
+
201
+ def _call_anthropic_with_system(key: str, model: str, prompt: str, system: str) -> str:
202
+ import anthropic
203
+
204
+ client = anthropic.Anthropic(api_key=key)
205
+ message = client.messages.create(
206
+ model=model,
207
+ max_tokens=1500,
208
+ system=system,
209
+ messages=[{"role": "user", "content": prompt}],
210
+ output_config={"effort": "low"},
211
+ )
212
+ return "".join(b.text for b in message.content if b.type == "text")
213
+
214
+
215
+ def _call_openai_with_system(key: str, model: str, prompt: str, system: str) -> str:
216
+ import openai
217
+
218
+ client = openai.OpenAI(api_key=key)
219
+ resp = client.responses.create(model=model, instructions=system, input=prompt)
220
+ return resp.output_text or ""
221
+
222
+
223
+ def _call_gemini_with_system(key: str, model: str, prompt: str, system: str) -> str:
224
+ from google import genai
225
+ from google.genai import types
226
+
227
+ client = genai.Client(api_key=key)
228
+ resp = client.models.generate_content(
229
+ model=model,
230
+ contents=prompt,
231
+ config=types.GenerateContentConfig(system_instruction=system),
232
+ )
233
+ return resp.text or ""
234
+
235
+
236
+ __all__ = ["LLMProvider", "call_llm"]
@@ -0,0 +1,110 @@
1
+ """The deterministic provider. Never calls out, never fails - the guaranteed
2
+ tail of every fallback chain."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from datetime import UTC, datetime
7
+
8
+ from devmemory.analysis.base import AnalysisInput, AnalysisProvider
9
+ from devmemory.domain.models import Analysis
10
+
11
+
12
+ class RulesProvider(AnalysisProvider):
13
+ name = "rules"
14
+
15
+ def analyze(self, data: AnalysisInput) -> Analysis:
16
+ summary = _summary(data)
17
+ warnings = _warnings(data)
18
+ return Analysis(
19
+ version_id=data.version_id,
20
+ summary=summary,
21
+ reasoning=_reasoning(data),
22
+ recommendation=_recommendation(data),
23
+ warnings=warnings,
24
+ risk=_risk(data),
25
+ provider=self.name,
26
+ model=None,
27
+ generated_at=datetime.now(UTC),
28
+ )
29
+
30
+
31
+ def _summary(d: AnalysisInput) -> str:
32
+ what = d.intent or f"a {d.files_changed}-file change"
33
+ scope = f"{d.files_changed} file(s), +{d.lines_added}/-{d.lines_removed}"
34
+ if d.status == "REGRESSION" or d.regressions:
35
+ return f"{what} - regressed ({'; '.join(d.regressions) or 'status set to REGRESSION'}). {scope}."
36
+ if d.status == "SUCCESS":
37
+ gain = next((m for m in d.metric_deltas if m.improved), None)
38
+ tail = f" {gain.name} {gain.before:g} -> {gain.after:g}." if gain else ""
39
+ return f"{what} - landed successfully. {scope}.{tail}"
40
+ if d.tests_failed:
41
+ return f"{what} - {d.tests_failed} test(s) failing. {scope}."
42
+ return f"{what}. {scope}. Status: {d.status}."
43
+
44
+
45
+ def _reasoning(d: AnalysisInput) -> str | None:
46
+ bits: list[str] = []
47
+ for m in d.metric_deltas:
48
+ if m.before is not None and m.after is not None and m.before != m.after:
49
+ move = "improved" if m.improved else "worsened" if m.worsened else "changed"
50
+ bits.append(f"{m.name} {move} ({m.before:g} -> {m.after:g})")
51
+ if d.previous_attempts:
52
+ adverse = [a for a in d.previous_attempts if a.status in ("REGRESSION", "ERROR")]
53
+ if adverse:
54
+ bits.append(
55
+ f"{len(adverse)} earlier attempt(s) in this area went wrong "
56
+ f"({', '.join(a.version_id.upper() for a in adverse)})"
57
+ )
58
+ if d.impact_hotspots:
59
+ top = d.impact_hotspots[0]
60
+ bits.append(f"{top.change_type} on {top.entity} affects {top.dependents} dependent(s)")
61
+ return "; ".join(bits) or None
62
+
63
+
64
+ def _recommendation(d: AnalysisInput) -> str | None:
65
+ if d.status == "REGRESSION" or d.regressions:
66
+ return "Revert or fix the cause before building on this - it regressed here."
67
+ if d.status == "ERROR":
68
+ return "This attempt errored; review the follow-up fix before retrying the approach."
69
+ risky = [h for h in d.impact_hotspots if h.change_type in ("removed", "signature_changed")]
70
+ if risky:
71
+ return (
72
+ f"{risky[0].change_type} on {risky[0].entity} has "
73
+ f"{risky[0].dependents} dependents - check every caller."
74
+ )
75
+ if any(a.status in ("REGRESSION", "ERROR") for a in d.previous_attempts):
76
+ return "A similar change failed before - compare against that version first."
77
+ if d.status == "SUCCESS":
78
+ return "Safe to build on."
79
+ return None
80
+
81
+
82
+ def _warnings(d: AnalysisInput) -> list[str]:
83
+ out: list[str] = []
84
+ for a in d.previous_attempts:
85
+ if a.status in ("REGRESSION", "ERROR"):
86
+ out.append(f"{a.version_id.upper()} [{a.status}]: {a.result}")
87
+ for h in d.impact_hotspots:
88
+ if h.change_type in ("removed", "signature_changed") and h.dependents > 0:
89
+ out.append(f"{h.change_type} {h.entity}: {h.dependents} dependent(s)")
90
+ return out
91
+
92
+
93
+ def _risk(d: AnalysisInput) -> str:
94
+ if d.status in ("REGRESSION", "ERROR") or d.regressions:
95
+ return "high"
96
+ if d.tests_failed:
97
+ return "high"
98
+ if any(a.status in ("REGRESSION", "ERROR") for a in d.previous_attempts):
99
+ return "medium"
100
+ if any(
101
+ h.change_type in ("removed", "signature_changed") and h.dependents > 2
102
+ for h in d.impact_hotspots
103
+ ):
104
+ return "medium"
105
+ if any(m.worsened for m in d.metric_deltas):
106
+ return "medium"
107
+ return "low"
108
+
109
+
110
+ __all__ = ["RulesProvider"]
@@ -0,0 +1,10 @@
1
+ """FastAPI application serving the dashboard and its JSON API.
2
+
3
+ Thin transport over :mod:`devmemory.services`. A per-request
4
+ :class:`~devmemory.services.context.ProjectContext` owns its own SQLite
5
+ connection so the threadpool-executed handlers never share one.
6
+ """
7
+
8
+ from devmemory.api.app import create_app
9
+
10
+ __all__ = ["create_app"]