devmemory 0.1.0__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/__init__.py +2 -0
- devmemory/cli.py +99 -0
- devmemory/commands/__init__.py +1 -0
- devmemory/commands/add.py +160 -0
- devmemory/commands/config_cmd.py +44 -0
- devmemory/commands/context.py +285 -0
- devmemory/commands/install.py +200 -0
- devmemory/commands/learn.py +216 -0
- devmemory/commands/search.py +245 -0
- devmemory/commands/status.py +71 -0
- devmemory/commands/sync.py +125 -0
- devmemory/core/__init__.py +1 -0
- devmemory/core/ams_client.py +113 -0
- devmemory/core/config.py +42 -0
- devmemory/core/git_ai_parser.py +362 -0
- devmemory/core/llm_client.py +119 -0
- devmemory/core/memory_formatter.py +445 -0
- devmemory/core/sync_state.py +41 -0
- devmemory/hooks/__init__.py +1 -0
- devmemory/hooks/post_commit.py +6 -0
- devmemory/rules/devmemory-context.mdc +16 -0
- devmemory/rules/devmemory.mdc +203 -0
- devmemory-0.1.0.dist-info/METADATA +383 -0
- devmemory-0.1.0.dist-info/RECORD +27 -0
- devmemory-0.1.0.dist-info/WHEEL +4 -0
- devmemory-0.1.0.dist-info/entry_points.txt +2 -0
- devmemory-0.1.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import pathlib
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
|
|
6
|
+
from devmemory.core.config import DevMemoryConfig
|
|
7
|
+
from devmemory.core.sync_state import SyncState
|
|
8
|
+
from devmemory.core.git_ai_parser import get_repo_root, get_git_ai_version, is_git_ai_installed
|
|
9
|
+
from devmemory.core.ams_client import AMSClient
|
|
10
|
+
from devmemory import __version__
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run_status():
|
|
16
|
+
config = DevMemoryConfig.load()
|
|
17
|
+
|
|
18
|
+
table = Table(title="DevMemory Status", show_header=False, border_style="dim")
|
|
19
|
+
table.add_column("Key", style="bold")
|
|
20
|
+
table.add_column("Value")
|
|
21
|
+
|
|
22
|
+
table.add_row("DevMemory version", __version__)
|
|
23
|
+
|
|
24
|
+
git_ai_ok = is_git_ai_installed()
|
|
25
|
+
git_ai_ver = get_git_ai_version() if git_ai_ok else "not installed"
|
|
26
|
+
table.add_row("Git AI", f"[green]{git_ai_ver}[/green]" if git_ai_ok else "[red]not installed[/red]")
|
|
27
|
+
|
|
28
|
+
repo_root = get_repo_root()
|
|
29
|
+
table.add_row("Git repo", repo_root or "[red]not in a git repo[/red]")
|
|
30
|
+
|
|
31
|
+
client = AMSClient(base_url=config.ams_endpoint)
|
|
32
|
+
try:
|
|
33
|
+
health = client.health_check()
|
|
34
|
+
ams_status = f"[green]healthy[/green] (endpoint: {config.ams_endpoint})"
|
|
35
|
+
except Exception:
|
|
36
|
+
health = None
|
|
37
|
+
ams_status = f"[red]unreachable[/red] (endpoint: {config.ams_endpoint})"
|
|
38
|
+
table.add_row("AMS server", ams_status)
|
|
39
|
+
|
|
40
|
+
if health:
|
|
41
|
+
count = client.get_memory_count(namespace=config.namespace)
|
|
42
|
+
table.add_row("Memories stored", str(count) if count >= 0 else "[yellow]unknown[/yellow]")
|
|
43
|
+
else:
|
|
44
|
+
table.add_row("Memories stored", "[dim]N/A[/dim]")
|
|
45
|
+
|
|
46
|
+
table.add_row("Namespace", config.namespace)
|
|
47
|
+
table.add_row("User ID", config.user_id or "[dim]auto (from git)[/dim]")
|
|
48
|
+
|
|
49
|
+
if repo_root:
|
|
50
|
+
state = SyncState.load(repo_root)
|
|
51
|
+
if state.last_synced_sha:
|
|
52
|
+
table.add_row("Last synced commit", state.last_synced_sha[:12])
|
|
53
|
+
table.add_row("Last synced at", state.last_synced_at)
|
|
54
|
+
table.add_row("Total synced", str(state.total_synced))
|
|
55
|
+
else:
|
|
56
|
+
table.add_row("Sync state", "[yellow]never synced[/yellow]")
|
|
57
|
+
|
|
58
|
+
if repo_root:
|
|
59
|
+
hook_path = pathlib.Path(repo_root) / ".git" / "hooks" / "post-commit"
|
|
60
|
+
if hook_path.exists() and "devmemory" in hook_path.read_text():
|
|
61
|
+
table.add_row("Post-commit hook", "[green]installed[/green]")
|
|
62
|
+
else:
|
|
63
|
+
table.add_row("Post-commit hook", "[yellow]not installed[/yellow] (run: devmemory install)")
|
|
64
|
+
|
|
65
|
+
mcp_config = pathlib.Path.home() / ".cursor" / "mcp.json"
|
|
66
|
+
if mcp_config.exists() and "agent-memory" in mcp_config.read_text():
|
|
67
|
+
table.add_row("Cursor MCP config", "[green]configured[/green]")
|
|
68
|
+
else:
|
|
69
|
+
table.add_row("Cursor MCP config", "[yellow]not configured[/yellow] (run: devmemory install)")
|
|
70
|
+
|
|
71
|
+
console.print(table)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from rich.table import Table
|
|
4
|
+
|
|
5
|
+
from devmemory.core.config import DevMemoryConfig
|
|
6
|
+
from devmemory.core.sync_state import SyncState
|
|
7
|
+
from devmemory.core.git_ai_parser import (
|
|
8
|
+
get_repo_root,
|
|
9
|
+
get_ai_notes_since,
|
|
10
|
+
get_latest_commit_note,
|
|
11
|
+
)
|
|
12
|
+
from devmemory.core.memory_formatter import format_commit_as_memories, format_commit_without_ai
|
|
13
|
+
from devmemory.core.ams_client import AMSClient
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run_sync(
|
|
19
|
+
latest: bool = False,
|
|
20
|
+
all_commits: bool = False,
|
|
21
|
+
ai_only: bool = True,
|
|
22
|
+
dry_run: bool = False,
|
|
23
|
+
limit: int = 50,
|
|
24
|
+
quiet: bool = False,
|
|
25
|
+
):
|
|
26
|
+
repo_root = get_repo_root()
|
|
27
|
+
if not repo_root:
|
|
28
|
+
if not quiet:
|
|
29
|
+
console.print("[red]Not inside a git repository.[/red]")
|
|
30
|
+
raise typer.Exit(1)
|
|
31
|
+
|
|
32
|
+
config = DevMemoryConfig.load()
|
|
33
|
+
state = SyncState.load(repo_root)
|
|
34
|
+
|
|
35
|
+
if latest:
|
|
36
|
+
note = get_latest_commit_note()
|
|
37
|
+
if not note:
|
|
38
|
+
if not quiet:
|
|
39
|
+
console.print("[yellow]No commits found.[/yellow]")
|
|
40
|
+
raise typer.Exit(0)
|
|
41
|
+
notes = [note]
|
|
42
|
+
else:
|
|
43
|
+
since_sha = None if all_commits else (state.last_synced_sha or None)
|
|
44
|
+
notes = get_ai_notes_since(since_sha, limit=limit)
|
|
45
|
+
|
|
46
|
+
if not notes:
|
|
47
|
+
if not quiet:
|
|
48
|
+
console.print("[green]Already up to date. No new commits to sync.[/green]")
|
|
49
|
+
raise typer.Exit(0)
|
|
50
|
+
|
|
51
|
+
if ai_only:
|
|
52
|
+
ai_notes = [n for n in notes if n.has_ai_note]
|
|
53
|
+
skipped = len(notes) - len(ai_notes)
|
|
54
|
+
if skipped > 0 and not quiet:
|
|
55
|
+
console.print(f"[dim]Skipping {skipped} commit(s) without AI notes.[/dim]")
|
|
56
|
+
notes_to_sync = ai_notes
|
|
57
|
+
else:
|
|
58
|
+
notes_to_sync = notes
|
|
59
|
+
|
|
60
|
+
if not notes_to_sync:
|
|
61
|
+
if not quiet:
|
|
62
|
+
console.print("[green]No commits with AI notes to sync.[/green]")
|
|
63
|
+
if notes:
|
|
64
|
+
state.mark_synced(notes[0].sha, count=0)
|
|
65
|
+
raise typer.Exit(0)
|
|
66
|
+
|
|
67
|
+
if not quiet:
|
|
68
|
+
table = Table(title=f"Commits to sync ({len(notes_to_sync)})")
|
|
69
|
+
table.add_column("SHA", style="cyan", width=12)
|
|
70
|
+
table.add_column("Subject", style="white")
|
|
71
|
+
table.add_column("AI Files", style="green", justify="right")
|
|
72
|
+
table.add_column("Prompts", style="magenta", justify="right")
|
|
73
|
+
table.add_column("Author", style="dim")
|
|
74
|
+
|
|
75
|
+
for n in notes_to_sync:
|
|
76
|
+
table.add_row(
|
|
77
|
+
n.sha[:12],
|
|
78
|
+
n.subject[:60],
|
|
79
|
+
str(len(n.files)),
|
|
80
|
+
str(len(n.prompts)),
|
|
81
|
+
n.author_name,
|
|
82
|
+
)
|
|
83
|
+
console.print(table)
|
|
84
|
+
|
|
85
|
+
if dry_run:
|
|
86
|
+
if not quiet:
|
|
87
|
+
console.print("[yellow]Dry run -- no memories sent.[/yellow]")
|
|
88
|
+
raise typer.Exit(0)
|
|
89
|
+
|
|
90
|
+
client = AMSClient(base_url=config.ams_endpoint)
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
client.health_check()
|
|
94
|
+
except Exception as e:
|
|
95
|
+
if not quiet:
|
|
96
|
+
console.print(f"[red]Cannot reach AMS at {config.ams_endpoint}: {e}[/red]")
|
|
97
|
+
console.print("[dim]Is the Docker stack running? Try: make up[/dim]")
|
|
98
|
+
raise typer.Exit(1)
|
|
99
|
+
|
|
100
|
+
total_memories = 0
|
|
101
|
+
for n in notes_to_sync:
|
|
102
|
+
if n.has_ai_note:
|
|
103
|
+
memories = format_commit_as_memories(n, namespace=config.namespace, user_id=config.user_id)
|
|
104
|
+
else:
|
|
105
|
+
memories = format_commit_without_ai(n, namespace=config.namespace, user_id=config.user_id)
|
|
106
|
+
|
|
107
|
+
if memories:
|
|
108
|
+
try:
|
|
109
|
+
client.create_memories(memories)
|
|
110
|
+
total_memories += len(memories)
|
|
111
|
+
if not quiet:
|
|
112
|
+
console.print(f" [green]✓[/green] {n.sha[:12]} → {len(memories)} memory(s)")
|
|
113
|
+
except Exception as e:
|
|
114
|
+
if not quiet:
|
|
115
|
+
console.print(f" [red]✗[/red] {n.sha[:12]} → error: {e}")
|
|
116
|
+
|
|
117
|
+
newest_sha = notes_to_sync[0].sha
|
|
118
|
+
state.mark_synced(newest_sha, count=total_memories)
|
|
119
|
+
|
|
120
|
+
if quiet:
|
|
121
|
+
if total_memories > 0:
|
|
122
|
+
print(f"devmemory: synced {total_memories} memory(s) from {len(notes_to_sync)} commit(s)")
|
|
123
|
+
else:
|
|
124
|
+
console.print(f"\n[green]Synced {total_memories} memories from {len(notes_to_sync)} commit(s).[/green]")
|
|
125
|
+
console.print(f"[dim]Last synced: {newest_sha[:12]}[/dim]")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class MemoryResult:
|
|
9
|
+
id: str
|
|
10
|
+
text: str
|
|
11
|
+
score: float
|
|
12
|
+
topics: list[str]
|
|
13
|
+
entities: list[str]
|
|
14
|
+
memory_type: str
|
|
15
|
+
created_at: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AMSClient:
|
|
19
|
+
def __init__(self, base_url: str = "http://localhost:8000", timeout: float = 30.0):
|
|
20
|
+
self.base_url = base_url.rstrip("/")
|
|
21
|
+
self.timeout = timeout
|
|
22
|
+
|
|
23
|
+
def _client(self) -> httpx.Client:
|
|
24
|
+
return httpx.Client(base_url=self.base_url, timeout=self.timeout)
|
|
25
|
+
|
|
26
|
+
def health_check(self) -> dict:
|
|
27
|
+
with self._client() as client:
|
|
28
|
+
resp = client.get("/v1/health")
|
|
29
|
+
resp.raise_for_status()
|
|
30
|
+
return resp.json()
|
|
31
|
+
|
|
32
|
+
def create_memories(
|
|
33
|
+
self,
|
|
34
|
+
memories: list[dict],
|
|
35
|
+
deduplicate: bool = True,
|
|
36
|
+
) -> dict:
|
|
37
|
+
payload = {
|
|
38
|
+
"memories": memories,
|
|
39
|
+
"deduplicate": deduplicate,
|
|
40
|
+
}
|
|
41
|
+
with self._client() as client:
|
|
42
|
+
resp = client.post("/v1/long-term-memory/", json=payload)
|
|
43
|
+
resp.raise_for_status()
|
|
44
|
+
return resp.json()
|
|
45
|
+
|
|
46
|
+
def search_memories(
|
|
47
|
+
self,
|
|
48
|
+
text: str,
|
|
49
|
+
limit: int = 10,
|
|
50
|
+
namespace: str | None = None,
|
|
51
|
+
user_id: str | None = None,
|
|
52
|
+
topics: list[str] | None = None,
|
|
53
|
+
memory_type: str | None = None,
|
|
54
|
+
) -> list[MemoryResult]:
|
|
55
|
+
payload: dict = {
|
|
56
|
+
"text": text,
|
|
57
|
+
"limit": limit,
|
|
58
|
+
}
|
|
59
|
+
if namespace:
|
|
60
|
+
payload["namespace"] = {"eq": namespace}
|
|
61
|
+
if user_id:
|
|
62
|
+
payload["user_id"] = {"eq": user_id}
|
|
63
|
+
if topics:
|
|
64
|
+
payload["topics"] = {"any": topics}
|
|
65
|
+
if memory_type:
|
|
66
|
+
payload["memory_type"] = {"eq": memory_type}
|
|
67
|
+
|
|
68
|
+
with self._client() as client:
|
|
69
|
+
resp = client.post("/v1/long-term-memory/search", json=payload)
|
|
70
|
+
resp.raise_for_status()
|
|
71
|
+
data = resp.json()
|
|
72
|
+
|
|
73
|
+
results = []
|
|
74
|
+
for m in data.get("memories", []):
|
|
75
|
+
results.append(MemoryResult(
|
|
76
|
+
id=m.get("id", ""),
|
|
77
|
+
text=m.get("text", ""),
|
|
78
|
+
score=m.get("dist", 0.0),
|
|
79
|
+
topics=m.get("topics") or [],
|
|
80
|
+
entities=m.get("entities") or [],
|
|
81
|
+
memory_type=m.get("memory_type", ""),
|
|
82
|
+
created_at=m.get("created_at", ""),
|
|
83
|
+
))
|
|
84
|
+
return results
|
|
85
|
+
|
|
86
|
+
def get_memory_count(self, namespace: str | None = None) -> int:
|
|
87
|
+
try:
|
|
88
|
+
total = 0
|
|
89
|
+
offset = 0
|
|
90
|
+
with self._client() as client:
|
|
91
|
+
while True:
|
|
92
|
+
payload: dict = {"text": "", "limit": 100, "offset": offset}
|
|
93
|
+
if namespace:
|
|
94
|
+
payload["namespace"] = {"eq": namespace}
|
|
95
|
+
resp = client.post("/v1/long-term-memory/search", json=payload)
|
|
96
|
+
resp.raise_for_status()
|
|
97
|
+
data = resp.json()
|
|
98
|
+
total += len(data.get("memories", []))
|
|
99
|
+
if data.get("next_offset") is None:
|
|
100
|
+
break
|
|
101
|
+
offset = data["next_offset"]
|
|
102
|
+
return total
|
|
103
|
+
except Exception:
|
|
104
|
+
return -1
|
|
105
|
+
|
|
106
|
+
def list_sessions(self, namespace: str | None = None, limit: int = 50) -> list[str]:
|
|
107
|
+
params: dict = {"limit": limit}
|
|
108
|
+
if namespace:
|
|
109
|
+
params["namespace"] = namespace
|
|
110
|
+
with self._client() as client:
|
|
111
|
+
resp = client.get("/v1/working-memory/", params=params)
|
|
112
|
+
resp.raise_for_status()
|
|
113
|
+
return resp.json().get("sessions", [])
|
devmemory/core/config.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from dataclasses import dataclass, field, asdict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
CONFIG_DIR = Path.home() / ".devmemory"
|
|
9
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
10
|
+
|
|
11
|
+
DEFAULTS = {
|
|
12
|
+
"ams_endpoint": "http://localhost:8000",
|
|
13
|
+
"mcp_endpoint": "http://localhost:9050",
|
|
14
|
+
"namespace": "default",
|
|
15
|
+
"user_id": "",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class DevMemoryConfig:
|
|
21
|
+
ams_endpoint: str = DEFAULTS["ams_endpoint"]
|
|
22
|
+
mcp_endpoint: str = DEFAULTS["mcp_endpoint"]
|
|
23
|
+
namespace: str = DEFAULTS["namespace"]
|
|
24
|
+
user_id: str = DEFAULTS["user_id"]
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def load(cls) -> DevMemoryConfig:
|
|
28
|
+
if CONFIG_FILE.exists():
|
|
29
|
+
raw = json.loads(CONFIG_FILE.read_text())
|
|
30
|
+
merged = {**DEFAULTS, **raw}
|
|
31
|
+
return cls(**{k: v for k, v in merged.items() if k in cls.__dataclass_fields__})
|
|
32
|
+
return cls()
|
|
33
|
+
|
|
34
|
+
def save(self) -> None:
|
|
35
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
CONFIG_FILE.write_text(json.dumps(asdict(self), indent=2) + "\n")
|
|
37
|
+
|
|
38
|
+
def set_value(self, key: str, value: str) -> None:
|
|
39
|
+
if key not in self.__dataclass_fields__:
|
|
40
|
+
raise KeyError(f"Unknown config key: {key}. Valid keys: {list(self.__dataclass_fields__)}")
|
|
41
|
+
setattr(self, key, value)
|
|
42
|
+
self.save()
|