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
devmemory/__init__.py
ADDED
devmemory/cli.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from devmemory.commands.config_cmd import app as config_app
|
|
3
|
+
|
|
4
|
+
app = typer.Typer(
|
|
5
|
+
name="devmemory",
|
|
6
|
+
help="Sync AI coding context from Git AI to Redis Agent Memory Server for semantic search and recall.",
|
|
7
|
+
no_args_is_help=True,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
app.add_typer(config_app, name="config", help="Manage devmemory configuration.")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@app.command()
|
|
14
|
+
def sync(
|
|
15
|
+
latest: bool = typer.Option(False, "--latest", help="Sync only the latest commit."),
|
|
16
|
+
all_commits: bool = typer.Option(False, "--all", help="Sync all commits (ignore last synced state)."),
|
|
17
|
+
ai_only: bool = typer.Option(True, "--ai-only/--include-human", help="Only sync commits with AI notes."),
|
|
18
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be synced without sending."),
|
|
19
|
+
limit: int = typer.Option(50, "--limit", help="Max commits to process."),
|
|
20
|
+
quiet: bool = typer.Option(False, "--quiet", "-q", help="Minimal output (single summary line)."),
|
|
21
|
+
):
|
|
22
|
+
"""Sync Git AI notes to Redis AMS."""
|
|
23
|
+
from devmemory.commands.sync import run_sync
|
|
24
|
+
run_sync(latest=latest, all_commits=all_commits, ai_only=ai_only, dry_run=dry_run, limit=limit, quiet=quiet)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.command()
|
|
28
|
+
def search(
|
|
29
|
+
query: str = typer.Argument(..., help="Search query (natural language)."),
|
|
30
|
+
limit: int = typer.Option(10, "--limit", "-n", help="Max results to return."),
|
|
31
|
+
namespace: str = typer.Option("", "--namespace", "-ns", help="Filter by namespace."),
|
|
32
|
+
topic: list[str] = typer.Option([], "--topic", "-t", help="Filter by topic(s)."),
|
|
33
|
+
memory_type: str = typer.Option("", "--type", help="Filter by memory type (episodic, semantic)."),
|
|
34
|
+
threshold: float = typer.Option(0.75, "--threshold", help="Relevance threshold (0-1, lower=stricter). Results with distance above this are filtered."),
|
|
35
|
+
raw: bool = typer.Option(False, "--raw", help="Raw output mode: skip answer synthesis and show memory panels directly."),
|
|
36
|
+
):
|
|
37
|
+
"""Search the project knowledgebase with AI-powered answer synthesis."""
|
|
38
|
+
from devmemory.commands.search import run_search
|
|
39
|
+
run_search(query=query, limit=limit, namespace=namespace, topic=topic, memory_type=memory_type, threshold=threshold, raw=raw)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.command()
|
|
43
|
+
def status():
|
|
44
|
+
"""Show system status."""
|
|
45
|
+
from devmemory.commands.status import run_status
|
|
46
|
+
run_status()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.command()
|
|
50
|
+
def add(
|
|
51
|
+
text: str = typer.Argument("", help="Memory text to store. If empty, launches interactive mode."),
|
|
52
|
+
memory_type: str = typer.Option("semantic", "--type", help="Memory type: semantic (facts/decisions) or episodic (events)."),
|
|
53
|
+
topic: list[str] = typer.Option([], "--topic", "-t", help="Topic tags (can specify multiple)."),
|
|
54
|
+
entity: list[str] = typer.Option([], "--entity", "-e", help="Entity tags (can specify multiple)."),
|
|
55
|
+
interactive: bool = typer.Option(False, "--interactive", "-i", help="Interactive mode with prompts."),
|
|
56
|
+
):
|
|
57
|
+
"""Add a memory directly (design decisions, gotchas, conventions, etc.)."""
|
|
58
|
+
from devmemory.commands.add import run_add
|
|
59
|
+
run_add(text=text, memory_type=memory_type, topics=topic or None, entities=entity or None, interactive=interactive)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.command()
|
|
63
|
+
def learn(
|
|
64
|
+
path: str = typer.Argument("", help="Path to knowledge directory (default: .devmemory/knowledge/)."),
|
|
65
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be synced without sending."),
|
|
66
|
+
):
|
|
67
|
+
"""Sync knowledge files (markdown) into the memory store."""
|
|
68
|
+
from devmemory.commands.learn import run_learn
|
|
69
|
+
run_learn(path=path, dry_run=dry_run)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.command()
|
|
73
|
+
def context(
|
|
74
|
+
output: str = typer.Option("", "--output", "-o", help="Output file path (default: .devmemory/CONTEXT.md)."),
|
|
75
|
+
quiet: bool = typer.Option(False, "--quiet", "-q", help="No terminal output, just write the file."),
|
|
76
|
+
):
|
|
77
|
+
"""Generate a context briefing from memory based on current git state."""
|
|
78
|
+
from devmemory.commands.context import run_context
|
|
79
|
+
run_context(output=output, quiet=quiet)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.command()
|
|
83
|
+
def install(
|
|
84
|
+
skip_hook: bool = typer.Option(False, "--skip-hook", help="Skip post-commit hook installation."),
|
|
85
|
+
skip_mcp: bool = typer.Option(False, "--skip-mcp", help="Skip Cursor MCP config."),
|
|
86
|
+
skip_rule: bool = typer.Option(False, "--skip-rule", help="Skip Cursor agent rule installation."),
|
|
87
|
+
mcp_endpoint: str = typer.Option("", "--mcp-endpoint", help="Override MCP server endpoint."),
|
|
88
|
+
):
|
|
89
|
+
"""Set up Git hooks, Cursor MCP config, and agent coordination rules."""
|
|
90
|
+
from devmemory.commands.install import run_install
|
|
91
|
+
run_install(skip_hook=skip_hook, skip_mcp=skip_mcp, skip_rule=skip_rule, mcp_endpoint=mcp_endpoint)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def main():
|
|
95
|
+
app()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.panel import Panel
|
|
6
|
+
|
|
7
|
+
from devmemory.core.config import DevMemoryConfig
|
|
8
|
+
from devmemory.core.ams_client import AMSClient
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
VALID_TYPES = ("semantic", "episodic")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _generate_id(text: str) -> str:
|
|
16
|
+
return hashlib.sha256(f"manual:{text}".encode()).hexdigest()[:24]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run_add(
|
|
20
|
+
text: str = "",
|
|
21
|
+
memory_type: str = "semantic",
|
|
22
|
+
topics: list[str] | None = None,
|
|
23
|
+
entities: list[str] | None = None,
|
|
24
|
+
interactive: bool = False,
|
|
25
|
+
):
|
|
26
|
+
config = DevMemoryConfig.load()
|
|
27
|
+
client = AMSClient(base_url=config.ams_endpoint)
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
client.health_check()
|
|
31
|
+
except Exception as e:
|
|
32
|
+
console.print(f"[red]Cannot reach AMS at {config.ams_endpoint}: {e}[/red]")
|
|
33
|
+
raise typer.Exit(1)
|
|
34
|
+
|
|
35
|
+
if interactive or not text:
|
|
36
|
+
text = _interactive_prompt(memory_type, topics, entities)
|
|
37
|
+
if text is None:
|
|
38
|
+
raise typer.Exit(0)
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
if memory_type not in VALID_TYPES:
|
|
42
|
+
console.print(f"[red]Invalid memory type '{memory_type}'. Must be one of: {', '.join(VALID_TYPES)}[/red]")
|
|
43
|
+
raise typer.Exit(1)
|
|
44
|
+
|
|
45
|
+
memory = {
|
|
46
|
+
"id": _generate_id(text),
|
|
47
|
+
"text": text,
|
|
48
|
+
"memory_type": memory_type,
|
|
49
|
+
"namespace": config.namespace or "default",
|
|
50
|
+
}
|
|
51
|
+
if topics:
|
|
52
|
+
memory["topics"] = topics
|
|
53
|
+
if entities:
|
|
54
|
+
memory["entities"] = entities
|
|
55
|
+
if config.user_id:
|
|
56
|
+
memory["user_id"] = config.user_id
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
client.create_memories([memory])
|
|
60
|
+
except Exception as e:
|
|
61
|
+
console.print(f"[red]Failed to store memory: {e}[/red]")
|
|
62
|
+
raise typer.Exit(1)
|
|
63
|
+
|
|
64
|
+
console.print(Panel(
|
|
65
|
+
text,
|
|
66
|
+
title=f"[bold green]Stored[/bold green] [{memory_type}]",
|
|
67
|
+
border_style="green",
|
|
68
|
+
padding=(0, 1),
|
|
69
|
+
))
|
|
70
|
+
if topics:
|
|
71
|
+
console.print(f" [dim]Topics: {', '.join(topics)}[/dim]")
|
|
72
|
+
if entities:
|
|
73
|
+
console.print(f" [dim]Entities: {', '.join(entities)}[/dim]")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _interactive_prompt(
|
|
77
|
+
default_type: str = "semantic",
|
|
78
|
+
default_topics: list[str] | None = None,
|
|
79
|
+
default_entities: list[str] | None = None,
|
|
80
|
+
) -> str | None:
|
|
81
|
+
config = DevMemoryConfig.load()
|
|
82
|
+
client = AMSClient(base_url=config.ams_endpoint)
|
|
83
|
+
|
|
84
|
+
console.print("[bold]Add a memory[/bold]\n")
|
|
85
|
+
console.print("[dim]Types of knowledge to store:[/dim]")
|
|
86
|
+
console.print(" - Architecture decisions and rationale")
|
|
87
|
+
console.print(" - Known gotchas and workarounds")
|
|
88
|
+
console.print(" - Project conventions and patterns")
|
|
89
|
+
console.print(" - Bug root causes")
|
|
90
|
+
console.print(" - API quirks and limitations")
|
|
91
|
+
console.print()
|
|
92
|
+
|
|
93
|
+
text = typer.prompt("Memory text (what do you want to remember?)")
|
|
94
|
+
if not text.strip():
|
|
95
|
+
console.print("[yellow]No text provided, aborting.[/yellow]")
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
type_input = typer.prompt(
|
|
99
|
+
"Memory type",
|
|
100
|
+
default=default_type,
|
|
101
|
+
show_default=True,
|
|
102
|
+
)
|
|
103
|
+
if type_input not in VALID_TYPES:
|
|
104
|
+
console.print(f"[yellow]Invalid type, using 'semantic'[/yellow]")
|
|
105
|
+
type_input = "semantic"
|
|
106
|
+
|
|
107
|
+
topics_default = ", ".join(default_topics) if default_topics else ""
|
|
108
|
+
topics_input = typer.prompt(
|
|
109
|
+
"Topics (comma-separated, e.g. architecture,patterns)",
|
|
110
|
+
default=topics_default,
|
|
111
|
+
show_default=bool(topics_default),
|
|
112
|
+
)
|
|
113
|
+
topics = [t.strip() for t in topics_input.split(",") if t.strip()] if topics_input else []
|
|
114
|
+
|
|
115
|
+
entities_default = ", ".join(default_entities) if default_entities else ""
|
|
116
|
+
entities_input = typer.prompt(
|
|
117
|
+
"Entities (comma-separated, e.g. Redis,httpx)",
|
|
118
|
+
default=entities_default,
|
|
119
|
+
show_default=bool(entities_default),
|
|
120
|
+
)
|
|
121
|
+
entities = [e.strip() for e in entities_input.split(",") if e.strip()] if entities_input else []
|
|
122
|
+
|
|
123
|
+
memory = {
|
|
124
|
+
"id": _generate_id(text),
|
|
125
|
+
"text": text,
|
|
126
|
+
"memory_type": type_input,
|
|
127
|
+
"namespace": config.namespace or "default",
|
|
128
|
+
}
|
|
129
|
+
if topics:
|
|
130
|
+
memory["topics"] = topics
|
|
131
|
+
if entities:
|
|
132
|
+
memory["entities"] = entities
|
|
133
|
+
if config.user_id:
|
|
134
|
+
memory["user_id"] = config.user_id
|
|
135
|
+
|
|
136
|
+
console.print()
|
|
137
|
+
console.print(Panel(
|
|
138
|
+
text,
|
|
139
|
+
title=f"[bold]Preview[/bold] [{type_input}]",
|
|
140
|
+
border_style="dim",
|
|
141
|
+
padding=(0, 1),
|
|
142
|
+
))
|
|
143
|
+
if topics:
|
|
144
|
+
console.print(f" [dim]Topics: {', '.join(topics)}[/dim]")
|
|
145
|
+
if entities:
|
|
146
|
+
console.print(f" [dim]Entities: {', '.join(entities)}[/dim]")
|
|
147
|
+
console.print()
|
|
148
|
+
|
|
149
|
+
if not typer.confirm("Store this memory?", default=True):
|
|
150
|
+
console.print("[yellow]Cancelled.[/yellow]")
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
client.create_memories([memory])
|
|
155
|
+
except Exception as e:
|
|
156
|
+
console.print(f"[red]Failed to store memory: {e}[/red]")
|
|
157
|
+
raise typer.Exit(1)
|
|
158
|
+
|
|
159
|
+
console.print("[bold green]Memory stored.[/bold green]")
|
|
160
|
+
return text
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from rich.table import Table
|
|
4
|
+
from dataclasses import asdict
|
|
5
|
+
|
|
6
|
+
from devmemory.core.config import DevMemoryConfig
|
|
7
|
+
|
|
8
|
+
app = typer.Typer()
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.command("show")
|
|
13
|
+
def show():
|
|
14
|
+
config = DevMemoryConfig.load()
|
|
15
|
+
table = Table(title="DevMemory Configuration", show_header=True, border_style="dim")
|
|
16
|
+
table.add_column("Key", style="bold")
|
|
17
|
+
table.add_column("Value")
|
|
18
|
+
|
|
19
|
+
for key, value in asdict(config).items():
|
|
20
|
+
display = value if value else "[dim]not set[/dim]"
|
|
21
|
+
table.add_row(key, str(display))
|
|
22
|
+
|
|
23
|
+
console.print(table)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command("set")
|
|
27
|
+
def set_value(
|
|
28
|
+
key: str = typer.Argument(..., help="Config key to set."),
|
|
29
|
+
value: str = typer.Argument(..., help="Value to set."),
|
|
30
|
+
):
|
|
31
|
+
config = DevMemoryConfig.load()
|
|
32
|
+
try:
|
|
33
|
+
config.set_value(key, value)
|
|
34
|
+
console.print(f"[green]Set {key} = {value}[/green]")
|
|
35
|
+
except KeyError as e:
|
|
36
|
+
console.print(f"[red]{e}[/red]")
|
|
37
|
+
raise typer.Exit(1)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.command("reset")
|
|
41
|
+
def reset():
|
|
42
|
+
config = DevMemoryConfig()
|
|
43
|
+
config.save()
|
|
44
|
+
console.print("[green]Config reset to defaults.[/green]")
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from devmemory.core.config import DevMemoryConfig
|
|
11
|
+
from devmemory.core.ams_client import AMSClient, MemoryResult
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
DEFAULT_OUTPUT = ".devmemory/CONTEXT.md"
|
|
16
|
+
RELEVANCE_THRESHOLD = 0.65
|
|
17
|
+
MAX_CONTEXT_CHARS = 4000
|
|
18
|
+
MAX_RESULTS_PER_QUERY = 5
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _git_cmd(args: list[str]) -> str:
|
|
22
|
+
try:
|
|
23
|
+
result = subprocess.run(
|
|
24
|
+
["git"] + args,
|
|
25
|
+
capture_output=True, text=True, check=True,
|
|
26
|
+
)
|
|
27
|
+
return result.stdout.strip()
|
|
28
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
29
|
+
return ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _get_git_signals() -> dict:
|
|
33
|
+
branch = _git_cmd(["rev-parse", "--abbrev-ref", "HEAD"])
|
|
34
|
+
|
|
35
|
+
changed_raw = _git_cmd(["diff", "--name-only"])
|
|
36
|
+
staged_raw = _git_cmd(["diff", "--cached", "--name-only"])
|
|
37
|
+
all_changed = set()
|
|
38
|
+
if changed_raw:
|
|
39
|
+
all_changed.update(changed_raw.splitlines())
|
|
40
|
+
if staged_raw:
|
|
41
|
+
all_changed.update(staged_raw.splitlines())
|
|
42
|
+
|
|
43
|
+
recent_log = _git_cmd(["log", "--oneline", "-5", "--format=%s"])
|
|
44
|
+
recent_subjects = [s.strip() for s in recent_log.splitlines() if s.strip()] if recent_log else []
|
|
45
|
+
|
|
46
|
+
recent_files_raw = _git_cmd(["log", "--name-only", "--format=", "-3"])
|
|
47
|
+
recent_files = set()
|
|
48
|
+
if recent_files_raw:
|
|
49
|
+
for f in recent_files_raw.splitlines():
|
|
50
|
+
f = f.strip()
|
|
51
|
+
if f:
|
|
52
|
+
recent_files.add(f)
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
"branch": branch or "unknown",
|
|
56
|
+
"changed_files": sorted(all_changed),
|
|
57
|
+
"recent_subjects": recent_subjects,
|
|
58
|
+
"recent_files": sorted(recent_files),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _build_search_queries(signals: dict) -> list[str]:
|
|
63
|
+
queries = []
|
|
64
|
+
|
|
65
|
+
branch = signals["branch"]
|
|
66
|
+
if branch and branch not in ("main", "master", "HEAD", "unknown"):
|
|
67
|
+
clean_branch = branch.replace("/", " ").replace("-", " ").replace("_", " ")
|
|
68
|
+
queries.append(clean_branch)
|
|
69
|
+
|
|
70
|
+
changed = signals["changed_files"]
|
|
71
|
+
if changed:
|
|
72
|
+
dirs = set()
|
|
73
|
+
for f in changed[:10]:
|
|
74
|
+
parts = f.rsplit("/", 1)
|
|
75
|
+
if len(parts) == 2:
|
|
76
|
+
dirs.add(parts[0].split("/")[-1])
|
|
77
|
+
if dirs:
|
|
78
|
+
queries.append(f"known issues and patterns in {' '.join(sorted(dirs)[:5])}")
|
|
79
|
+
file_list = " ".join(changed[:5])
|
|
80
|
+
queries.append(f"architecture decisions for {file_list}")
|
|
81
|
+
|
|
82
|
+
subjects = signals["recent_subjects"]
|
|
83
|
+
if subjects:
|
|
84
|
+
combined = "; ".join(subjects[:3])
|
|
85
|
+
queries.append(f"context for recent work: {combined}")
|
|
86
|
+
|
|
87
|
+
if not queries:
|
|
88
|
+
queries.append("project architecture and conventions")
|
|
89
|
+
|
|
90
|
+
return queries[:5]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _search_with_dedup(
|
|
94
|
+
client: AMSClient,
|
|
95
|
+
queries: list[str],
|
|
96
|
+
namespace: str | None,
|
|
97
|
+
threshold: float = RELEVANCE_THRESHOLD,
|
|
98
|
+
) -> list[MemoryResult]:
|
|
99
|
+
seen_ids: set[str] = set()
|
|
100
|
+
results: list[MemoryResult] = []
|
|
101
|
+
|
|
102
|
+
for query in queries:
|
|
103
|
+
try:
|
|
104
|
+
hits = client.search_memories(
|
|
105
|
+
text=query,
|
|
106
|
+
limit=MAX_RESULTS_PER_QUERY,
|
|
107
|
+
namespace=namespace,
|
|
108
|
+
memory_type="semantic",
|
|
109
|
+
)
|
|
110
|
+
except Exception:
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
for r in hits:
|
|
114
|
+
if r.score < threshold and r.id not in seen_ids:
|
|
115
|
+
seen_ids.add(r.id)
|
|
116
|
+
results.append(r)
|
|
117
|
+
|
|
118
|
+
results.sort(key=lambda r: r.score)
|
|
119
|
+
return results
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _fetch_coordination_state(client: AMSClient) -> str | None:
|
|
123
|
+
try:
|
|
124
|
+
sessions = client.list_sessions(limit=10)
|
|
125
|
+
for sid in sessions:
|
|
126
|
+
if "coordination" in sid.lower():
|
|
127
|
+
return sid
|
|
128
|
+
except Exception:
|
|
129
|
+
pass
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _truncate_memory_text(text: str, max_len: int = 200) -> str:
|
|
134
|
+
lines = text.strip().splitlines()
|
|
135
|
+
first_line = lines[0] if lines else ""
|
|
136
|
+
if len(first_line) > max_len:
|
|
137
|
+
return first_line[:max_len] + "..."
|
|
138
|
+
if len(lines) == 1:
|
|
139
|
+
return first_line
|
|
140
|
+
|
|
141
|
+
result = first_line
|
|
142
|
+
for line in lines[1:]:
|
|
143
|
+
if len(result) + len(line) + 1 > max_len:
|
|
144
|
+
break
|
|
145
|
+
result += "\n" + line
|
|
146
|
+
return result
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _render_context(
|
|
150
|
+
signals: dict,
|
|
151
|
+
results: list[MemoryResult],
|
|
152
|
+
coordination_session: str | None,
|
|
153
|
+
) -> str:
|
|
154
|
+
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
155
|
+
parts = [
|
|
156
|
+
f"# DevMemory Context",
|
|
157
|
+
f"_Auto-generated at {now}. Run `devmemory context` to refresh._\n",
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
parts.append(f"## Current Branch: `{signals['branch']}`\n")
|
|
161
|
+
|
|
162
|
+
if signals["changed_files"]:
|
|
163
|
+
parts.append("## Active Changes\n")
|
|
164
|
+
for f in signals["changed_files"][:15]:
|
|
165
|
+
parts.append(f"- `{f}`")
|
|
166
|
+
if len(signals["changed_files"]) > 15:
|
|
167
|
+
parts.append(f"- ... and {len(signals['changed_files']) - 15} more")
|
|
168
|
+
parts.append("")
|
|
169
|
+
|
|
170
|
+
if signals["recent_subjects"]:
|
|
171
|
+
parts.append("## Recent Commits\n")
|
|
172
|
+
for s in signals["recent_subjects"]:
|
|
173
|
+
parts.append(f"- {s}")
|
|
174
|
+
parts.append("")
|
|
175
|
+
|
|
176
|
+
decisions = [r for r in results if any(
|
|
177
|
+
t in r.topics for t in ("architecture", "decisions", "conventions", "dependencies")
|
|
178
|
+
)]
|
|
179
|
+
gotchas = [r for r in results if any(
|
|
180
|
+
t in r.topics for t in ("gotchas", "troubleshooting", "bugfix", "api-quirks")
|
|
181
|
+
)]
|
|
182
|
+
other = [r for r in results if r not in decisions and r not in gotchas]
|
|
183
|
+
|
|
184
|
+
total_chars = sum(len(p) for p in parts)
|
|
185
|
+
|
|
186
|
+
if decisions:
|
|
187
|
+
parts.append("## Relevant Architecture Decisions\n")
|
|
188
|
+
for r in decisions:
|
|
189
|
+
summary = _truncate_memory_text(r.text)
|
|
190
|
+
parts.append(f"- **{summary.splitlines()[0]}**")
|
|
191
|
+
remaining = "\n".join(summary.splitlines()[1:]).strip()
|
|
192
|
+
if remaining:
|
|
193
|
+
for line in remaining.splitlines():
|
|
194
|
+
parts.append(f" {line}")
|
|
195
|
+
total_chars += len(summary)
|
|
196
|
+
if total_chars > MAX_CONTEXT_CHARS:
|
|
197
|
+
break
|
|
198
|
+
parts.append("")
|
|
199
|
+
|
|
200
|
+
if gotchas and total_chars < MAX_CONTEXT_CHARS:
|
|
201
|
+
parts.append("## Known Gotchas for This Area\n")
|
|
202
|
+
for r in gotchas:
|
|
203
|
+
summary = _truncate_memory_text(r.text)
|
|
204
|
+
parts.append(f"- **{summary.splitlines()[0]}**")
|
|
205
|
+
remaining = "\n".join(summary.splitlines()[1:]).strip()
|
|
206
|
+
if remaining:
|
|
207
|
+
for line in remaining.splitlines():
|
|
208
|
+
parts.append(f" {line}")
|
|
209
|
+
total_chars += len(summary)
|
|
210
|
+
if total_chars > MAX_CONTEXT_CHARS:
|
|
211
|
+
break
|
|
212
|
+
parts.append("")
|
|
213
|
+
|
|
214
|
+
if other and total_chars < MAX_CONTEXT_CHARS:
|
|
215
|
+
parts.append("## Other Relevant Context\n")
|
|
216
|
+
for r in other:
|
|
217
|
+
summary = _truncate_memory_text(r.text, max_len=150)
|
|
218
|
+
first = summary.splitlines()[0]
|
|
219
|
+
parts.append(f"- [{r.memory_type}] {first}")
|
|
220
|
+
total_chars += len(first)
|
|
221
|
+
if total_chars > MAX_CONTEXT_CHARS:
|
|
222
|
+
break
|
|
223
|
+
parts.append("")
|
|
224
|
+
|
|
225
|
+
if coordination_session:
|
|
226
|
+
parts.append("## Active Coordination\n")
|
|
227
|
+
parts.append(f"- Active coordination session found: `{coordination_session}`")
|
|
228
|
+
parts.append("- Use `get_working_memory(session_id=\"project-coordination\")` via MCP to read details")
|
|
229
|
+
parts.append("")
|
|
230
|
+
|
|
231
|
+
if not results:
|
|
232
|
+
parts.append("## No Relevant Memories Found\n")
|
|
233
|
+
parts.append("No memories matched the current work area above the relevance threshold.")
|
|
234
|
+
parts.append("Use `devmemory search \"<query>\"` for broader searches.")
|
|
235
|
+
parts.append("")
|
|
236
|
+
|
|
237
|
+
parts.append("---")
|
|
238
|
+
parts.append(f"_Searched {len(results)} relevant memories across {len(signals.get('changed_files', []))} changed files._")
|
|
239
|
+
parts.append("_For deeper context, use `devmemory search \"<specific question>\"` or `search_long_term_memory()` via MCP._")
|
|
240
|
+
|
|
241
|
+
return "\n".join(parts) + "\n"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def run_context(
|
|
245
|
+
output: str = "",
|
|
246
|
+
quiet: bool = False,
|
|
247
|
+
):
|
|
248
|
+
config = DevMemoryConfig.load()
|
|
249
|
+
client = AMSClient(base_url=config.ams_endpoint)
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
client.health_check()
|
|
253
|
+
except Exception:
|
|
254
|
+
if not quiet:
|
|
255
|
+
console.print("[yellow]AMS not reachable — generating context from git signals only.[/yellow]")
|
|
256
|
+
signals = _get_git_signals()
|
|
257
|
+
content = _render_context(signals, [], None)
|
|
258
|
+
_write_output(content, output, quiet)
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
if not quiet:
|
|
262
|
+
console.print("[dim]Collecting git signals...[/dim]")
|
|
263
|
+
|
|
264
|
+
signals = _get_git_signals()
|
|
265
|
+
queries = _build_search_queries(signals)
|
|
266
|
+
|
|
267
|
+
if not quiet:
|
|
268
|
+
console.print(f"[dim]Searching memory with {len(queries)} queries...[/dim]")
|
|
269
|
+
|
|
270
|
+
ns = config.namespace or None
|
|
271
|
+
results = _search_with_dedup(client, queries, ns)
|
|
272
|
+
coordination = _fetch_coordination_state(client)
|
|
273
|
+
content = _render_context(signals, results, coordination)
|
|
274
|
+
_write_output(content, output, quiet)
|
|
275
|
+
|
|
276
|
+
if not quiet:
|
|
277
|
+
console.print(f"[green]Context generated with {len(results)} relevant memories.[/green]")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _write_output(content: str, output: str, quiet: bool):
|
|
281
|
+
out_path = Path(output) if output else Path.cwd() / DEFAULT_OUTPUT
|
|
282
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
283
|
+
out_path.write_text(content)
|
|
284
|
+
if not quiet:
|
|
285
|
+
console.print(f"[dim]Written to {out_path}[/dim]")
|