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
devmemory/__about__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Single source of truth for the package version."""
2
+
3
+ __version__ = "0.1.0.dev0"
devmemory/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """DevMemory - development-memory and version-intelligence for AI-assisted software development.
2
+
3
+ Git remembers *what* changed. Entire remembers the AI-assisted development *context*.
4
+ DevMemory connects those changes with test results, metrics, feature status, regressions,
5
+ and previous attempts so that developers and future AI agents can understand the complete
6
+ development history - and avoid repeating past mistakes.
7
+
8
+ This package sits *alongside* existing tools (Git, Entire, editors, CI). It is not an IDE,
9
+ a version-control system, or an MLflow replacement.
10
+ """
11
+
12
+ from devmemory.__about__ import __version__
13
+
14
+ __all__ = ["__version__"]
devmemory/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Enable ``python -m devmemory``."""
2
+
3
+ from devmemory.cli.app import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,6 @@
1
+ """Adapters isolate external systems (git, Entire, test runners, Databricks).
2
+
3
+ Each returns normalized :mod:`devmemory.domain.models` types. The domain and
4
+ services layers never see a ``subprocess`` result, a git flag, or an Entire CLI
5
+ detail directly.
6
+ """
@@ -0,0 +1,346 @@
1
+ """Databricks adapter - publish normalized development telemetry to Delta tables
2
+ and run analytical queries.
3
+
4
+ REST only: the SQL Statement Execution API against a serverless SQL warehouse.
5
+ No Spark, no cluster. Credentials come from the environment
6
+ (``DATABRICKS_HOST`` / ``DATABRICKS_TOKEN`` / ``DATABRICKS_WAREHOUSE_ID``).
7
+
8
+ Only a fixed allowlist of normalized fields is ever sent - never source code,
9
+ transcripts, or secrets. A failure here never touches local history; the caller
10
+ falls back to the local analytics path.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from datetime import UTC, datetime
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ from devmemory.config import DevMemoryConfig, resolve_databricks_credentials
19
+ from devmemory.domain.errors import DatabricksError
20
+ from devmemory.domain.models import DevelopmentVersion
21
+ from devmemory.logging import get_logger
22
+
23
+ if TYPE_CHECKING:
24
+ from databricks.sdk import WorkspaceClient
25
+
26
+ _log = get_logger(__name__)
27
+
28
+ # Fields allowed to leave the machine. Anything not here is never published.
29
+ _VERSION_FIELDS = (
30
+ "project_id",
31
+ "version_id",
32
+ "version_number",
33
+ "intent",
34
+ "agent",
35
+ "model",
36
+ "checkpoint_id",
37
+ "association_method",
38
+ "association_confidence",
39
+ "git_commit",
40
+ "parent_commit",
41
+ "branch",
42
+ "feature",
43
+ "status",
44
+ "files_changed",
45
+ "lines_added",
46
+ "lines_removed",
47
+ "is_regression",
48
+ "regression_severity",
49
+ "tests_total",
50
+ "tests_passed",
51
+ "tests_failed",
52
+ "committed_at",
53
+ "recorded_at",
54
+ )
55
+
56
+
57
+ class DatabricksUnavailableError(DatabricksError):
58
+ """Databricks is not configured, the SDK is missing, or the workspace is unreachable."""
59
+
60
+
61
+ class DatabricksAdapter:
62
+ def __init__(self, config: DevMemoryConfig) -> None:
63
+ self._config = config
64
+ self._catalog = config.databricks.catalog
65
+ self._schema = config.databricks.schema_name
66
+ self._client: WorkspaceClient | None = None
67
+ self._warehouse_id: str = ""
68
+
69
+ @property
70
+ def is_configured(self) -> bool:
71
+ return resolve_databricks_credentials() is not None
72
+
73
+ def table(self, name: str) -> str:
74
+ return f"{self._catalog}.{self._schema}.{name}"
75
+
76
+ # -- connection ---------------------------------------------------
77
+
78
+ def _workspace(self) -> WorkspaceClient:
79
+ if self._client is not None:
80
+ return self._client
81
+ creds = resolve_databricks_credentials()
82
+ if creds is None:
83
+ raise DatabricksUnavailableError(
84
+ "Databricks credentials are not set.",
85
+ hint="Set DATABRICKS_HOST, DATABRICKS_TOKEN and DATABRICKS_WAREHOUSE_ID.",
86
+ )
87
+ try:
88
+ from databricks.sdk import WorkspaceClient
89
+ except ImportError as exc: # pragma: no cover - extra not installed
90
+ raise DatabricksUnavailableError(
91
+ "the databricks extra is not installed",
92
+ hint="pip install 'devmemory[databricks]'",
93
+ ) from exc
94
+ self._client = WorkspaceClient(host=creds.host, token=creds.token)
95
+ self._warehouse_id = creds.warehouse_id
96
+ return self._client
97
+
98
+ # -- statement execution (named `:param` markers only) -----------
99
+
100
+ def _execute(self, statement: str, params: dict[str, object] | None = None) -> Any:
101
+ from databricks.sdk.service.sql import StatementParameterListItem, StatementState
102
+
103
+ client = self._workspace()
104
+ parameters = (
105
+ [StatementParameterListItem(name=k, value=_as_param(v)) for k, v in params.items()]
106
+ if params
107
+ else None
108
+ )
109
+ try:
110
+ resp = client.statement_execution.execute_statement(
111
+ warehouse_id=self._warehouse_id,
112
+ statement=statement,
113
+ parameters=parameters,
114
+ wait_timeout="30s",
115
+ catalog=self._catalog,
116
+ schema=self._schema,
117
+ )
118
+ except Exception as exc:
119
+ raise DatabricksUnavailableError(f"statement failed: {exc}") from exc
120
+
121
+ state = resp.status.state if resp.status else None
122
+ if state is not StatementState.SUCCEEDED:
123
+ detail = resp.status.error.message if resp.status and resp.status.error else state
124
+ raise DatabricksError(f"Databricks statement did not succeed: {detail}")
125
+ return resp
126
+
127
+ def query(
128
+ self, statement: str, params: dict[str, object] | None = None
129
+ ) -> list[dict[str, Any]]:
130
+ resp = self._execute(statement, params)
131
+ schema = resp.manifest.schema if resp.manifest else None
132
+ cols = [c.name for c in (schema.columns or [])] if schema else []
133
+ data = (resp.result.data_array if resp.result else None) or []
134
+ return [dict(zip(cols, row, strict=False)) for row in data]
135
+
136
+ def execute(self, statement: str, params: dict[str, object] | None = None) -> None:
137
+ self._execute(statement, params)
138
+
139
+ # -- schema -----------------------------------------------------
140
+
141
+ def bootstrap(self) -> None:
142
+ """Create the schema and Delta tables if they do not exist. Idempotent."""
143
+ self.execute(f"CREATE SCHEMA IF NOT EXISTS {self._catalog}.{self._schema}")
144
+ for ddl in _SCHEMA_DDL:
145
+ self.execute(ddl.format(t=f"{self._catalog}.{self._schema}"))
146
+ _log.info("databricks.bootstrap", schema=f"{self._catalog}.{self._schema}")
147
+
148
+ # -- publish ---------------------------------------------------
149
+
150
+ def publish_version(self, version: DevelopmentVersion) -> None:
151
+ row = version_record(version)
152
+ self.bootstrap()
153
+
154
+ cols = ", ".join(_VERSION_FIELDS)
155
+ values = ", ".join(f":{f}" for f in _VERSION_FIELDS)
156
+ # Spark SQL rejects a column-alias list after the subquery (`s (a, b, ...)`);
157
+ # alias each column inside the SELECT instead.
158
+ source = ", ".join(f":{f} AS {f}" for f in _VERSION_FIELDS)
159
+ updates = ", ".join(
160
+ f"{f} = :{f}" for f in _VERSION_FIELDS if f not in ("project_id", "version_id")
161
+ )
162
+ merge = (
163
+ f"MERGE INTO {self.table('fact_versions')} t "
164
+ f"USING (SELECT {source}) s "
165
+ f"ON t.project_id = s.project_id AND t.version_id = s.version_id "
166
+ f"WHEN MATCHED THEN UPDATE SET {updates} "
167
+ f"WHEN NOT MATCHED THEN INSERT ({cols}) VALUES ({values})"
168
+ )
169
+ self.execute(merge, {f: row.get(f) for f in _VERSION_FIELDS})
170
+ self._publish_children(version)
171
+ _log.info("databricks.published", version=version.version_id)
172
+
173
+ def _publish_children(self, version: DevelopmentVersion) -> None:
174
+ vid, pid = version.version_id, self._config.project_id
175
+ scope: dict[str, object] = {"pid": pid, "vid": vid}
176
+ for table in ("fact_changed_files", "fact_metrics", "fact_tests", "fact_regressions"):
177
+ self.execute(
178
+ f"DELETE FROM {self.table(table)} WHERE project_id = :pid AND version_id = :vid",
179
+ scope,
180
+ )
181
+ for i, f in enumerate(version.changed_files):
182
+ self.execute(
183
+ f"INSERT INTO {self.table('fact_changed_files')} "
184
+ "(project_id, version_id, path, change_type, additions, deletions) "
185
+ "VALUES (:pid, :vid, :path, :ct, :add, :del)",
186
+ {
187
+ **scope,
188
+ "path": f.path,
189
+ "ct": f.change_type.value,
190
+ "add": f.additions,
191
+ "del": f.deletions,
192
+ "_i": i,
193
+ },
194
+ )
195
+ for m in version.metrics:
196
+ self.execute(
197
+ f"INSERT INTO {self.table('fact_metrics')} "
198
+ "(project_id, version_id, name, before_value, after_value, unit, direction) "
199
+ "VALUES (:pid, :vid, :name, :before, :after, :unit, :dir)",
200
+ {
201
+ **scope,
202
+ "name": m.name,
203
+ "before": m.before,
204
+ "after": m.after,
205
+ "unit": m.unit,
206
+ "dir": m.direction.value,
207
+ },
208
+ )
209
+ if version.tests and version.tests.ran:
210
+ t = version.tests
211
+ self.execute(
212
+ f"INSERT INTO {self.table('fact_tests')} "
213
+ "(project_id, version_id, total, passed, failed, skipped, command) "
214
+ "VALUES (:pid, :vid, :total, :passed, :failed, :skipped, :cmd)",
215
+ {
216
+ **scope,
217
+ "total": t.total,
218
+ "passed": t.passed,
219
+ "failed": t.failed,
220
+ "skipped": t.skipped,
221
+ "cmd": t.command,
222
+ },
223
+ )
224
+ for r in version.regressions:
225
+ self.execute(
226
+ f"INSERT INTO {self.table('fact_regressions')} "
227
+ "(project_id, version_id, kind, metric, before_value, after_value, "
228
+ "change_percent, severity) "
229
+ "VALUES (:pid, :vid, :kind, :metric, :before, :after, :pct, :sev)",
230
+ {
231
+ **scope,
232
+ "kind": r.kind,
233
+ "metric": r.metric,
234
+ "before": r.before,
235
+ "after": r.after,
236
+ "pct": r.change_percent,
237
+ "sev": r.severity,
238
+ },
239
+ )
240
+
241
+
242
+ # --- record shaping -----------------------------------------------------------
243
+
244
+
245
+ def version_record(version: DevelopmentVersion) -> dict[str, Any]:
246
+ """The exact allowlisted row published for a version. No source, no secrets."""
247
+ cp = version.primary_checkpoint
248
+ worst = max(
249
+ (r.severity for r in version.regressions),
250
+ key=lambda s: {"HIGH": 3, "MEDIUM": 2, "LOW": 1}.get(s, 0),
251
+ default=None,
252
+ )
253
+ record: dict[str, Any] = {
254
+ "project_id": version.project_id,
255
+ "version_id": version.version_id,
256
+ "version_number": version.version_number,
257
+ "intent": (version.intent or "")[:2000] or None,
258
+ "agent": version.agent,
259
+ "model": version.model,
260
+ "checkpoint_id": cp.checkpoint_id if cp else None,
261
+ "association_method": version.entire_association_method.value,
262
+ "association_confidence": version.entire_association_confidence,
263
+ "git_commit": version.git_commit,
264
+ "parent_commit": version.parent_commit,
265
+ "branch": version.branch,
266
+ "feature": version.feature_id.split(":", 1)[-1] if version.feature_id else None,
267
+ "status": version.status.value,
268
+ "files_changed": version.files_changed,
269
+ "lines_added": version.lines_added,
270
+ "lines_removed": version.lines_removed,
271
+ "is_regression": bool(version.regressions) or version.status.value == "REGRESSION",
272
+ "regression_severity": worst,
273
+ "tests_total": version.tests.total if version.tests and version.tests.ran else None,
274
+ "tests_passed": version.tests.passed if version.tests and version.tests.ran else None,
275
+ "tests_failed": version.tests.failed if version.tests and version.tests.ran else None,
276
+ "committed_at": version.committed_at.isoformat() if version.committed_at else None,
277
+ "recorded_at": version.created_at.isoformat(),
278
+ }
279
+ return {k: record.get(k) for k in _VERSION_FIELDS}
280
+
281
+
282
+ def outbox_event(version: DevelopmentVersion) -> dict[str, Any]:
283
+ return {
284
+ "kind": "version",
285
+ "queued_at": datetime.now(UTC).isoformat(),
286
+ "record": version_record(version),
287
+ "changed_files": [
288
+ {
289
+ "path": f.path,
290
+ "change_type": f.change_type.value,
291
+ "additions": f.additions,
292
+ "deletions": f.deletions,
293
+ }
294
+ for f in version.changed_files
295
+ ],
296
+ "metrics": [
297
+ {
298
+ "name": m.name,
299
+ "before": m.before,
300
+ "after": m.after,
301
+ "unit": m.unit,
302
+ "direction": m.direction.value,
303
+ }
304
+ for m in version.metrics
305
+ ],
306
+ "regressions": [r.model_dump(mode="json") for r in version.regressions],
307
+ }
308
+
309
+
310
+ def _as_param(value: object) -> str | None:
311
+ if value is None:
312
+ return None
313
+ if isinstance(value, bool):
314
+ return "true" if value else "false"
315
+ return str(value)
316
+
317
+
318
+ _SCHEMA_DDL = (
319
+ """CREATE TABLE IF NOT EXISTS {t}.fact_versions (
320
+ project_id STRING, version_id STRING, version_number INT, intent STRING,
321
+ agent STRING, model STRING, checkpoint_id STRING, association_method STRING,
322
+ association_confidence DOUBLE, git_commit STRING, parent_commit STRING, branch STRING,
323
+ feature STRING, status STRING, files_changed INT, lines_added INT, lines_removed INT,
324
+ is_regression BOOLEAN, regression_severity STRING, tests_total INT, tests_passed INT,
325
+ tests_failed INT, committed_at STRING, recorded_at STRING
326
+ ) USING DELTA""",
327
+ """CREATE TABLE IF NOT EXISTS {t}.fact_changed_files (
328
+ project_id STRING, version_id STRING, path STRING, change_type STRING,
329
+ additions INT, deletions INT
330
+ ) USING DELTA""",
331
+ """CREATE TABLE IF NOT EXISTS {t}.fact_metrics (
332
+ project_id STRING, version_id STRING, name STRING, before_value DOUBLE,
333
+ after_value DOUBLE, unit STRING, direction STRING
334
+ ) USING DELTA""",
335
+ """CREATE TABLE IF NOT EXISTS {t}.fact_tests (
336
+ project_id STRING, version_id STRING, total INT, passed INT, failed INT,
337
+ skipped INT, command STRING
338
+ ) USING DELTA""",
339
+ """CREATE TABLE IF NOT EXISTS {t}.fact_regressions (
340
+ project_id STRING, version_id STRING, kind STRING, metric STRING,
341
+ before_value DOUBLE, after_value DOUBLE, change_percent DOUBLE, severity STRING
342
+ ) USING DELTA""",
343
+ )
344
+
345
+
346
+ __all__ = ["DatabricksAdapter", "DatabricksUnavailableError", "outbox_event", "version_record"]