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,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"]
|