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
devmemory/cli/init.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""``devmemory init`` - set DevMemory up in the current repository."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from devmemory.adapters.entire import EntireAdapter
|
|
12
|
+
from devmemory.cli._render import check, console, hint, kv_table, success, warn
|
|
13
|
+
from devmemory.domain.models import EntireStatus
|
|
14
|
+
from devmemory.services.projects import init_project
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def init_command(
|
|
18
|
+
name: Annotated[str | None, typer.Option("--name", help="Human-readable project name.")] = None,
|
|
19
|
+
project_id: Annotated[
|
|
20
|
+
str | None,
|
|
21
|
+
typer.Option("--project-id", help="Stable slug id (default: derived from name)."),
|
|
22
|
+
] = None,
|
|
23
|
+
force: Annotated[
|
|
24
|
+
bool, typer.Option("--force", help="Re-create configuration even if already initialized.")
|
|
25
|
+
] = False,
|
|
26
|
+
as_json: Annotated[bool, typer.Option("--json", help="Emit the init report as JSON.")] = False,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Initialize DevMemory tracking in the current git repository.
|
|
29
|
+
|
|
30
|
+
Creates a `.devmemory/` directory (config, SQLite database), registers the
|
|
31
|
+
project, and reports what git and Entire look like here.
|
|
32
|
+
"""
|
|
33
|
+
repo_path = Path.cwd()
|
|
34
|
+
entire = EntireAdapter(repo_path).probe()
|
|
35
|
+
report = init_project(
|
|
36
|
+
repo_path,
|
|
37
|
+
name=name,
|
|
38
|
+
project_id=project_id,
|
|
39
|
+
force=force,
|
|
40
|
+
entire_probe=entire,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if as_json:
|
|
44
|
+
console.print_json(json.dumps(report.model_dump(mode="json")))
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
success(f"DevMemory initialized for [bold]{report.project.name}[/bold]")
|
|
48
|
+
console.print(
|
|
49
|
+
kv_table(
|
|
50
|
+
[
|
|
51
|
+
("project id", report.project.project_id),
|
|
52
|
+
("repository", report.project.repo_path),
|
|
53
|
+
("config", report.config_path),
|
|
54
|
+
("database", report.db_path),
|
|
55
|
+
("git", report.git_version or "detected"),
|
|
56
|
+
("entire", _entire_line(report.entire)),
|
|
57
|
+
("python", report.environment.python_version),
|
|
58
|
+
]
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if report.gitignore_updated:
|
|
63
|
+
console.print("[dim]added DevMemory entries to .gitignore[/dim]")
|
|
64
|
+
if not report.entire.installed:
|
|
65
|
+
warn("Entire CLI not found - versions will be recorded without checkpoint context.")
|
|
66
|
+
hint("Install Entire from https://entire.io, then run `entire enable`.")
|
|
67
|
+
elif not report.entire.enabled:
|
|
68
|
+
warn("Entire is installed but not enabled in this repository.")
|
|
69
|
+
hint("Run `entire enable` so AI sessions are captured as checkpoints.")
|
|
70
|
+
|
|
71
|
+
console.print()
|
|
72
|
+
console.print(
|
|
73
|
+
"Next: make an AI-assisted change, commit it, then run [bold]devmemory checkpoint[/bold]."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _entire_line(status: EntireStatus) -> object:
|
|
78
|
+
if not status.installed:
|
|
79
|
+
return check(False)
|
|
80
|
+
label = f"{status.cli_version or 'installed'}"
|
|
81
|
+
if status.enabled:
|
|
82
|
+
label += " (enabled"
|
|
83
|
+
if status.agents:
|
|
84
|
+
label += f", {', '.join(status.agents)}"
|
|
85
|
+
label += ")"
|
|
86
|
+
else:
|
|
87
|
+
label += " (not enabled)"
|
|
88
|
+
return label
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
__all__ = ["init_command"]
|
devmemory/cli/mcp.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""``devmemory mcp`` - run the Model Context Protocol server over stdio."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from devmemory.cli._render import err_console
|
|
13
|
+
from devmemory.domain.errors import DevMemoryError
|
|
14
|
+
from devmemory.services.context import ProjectContext
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def mcp_command(
|
|
18
|
+
repo: Annotated[
|
|
19
|
+
Path | None,
|
|
20
|
+
typer.Option("--repo", help="Repository to serve (default: discover from the cwd)."),
|
|
21
|
+
] = None,
|
|
22
|
+
print_config: Annotated[
|
|
23
|
+
bool,
|
|
24
|
+
typer.Option(
|
|
25
|
+
"--print-config",
|
|
26
|
+
help="Print an .mcp.json fragment for this repo and exit (no server).",
|
|
27
|
+
),
|
|
28
|
+
] = False,
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Expose this project's development memory to an AI agent over MCP (stdio).
|
|
31
|
+
|
|
32
|
+
Wire it into an MCP client (Claude Code, Cursor, ...) with the fragment from
|
|
33
|
+
`devmemory mcp --print-config`. The server is read-only.
|
|
34
|
+
"""
|
|
35
|
+
with ProjectContext.load(repo) as ctx:
|
|
36
|
+
root = ctx.paths.repo_root
|
|
37
|
+
|
|
38
|
+
if print_config:
|
|
39
|
+
fragment = {
|
|
40
|
+
"mcpServers": {
|
|
41
|
+
"devmemory": {
|
|
42
|
+
"command": "devmemory",
|
|
43
|
+
"args": ["mcp", "--repo", str(root)],
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
# plain stdout, not Rich - this is meant to be copy-pasted into a JSON file
|
|
48
|
+
sys.stdout.write(json.dumps(fragment, indent=2) + "\n")
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
import fastmcp # noqa: F401
|
|
53
|
+
except ImportError as exc: # pragma: no cover - extra not installed
|
|
54
|
+
raise DevMemoryError(
|
|
55
|
+
"the 'mcp' extra is not installed",
|
|
56
|
+
hint="pip install 'devmemory[mcp]'",
|
|
57
|
+
) from exc
|
|
58
|
+
|
|
59
|
+
from devmemory.mcp.server import build_server
|
|
60
|
+
|
|
61
|
+
# stdio transport owns stdout; keep our chatter on stderr.
|
|
62
|
+
err_console.print(f"[dim]devmemory mcp: serving {root} over stdio[/dim]")
|
|
63
|
+
build_server(root).run(transport="stdio", show_banner=False)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
__all__ = ["mcp_command"]
|
devmemory/cli/memory.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""``devmemory memory`` - what failed before, so it isn't tried again."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
|
|
11
|
+
from devmemory.cli._render import console, status_text
|
|
12
|
+
from devmemory.services.context import ProjectContext
|
|
13
|
+
from devmemory.services.memory import MemoryQuery, previous_attempts
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def memory_command(
|
|
17
|
+
files: Annotated[
|
|
18
|
+
list[str] | None,
|
|
19
|
+
typer.Option("--file", "-F", help="A path being changed (repeatable)."),
|
|
20
|
+
] = None,
|
|
21
|
+
feature: Annotated[str | None, typer.Option("--feature", "-f", help="Feature area.")] = None,
|
|
22
|
+
intent: Annotated[
|
|
23
|
+
str | None, typer.Option("--intent", "-i", help="What you're about to do.")
|
|
24
|
+
] = None,
|
|
25
|
+
successes: Annotated[
|
|
26
|
+
bool, typer.Option("--include-successes", help="Also show relevant successful attempts.")
|
|
27
|
+
] = False,
|
|
28
|
+
limit: Annotated[int, typer.Option("--limit", "-n")] = 10,
|
|
29
|
+
as_json: Annotated[bool, typer.Option("--json")] = False,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Show previous development attempts relevant to a change - especially failed ones."""
|
|
32
|
+
query = MemoryQuery(
|
|
33
|
+
files=list(files or []),
|
|
34
|
+
feature=feature,
|
|
35
|
+
intent=intent,
|
|
36
|
+
include_successes=successes,
|
|
37
|
+
limit=limit,
|
|
38
|
+
)
|
|
39
|
+
with ProjectContext.load() as ctx:
|
|
40
|
+
attempts = previous_attempts(ctx, query)
|
|
41
|
+
|
|
42
|
+
if as_json:
|
|
43
|
+
console.print_json(json.dumps([a.model_dump(mode="json") for a in attempts]))
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
if not attempts:
|
|
47
|
+
console.print("[dim]No relevant previous attempts. Nothing to avoid — yet.[/dim]")
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
for a in attempts:
|
|
51
|
+
head = f"[bold]{a.version_id.upper()}[/bold] "
|
|
52
|
+
body = (
|
|
53
|
+
f"[dim]intent[/dim] {a.intent or '—'}\n"
|
|
54
|
+
f"[dim]change[/dim] {a.change_summary}\n"
|
|
55
|
+
f"[dim]result[/dim] {a.result}\n"
|
|
56
|
+
f"[dim]matched[/dim] {'; '.join(a.matched_on)}"
|
|
57
|
+
)
|
|
58
|
+
if a.recommendation:
|
|
59
|
+
body += f"\n[dim]advice[/dim] [cyan]{a.recommendation}[/cyan]"
|
|
60
|
+
console.print(
|
|
61
|
+
Panel(
|
|
62
|
+
body,
|
|
63
|
+
title=head + str(status_text(a.status)),
|
|
64
|
+
title_align="left",
|
|
65
|
+
border_style="red" if a.is_adverse else "dim",
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
__all__ = ["memory_command"]
|
devmemory/cli/restore.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""``devmemory restore`` - move the working tree back to an earlier version, safely."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from devmemory.cli._render import console, hint, kv_table, success, warn
|
|
11
|
+
from devmemory.services.context import ProjectContext
|
|
12
|
+
from devmemory.services.restore import restore_preview, restore_version
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def restore_command(
|
|
16
|
+
version: Annotated[str, typer.Argument(help="Version to restore: v7 / 7 / a commit prefix.")],
|
|
17
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip the confirmation prompt.")] = False,
|
|
18
|
+
hard: Annotated[
|
|
19
|
+
bool,
|
|
20
|
+
typer.Option(
|
|
21
|
+
"--hard", help="`git reset --hard` instead of a detached checkout (requires -y)."
|
|
22
|
+
),
|
|
23
|
+
] = False,
|
|
24
|
+
allow_dirty: Annotated[
|
|
25
|
+
bool,
|
|
26
|
+
typer.Option(
|
|
27
|
+
"--allow-dirty", help="Proceed with uncommitted changes (a safety stash is kept)."
|
|
28
|
+
),
|
|
29
|
+
] = False,
|
|
30
|
+
as_json: Annotated[bool, typer.Option("--json")] = False,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Restore the source tree to the git state of a previous development version.
|
|
33
|
+
|
|
34
|
+
Always previews first. A safety tag is created at the current HEAD (and a
|
|
35
|
+
``git stash create`` reference if the tree is dirty) before anything moves.
|
|
36
|
+
"""
|
|
37
|
+
with ProjectContext.load() as ctx:
|
|
38
|
+
preview = restore_preview(ctx, version)
|
|
39
|
+
|
|
40
|
+
if as_json and not yes:
|
|
41
|
+
console.print_json(json.dumps(preview.model_dump(mode="json")))
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
if preview.already_there:
|
|
45
|
+
success(f"Already at {preview.version_id.upper()} with a clean tree.")
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
console.print()
|
|
49
|
+
console.print(f"[bold]Restore {preview.version_id.upper()}[/bold]")
|
|
50
|
+
console.print(
|
|
51
|
+
kv_table(
|
|
52
|
+
[
|
|
53
|
+
("target", f"{preview.target_commit[:12]} {preview.target_subject}"),
|
|
54
|
+
(
|
|
55
|
+
"current",
|
|
56
|
+
f"{(preview.current_commit or '-')[:12]}"
|
|
57
|
+
+ (f" ({preview.current_branch})" if preview.current_branch else ""),
|
|
58
|
+
),
|
|
59
|
+
("mode", "reset --hard" if hard else "detached checkout"),
|
|
60
|
+
("safety tag", preview.safety_tag),
|
|
61
|
+
]
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
if preview.uncommitted:
|
|
65
|
+
warn(
|
|
66
|
+
f"{len(preview.uncommitted)} uncommitted change(s): {', '.join(preview.uncommitted[:5])}"
|
|
67
|
+
)
|
|
68
|
+
console.print(f"\n[yellow]{preview.warning}[/yellow]\n")
|
|
69
|
+
|
|
70
|
+
if hard and not yes:
|
|
71
|
+
raise typer.BadParameter("--hard requires --yes")
|
|
72
|
+
if not yes and not typer.confirm("Proceed with the restore?"):
|
|
73
|
+
console.print("[dim]cancelled[/dim]")
|
|
74
|
+
raise typer.Exit(1)
|
|
75
|
+
|
|
76
|
+
result = restore_version(
|
|
77
|
+
ctx,
|
|
78
|
+
version,
|
|
79
|
+
mode="hard" if hard else "detach",
|
|
80
|
+
allow_dirty=allow_dirty,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if as_json:
|
|
84
|
+
console.print_json(json.dumps(result.model_dump(mode="json")))
|
|
85
|
+
return
|
|
86
|
+
success(result.message)
|
|
87
|
+
if result.stash_ref:
|
|
88
|
+
hint(f"uncommitted work saved as {result.stash_ref[:12]}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
__all__ = ["restore_command"]
|
devmemory/cli/search.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""``devmemory search`` - full-text search across development history."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from devmemory.cli._render import console, status_text
|
|
11
|
+
from devmemory.services.context import ProjectContext
|
|
12
|
+
from devmemory.services.versions import search_versions
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def search_command(
|
|
16
|
+
query: Annotated[
|
|
17
|
+
str, typer.Argument(help="Query: authentication, model.py, Codex, learning rate…")
|
|
18
|
+
],
|
|
19
|
+
limit: Annotated[int, typer.Option("--limit", "-n")] = 25,
|
|
20
|
+
as_json: Annotated[bool, typer.Option("--json")] = False,
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Search versions by intent, agent, feature, files, commit, checkpoint, or analysis."""
|
|
23
|
+
with ProjectContext.load() as ctx:
|
|
24
|
+
results = search_versions(ctx, query, limit=limit)
|
|
25
|
+
|
|
26
|
+
if as_json:
|
|
27
|
+
console.print_json(json.dumps([v.model_dump(mode="json") for v in results]))
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
if not results:
|
|
31
|
+
console.print(f"[dim]No matches for “{query}”.[/dim]")
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
console.print()
|
|
35
|
+
for v in results:
|
|
36
|
+
console.print(
|
|
37
|
+
f"[bold]{v.version_id.upper()}[/bold] ",
|
|
38
|
+
status_text(v.status),
|
|
39
|
+
f" [dim]{v.feature_id.split(':')[-1] if v.feature_id else ''}[/dim]",
|
|
40
|
+
)
|
|
41
|
+
console.print(f" {v.intent or '—'}")
|
|
42
|
+
console.print(
|
|
43
|
+
f" [dim]{v.agent or '—'} · {v.git_commit[:12]} · "
|
|
44
|
+
f"{', '.join(f.path for f in v.changed_files[:3])}[/dim]\n"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
__all__ = ["search_command"]
|
devmemory/cli/serve.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""``devmemory serve`` - the local dashboard + API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import socket
|
|
7
|
+
import webbrowser
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from devmemory.cli._render import console, success
|
|
13
|
+
from devmemory.services.context import ProjectContext
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def serve_command(
|
|
17
|
+
host: Annotated[str | None, typer.Option("--host", help="Bind address.")] = None,
|
|
18
|
+
port: Annotated[int | None, typer.Option("--port", "-p", help="Preferred port.")] = None,
|
|
19
|
+
open_browser: Annotated[
|
|
20
|
+
bool, typer.Option("--open/--no-open", help="Open the dashboard in a browser.")
|
|
21
|
+
] = True,
|
|
22
|
+
enable_restore: Annotated[
|
|
23
|
+
bool,
|
|
24
|
+
typer.Option("--enable-restore", help="Allow the restore endpoint (off by default)."),
|
|
25
|
+
] = False,
|
|
26
|
+
reload: Annotated[bool, typer.Option("--reload", hidden=True)] = False,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Start the DevMemory web dashboard on localhost."""
|
|
29
|
+
# Imported here so `fastapi`/`uvicorn` don't slow down every other command.
|
|
30
|
+
import uvicorn
|
|
31
|
+
|
|
32
|
+
from devmemory.api.app import create_app
|
|
33
|
+
|
|
34
|
+
with ProjectContext.load() as ctx:
|
|
35
|
+
repo_root = ctx.paths.repo_root
|
|
36
|
+
bind_host = host or ctx.config.web.host
|
|
37
|
+
want_port = port or ctx.config.web.port
|
|
38
|
+
allow_restore = enable_restore or ctx.config.web.enable_restore
|
|
39
|
+
|
|
40
|
+
chosen = _pick_port(bind_host, want_port)
|
|
41
|
+
url = f"http://{bind_host}:{chosen}"
|
|
42
|
+
|
|
43
|
+
app = create_app(repo_root, enable_restore=allow_restore)
|
|
44
|
+
success(f"DevMemory dashboard → [bold]{url}[/bold]")
|
|
45
|
+
console.print(f"[dim]API docs: {url}/api/docs · Ctrl-C to stop[/dim]")
|
|
46
|
+
if open_browser:
|
|
47
|
+
with contextlib.suppress(Exception):
|
|
48
|
+
webbrowser.open(url)
|
|
49
|
+
|
|
50
|
+
uvicorn.run(app, host=bind_host, port=chosen, log_level="warning", reload=reload)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _pick_port(host: str, preferred: int) -> int:
|
|
54
|
+
for candidate in range(preferred, preferred + 20):
|
|
55
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
56
|
+
try:
|
|
57
|
+
sock.bind((host, candidate))
|
|
58
|
+
except OSError:
|
|
59
|
+
continue
|
|
60
|
+
return candidate
|
|
61
|
+
return preferred
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
__all__ = ["serve_command"]
|
devmemory/cli/show.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""``devmemory show <version>`` - the full development record for one version."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.syntax import Syntax
|
|
11
|
+
|
|
12
|
+
from devmemory.cli._render import console, kv_table, status_text
|
|
13
|
+
from devmemory.services.context import ProjectContext
|
|
14
|
+
from devmemory.services.versions import get_version
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def show_command(
|
|
18
|
+
version: Annotated[str, typer.Argument(help="Version ref: v7, 7, or a commit prefix.")],
|
|
19
|
+
diff: Annotated[bool, typer.Option("--diff", help="Also print the git diff.")] = False,
|
|
20
|
+
as_json: Annotated[bool, typer.Option("--json", help="Emit as JSON.")] = False,
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Show one version: intent, agent, checkpoint, commit, files, tests, metrics, status."""
|
|
23
|
+
with ProjectContext.load() as ctx:
|
|
24
|
+
v = get_version(ctx, version)
|
|
25
|
+
diff_text = ctx.git.diff_text(v.parent_commit, v.git_commit) if diff else None
|
|
26
|
+
|
|
27
|
+
if as_json:
|
|
28
|
+
console.print_json(json.dumps(v.model_dump(mode="json")))
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
console.print()
|
|
32
|
+
console.print(f"[bold cyan]{v.version_id.upper()}[/bold cyan] ", status_text(v.status))
|
|
33
|
+
console.print()
|
|
34
|
+
|
|
35
|
+
rows: list[tuple[str, object]] = [("intent", v.intent or "-")]
|
|
36
|
+
if v.agent:
|
|
37
|
+
rows.append(("agent", f"{v.agent}" + (f" ({v.model})" if v.model else "")))
|
|
38
|
+
if v.primary_checkpoint:
|
|
39
|
+
cp = v.primary_checkpoint
|
|
40
|
+
line = f"{cp.checkpoint_id} [dim]{cp.association_method.value}[/dim]"
|
|
41
|
+
if cp.is_uncertain:
|
|
42
|
+
line += f" [yellow]confidence {cp.association_confidence:.2f}[/yellow]"
|
|
43
|
+
rows.append(("entire", line))
|
|
44
|
+
rows.append(
|
|
45
|
+
("commit", f"{v.git_commit[:12]} [dim](parent {(v.parent_commit or '-')[:12]})[/dim]")
|
|
46
|
+
)
|
|
47
|
+
if v.branch:
|
|
48
|
+
rows.append(("branch", v.branch))
|
|
49
|
+
if v.feature_id:
|
|
50
|
+
rows.append(("feature", v.feature_id.split(":", 1)[-1]))
|
|
51
|
+
rows.append(
|
|
52
|
+
(
|
|
53
|
+
"changes",
|
|
54
|
+
f"{v.files_changed} files [green]+{v.lines_added}[/green] [red]-{v.lines_removed}[/red]",
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
if v.tests and v.tests.ran:
|
|
58
|
+
rows.append(
|
|
59
|
+
(
|
|
60
|
+
"tests",
|
|
61
|
+
f"{v.tests.passed} passed / {v.tests.failed} failed / {v.tests.skipped} skipped",
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
if v.artifacts:
|
|
65
|
+
a = v.artifacts[0]
|
|
66
|
+
kb = f"{a.size_bytes / 1024:.0f} KB" if a.size_bytes else "?"
|
|
67
|
+
rows.append(("snapshot", f"{a.path} [dim]{kb}[/dim]"))
|
|
68
|
+
if v.committed_at:
|
|
69
|
+
rows.append(("committed", v.committed_at.isoformat(timespec="minutes")))
|
|
70
|
+
console.print(kv_table(rows))
|
|
71
|
+
|
|
72
|
+
if v.metrics:
|
|
73
|
+
console.print()
|
|
74
|
+
mt = kv_table(
|
|
75
|
+
(
|
|
76
|
+
m.name,
|
|
77
|
+
f"{m.before} → {m.after}"
|
|
78
|
+
+ (f" {m.unit}" if m.unit else "")
|
|
79
|
+
+ (
|
|
80
|
+
" [green]improvement[/green]"
|
|
81
|
+
if m.is_improvement
|
|
82
|
+
else " [red]worse[/red]"
|
|
83
|
+
if m.is_worse
|
|
84
|
+
else ""
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
for m in v.metrics
|
|
88
|
+
)
|
|
89
|
+
console.print(Panel(mt, title="metrics", title_align="left", border_style="dim"))
|
|
90
|
+
|
|
91
|
+
if v.changed_files:
|
|
92
|
+
console.print()
|
|
93
|
+
files = "\n".join(
|
|
94
|
+
f" {_mark(f.change_type.value)} {f.path}"
|
|
95
|
+
+ (
|
|
96
|
+
f" [green]+{f.additions}[/green] [red]-{f.deletions}[/red]"
|
|
97
|
+
if not f.binary
|
|
98
|
+
else " [dim]binary[/dim]"
|
|
99
|
+
)
|
|
100
|
+
for f in v.changed_files
|
|
101
|
+
)
|
|
102
|
+
console.print(Panel(files, title="files", title_align="left", border_style="dim"))
|
|
103
|
+
|
|
104
|
+
if v.regressions:
|
|
105
|
+
console.print()
|
|
106
|
+
for r in v.regressions:
|
|
107
|
+
console.print(f"[bold red]regression[/bold red] {r.metric or r.kind}: {r.detail}")
|
|
108
|
+
|
|
109
|
+
if v.analysis and v.analysis.summary:
|
|
110
|
+
console.print()
|
|
111
|
+
body = v.analysis.summary
|
|
112
|
+
if v.analysis.recommendation:
|
|
113
|
+
body += f"\n\n[bold]recommendation:[/bold] {v.analysis.recommendation}"
|
|
114
|
+
console.print(
|
|
115
|
+
Panel(
|
|
116
|
+
body,
|
|
117
|
+
title=f"analysis ([dim]{v.analysis.provider}[/dim])",
|
|
118
|
+
title_align="left",
|
|
119
|
+
border_style="dim",
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if diff_text:
|
|
124
|
+
console.print()
|
|
125
|
+
console.print(Syntax(diff_text, "diff", theme="ansi_dark", word_wrap=False))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _mark(change_type: str) -> str:
|
|
129
|
+
return {
|
|
130
|
+
"added": "[green]A[/green]",
|
|
131
|
+
"modified": "[yellow]M[/yellow]",
|
|
132
|
+
"deleted": "[red]D[/red]",
|
|
133
|
+
"renamed": "[cyan]R[/cyan]",
|
|
134
|
+
"copied": "[cyan]C[/cyan]",
|
|
135
|
+
"type_changed": "[magenta]T[/magenta]",
|
|
136
|
+
}.get(change_type, "?")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
__all__ = ["show_command"]
|
devmemory/cli/status.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""``devmemory status`` - where the project stands right now."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from devmemory.cli._render import check, console, kv_table, warn
|
|
11
|
+
from devmemory.domain.models import EntireStatus
|
|
12
|
+
from devmemory.services.context import ProjectContext
|
|
13
|
+
from devmemory.services.projects import project_status
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def status_command(
|
|
17
|
+
as_json: Annotated[bool, typer.Option("--json", help="Emit status as JSON.")] = False,
|
|
18
|
+
) -> None:
|
|
19
|
+
"""Show the current project, git HEAD, working-tree cleanliness, and Entire state."""
|
|
20
|
+
with ProjectContext.load() as ctx:
|
|
21
|
+
report = project_status(ctx, entire_probe=ctx.entire.probe())
|
|
22
|
+
|
|
23
|
+
if as_json:
|
|
24
|
+
console.print_json(json.dumps(report.model_dump(mode="json")))
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
head = report.head_sha[:12] if report.head_sha else "(no commits yet)"
|
|
28
|
+
rows: list[tuple[str, object]] = [
|
|
29
|
+
("project", f"{report.project.name} [dim]({report.project.project_id})[/dim]"),
|
|
30
|
+
("branch", report.branch or "[dim]detached[/dim]"),
|
|
31
|
+
("HEAD", f"{head} {report.head_subject or ''}".rstrip()),
|
|
32
|
+
("working tree", check(report.working_tree_clean)),
|
|
33
|
+
("versions", str(report.version_count)),
|
|
34
|
+
]
|
|
35
|
+
if report.latest_version_id:
|
|
36
|
+
rows.append(
|
|
37
|
+
(
|
|
38
|
+
"latest",
|
|
39
|
+
f"{report.latest_version_id.upper()} {report.latest_status}"
|
|
40
|
+
+ (f" [dim]{report.latest_intent}[/dim]" if report.latest_intent else ""),
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
if report.latest_metrics:
|
|
44
|
+
rows.append(
|
|
45
|
+
(
|
|
46
|
+
"metrics",
|
|
47
|
+
" ".join(f"{k}={v}" for k, v in report.latest_metrics.items() if v is not None),
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
if report.open_features:
|
|
51
|
+
rows.append(("in progress", ", ".join(report.open_features)))
|
|
52
|
+
if report.last_regression_id:
|
|
53
|
+
rows.append(("last regression", report.last_regression_id.upper()))
|
|
54
|
+
rows.append(("entire", _entire_summary(report.entire)))
|
|
55
|
+
console.print(kv_table(rows))
|
|
56
|
+
|
|
57
|
+
if not report.working_tree_clean:
|
|
58
|
+
warn("uncommitted changes present")
|
|
59
|
+
if report.head_sha and not report.head_has_version:
|
|
60
|
+
warn("HEAD is not recorded yet - run `devmemory checkpoint`")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _entire_summary(status: EntireStatus) -> str:
|
|
64
|
+
if not status.installed:
|
|
65
|
+
return "not installed"
|
|
66
|
+
if not status.enabled:
|
|
67
|
+
return f"{status.cli_version or 'installed'}, not enabled"
|
|
68
|
+
agents = f" [{', '.join(status.agents)}]" if status.agents else ""
|
|
69
|
+
return f"{status.cli_version or 'enabled'}, enabled{agents}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
__all__ = ["status_command"]
|