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.
@@ -0,0 +1,362 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import subprocess
7
+ from dataclasses import dataclass, field
8
+
9
+
10
+ @dataclass
11
+ class FileAttribution:
12
+ filepath: str
13
+ prompt_lines: dict[str, list[str]] = field(default_factory=dict)
14
+
15
+
16
+ @dataclass
17
+ class PromptData:
18
+ prompt_id: str
19
+ tool: str = ""
20
+ model: str = ""
21
+ human_author: str = ""
22
+ messages: list[dict] = field(default_factory=list)
23
+ total_additions: int = 0
24
+ total_deletions: int = 0
25
+ accepted_lines: int = 0
26
+ overridden_lines: int = 0
27
+
28
+
29
+ @dataclass
30
+ class CommitStats:
31
+ human_additions: int = 0
32
+ ai_additions: int = 0
33
+ ai_accepted: int = 0
34
+ mixed_additions: int = 0
35
+ total_ai_additions: int = 0
36
+ total_ai_deletions: int = 0
37
+ time_waiting_for_ai: float = 0.0
38
+ git_diff_added_lines: int = 0
39
+ git_diff_deleted_lines: int = 0
40
+ tool_model_breakdown: dict = field(default_factory=dict)
41
+
42
+
43
+ @dataclass
44
+ class CommitNote:
45
+ sha: str
46
+ author_name: str
47
+ author_email: str
48
+ subject: str
49
+ date: str
50
+ files: list[FileAttribution] = field(default_factory=list)
51
+ has_ai_note: bool = False
52
+ raw_note: str = ""
53
+ prompts: dict[str, PromptData] = field(default_factory=dict)
54
+ stats: CommitStats | None = None
55
+ body: str = ""
56
+
57
+
58
+ def _looks_like_filepath(line: str) -> bool:
59
+ stripped = line.strip()
60
+ if not stripped or len(stripped) < 2:
61
+ return False
62
+ if stripped in ("{", "}", "---", "...", "***"):
63
+ return False
64
+ if stripped.startswith("{") or stripped.startswith("["):
65
+ return False
66
+ if all(c in "-=_~*#<>{}[]()@!$%^&+|\\\"'" for c in stripped):
67
+ return False
68
+ if "/" in stripped or "." in stripped:
69
+ return True
70
+ if re.match(r'^[a-zA-Z0-9_]', stripped) and not stripped.startswith(" "):
71
+ return True
72
+ return False
73
+
74
+
75
+ def get_repo_root() -> str | None:
76
+ try:
77
+ result = subprocess.run(
78
+ ["git", "rev-parse", "--show-toplevel"],
79
+ capture_output=True, text=True, check=True,
80
+ )
81
+ return result.stdout.strip()
82
+ except (subprocess.CalledProcessError, FileNotFoundError):
83
+ return None
84
+
85
+
86
+ def get_head_sha() -> str | None:
87
+ try:
88
+ result = subprocess.run(
89
+ ["git", "rev-parse", "HEAD"],
90
+ capture_output=True, text=True, check=True,
91
+ )
92
+ return result.stdout.strip()
93
+ except (subprocess.CalledProcessError, FileNotFoundError):
94
+ return None
95
+
96
+
97
+ def get_commit_diff(sha: str) -> str:
98
+ try:
99
+ result = subprocess.run(
100
+ ["git", "diff", f"{sha}~1..{sha}", "--stat"],
101
+ capture_output=True, text=True, check=True,
102
+ )
103
+ return result.stdout.strip()
104
+ except subprocess.CalledProcessError:
105
+ return ""
106
+
107
+
108
+ def get_commit_diff_full(sha: str) -> str:
109
+ try:
110
+ result = subprocess.run(
111
+ ["git", "diff", f"{sha}~1..{sha}", "--no-color"],
112
+ capture_output=True, text=True, check=True,
113
+ )
114
+ return result.stdout.strip()
115
+ except subprocess.CalledProcessError:
116
+ return ""
117
+
118
+
119
+ def get_per_file_diffs(sha: str) -> dict[str, str]:
120
+ full_diff = get_commit_diff_full(sha)
121
+ if not full_diff:
122
+ return {}
123
+
124
+ file_diffs: dict[str, str] = {}
125
+ current_file = ""
126
+ current_lines: list[str] = []
127
+
128
+ for line in full_diff.splitlines():
129
+ if line.startswith("diff --git"):
130
+ if current_file and current_lines:
131
+ file_diffs[current_file] = "\n".join(current_lines)
132
+ match = re.search(r' b/(.+)$', line)
133
+ current_file = match.group(1) if match else ""
134
+ current_lines = []
135
+ elif current_file:
136
+ current_lines.append(line)
137
+
138
+ if current_file and current_lines:
139
+ file_diffs[current_file] = "\n".join(current_lines)
140
+
141
+ return file_diffs
142
+
143
+
144
+ def get_commit_body(sha: str) -> str:
145
+ try:
146
+ result = subprocess.run(
147
+ ["git", "log", "-1", "--format=%b", sha],
148
+ capture_output=True, text=True, check=True,
149
+ )
150
+ return result.stdout.strip()
151
+ except subprocess.CalledProcessError:
152
+ return ""
153
+
154
+
155
+ def parse_ai_note(raw_note: str) -> list[FileAttribution]:
156
+ files: list[FileAttribution] = []
157
+ current_file: FileAttribution | None = None
158
+
159
+ for line in raw_note.splitlines():
160
+ stripped = line.strip()
161
+ if not stripped:
162
+ continue
163
+
164
+ if not line.startswith(" ") and not line.startswith("\t"):
165
+ if _looks_like_filepath(stripped):
166
+ current_file = FileAttribution(filepath=stripped)
167
+ files.append(current_file)
168
+ else:
169
+ current_file = None
170
+ elif current_file is not None:
171
+ parts = stripped.split(None, 1)
172
+ if len(parts) == 2:
173
+ prompt_id, line_ranges = parts
174
+ if re.match(r'^[a-f0-9]+$', prompt_id):
175
+ current_file.prompt_lines[prompt_id] = line_ranges.split(",")
176
+ elif len(parts) == 1 and re.match(r'^[a-f0-9]+$', parts[0]):
177
+ current_file.prompt_lines[parts[0]] = []
178
+
179
+ return files
180
+
181
+
182
+ def get_prompt_data(prompt_id: str, commit_sha: str | None = None) -> PromptData | None:
183
+ cmd = ["git-ai", "show-prompt", prompt_id]
184
+ if commit_sha:
185
+ cmd.extend(["--commit", commit_sha])
186
+
187
+ try:
188
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
189
+ except (subprocess.CalledProcessError, FileNotFoundError):
190
+ return None
191
+
192
+ try:
193
+ data = json.loads(result.stdout)
194
+ except (json.JSONDecodeError, ValueError):
195
+ return None
196
+
197
+ prompt = data.get("prompt", {})
198
+ agent_id = prompt.get("agent_id", {})
199
+
200
+ messages = prompt.get("messages", [])
201
+ if isinstance(messages, str):
202
+ messages = [{"role": "user", "content": messages}]
203
+
204
+ return PromptData(
205
+ prompt_id=prompt_id,
206
+ tool=agent_id.get("tool", ""),
207
+ model=agent_id.get("model", ""),
208
+ human_author=prompt.get("human_author", ""),
209
+ messages=messages,
210
+ total_additions=prompt.get("total_additions", 0),
211
+ total_deletions=prompt.get("total_deletions", 0),
212
+ accepted_lines=prompt.get("accepted_lines", 0),
213
+ overridden_lines=prompt.get("overriden_lines", 0),
214
+ )
215
+
216
+
217
+ def get_commit_stats(sha: str) -> CommitStats | None:
218
+ cmd = ["git-ai", "stats", sha, "--json"]
219
+ try:
220
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
221
+ except (subprocess.CalledProcessError, FileNotFoundError):
222
+ return None
223
+
224
+ try:
225
+ data = json.loads(result.stdout)
226
+ except (json.JSONDecodeError, ValueError):
227
+ return None
228
+
229
+ return CommitStats(
230
+ human_additions=data.get("human_additions", 0),
231
+ ai_additions=data.get("ai_additions", 0),
232
+ ai_accepted=data.get("ai_accepted", 0),
233
+ mixed_additions=data.get("mixed_additions", 0),
234
+ total_ai_additions=data.get("total_ai_additions", 0),
235
+ total_ai_deletions=data.get("total_ai_deletions", 0),
236
+ time_waiting_for_ai=data.get("time_waiting_for_ai", 0.0),
237
+ git_diff_added_lines=data.get("git_diff_added_lines", 0),
238
+ git_diff_deleted_lines=data.get("git_diff_deleted_lines", 0),
239
+ tool_model_breakdown=data.get("tool_model_breakdown", {}),
240
+ )
241
+
242
+
243
+ def _collect_prompt_ids(files: list[FileAttribution]) -> set[str]:
244
+ ids: set[str] = set()
245
+ for f in files:
246
+ ids.update(f.prompt_lines.keys())
247
+ return ids
248
+
249
+
250
+ def get_ai_note_for_commit(sha: str) -> str:
251
+ try:
252
+ result = subprocess.run(
253
+ ["git", "notes", "--ref=ai", "show", sha],
254
+ capture_output=True, text=True, check=True,
255
+ )
256
+ return result.stdout.strip()
257
+ except subprocess.CalledProcessError:
258
+ return ""
259
+
260
+
261
+ def get_commits_since(since_sha: str | None, limit: int = 50) -> list[dict]:
262
+ fmt = "%H|%an|%ae|%s|%aI"
263
+ if since_sha:
264
+ cmd = ["git", "log", f"--format={fmt}", f"{since_sha}..HEAD", f"-{limit}"]
265
+ else:
266
+ cmd = ["git", "log", f"--format={fmt}", f"-{limit}"]
267
+
268
+ try:
269
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
270
+ except subprocess.CalledProcessError:
271
+ return []
272
+
273
+ commits = []
274
+ for line in result.stdout.strip().splitlines():
275
+ if not line.strip():
276
+ continue
277
+ parts = line.split("|", 4)
278
+ if len(parts) < 5:
279
+ continue
280
+ commits.append({
281
+ "sha": parts[0],
282
+ "author_name": parts[1],
283
+ "author_email": parts[2],
284
+ "subject": parts[3],
285
+ "date": parts[4],
286
+ })
287
+ return commits
288
+
289
+
290
+ def _build_commit_note(c: dict, enrich: bool = True) -> CommitNote:
291
+ raw_note = get_ai_note_for_commit(c["sha"])
292
+ has_ai = bool(raw_note)
293
+ files = parse_ai_note(raw_note) if has_ai else []
294
+
295
+ prompts: dict[str, PromptData] = {}
296
+ stats: CommitStats | None = None
297
+ body = ""
298
+
299
+ if has_ai and enrich:
300
+ prompt_ids = _collect_prompt_ids(files)
301
+ for pid in prompt_ids:
302
+ pd = get_prompt_data(pid, commit_sha=c["sha"])
303
+ if pd:
304
+ prompts[pid] = pd
305
+
306
+ stats = get_commit_stats(c["sha"])
307
+ body = get_commit_body(c["sha"])
308
+
309
+ return CommitNote(
310
+ sha=c["sha"],
311
+ author_name=c["author_name"],
312
+ author_email=c["author_email"],
313
+ subject=c["subject"],
314
+ date=c["date"],
315
+ files=files,
316
+ has_ai_note=has_ai,
317
+ raw_note=raw_note,
318
+ prompts=prompts,
319
+ stats=stats,
320
+ body=body,
321
+ )
322
+
323
+
324
+ def get_ai_notes_since(since_sha: str | None, limit: int = 50) -> list[CommitNote]:
325
+ commits = get_commits_since(since_sha, limit)
326
+ return [_build_commit_note(c) for c in commits]
327
+
328
+
329
+ def get_latest_commit_note() -> CommitNote | None:
330
+ commits = get_commits_since(None, limit=1)
331
+ if not commits:
332
+ return None
333
+ return _build_commit_note(commits[0])
334
+
335
+
336
+ def is_git_ai_installed() -> bool:
337
+ try:
338
+ result = subprocess.run(
339
+ ["git-ai", "version"],
340
+ capture_output=True, text=True,
341
+ )
342
+ return result.returncode == 0
343
+ except FileNotFoundError:
344
+ try:
345
+ result = subprocess.run(
346
+ ["git", "ai", "version"],
347
+ capture_output=True, text=True,
348
+ )
349
+ return result.returncode == 0
350
+ except FileNotFoundError:
351
+ return False
352
+
353
+
354
+ def get_git_ai_version() -> str:
355
+ for cmd in [["git-ai", "version"], ["git", "ai", "version"]]:
356
+ try:
357
+ result = subprocess.run(cmd, capture_output=True, text=True)
358
+ if result.returncode == 0:
359
+ return result.stdout.strip()
360
+ except FileNotFoundError:
361
+ continue
362
+ return "not installed"
@@ -0,0 +1,119 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import httpx
5
+ from pathlib import Path
6
+
7
+
8
+ def _find_env_file() -> Path | None:
9
+ cwd = Path.cwd()
10
+ for d in [cwd, *cwd.parents]:
11
+ env_path = d / ".env"
12
+ if env_path.exists():
13
+ if (d / ".git").exists() or (d / "docker-compose.yml").exists():
14
+ return env_path
15
+ if d == d.parent:
16
+ break
17
+ env_path = cwd / ".env"
18
+ if env_path.exists():
19
+ return env_path
20
+ return None
21
+
22
+
23
+ def _parse_env_file(path: Path) -> dict[str, str]:
24
+ env_vars: dict[str, str] = {}
25
+ for line in path.read_text().splitlines():
26
+ line = line.strip()
27
+ if not line or line.startswith("#"):
28
+ continue
29
+ if "=" in line:
30
+ key, _, value = line.partition("=")
31
+ key = key.strip()
32
+ value = value.strip().strip("'\"")
33
+ env_vars[key] = value
34
+ return env_vars
35
+
36
+
37
+ def _get_env_var(key: str) -> str:
38
+ value = os.environ.get(key, "")
39
+ if value:
40
+ return value
41
+ env_file = _find_env_file()
42
+ if env_file:
43
+ env_vars = _parse_env_file(env_file)
44
+ return env_vars.get(key, "")
45
+ return ""
46
+
47
+
48
+ def _get_llm_config() -> tuple[str, str]:
49
+ api_key = _get_env_var("OPENAI_API_KEY")
50
+ model = _get_env_var("GENERATION_MODEL") or "gpt-4o-mini"
51
+ return api_key, model
52
+
53
+
54
+ SYSTEM_PROMPT = """\
55
+ You are a knowledgebase assistant for a software development project. \
56
+ You answer questions by synthesizing information from the project's memory store, \
57
+ which contains commit histories, code changes, AI prompts, and development context.
58
+
59
+ Given the user's question and retrieved memories from the store, provide a clear, concise answer.
60
+
61
+ Rules:
62
+ - Be concise and direct (2-5 sentences for simple queries, more for complex ones)
63
+ - Reference specific commits (SHA), files, or code patterns when relevant
64
+ - If the memories are not relevant to the question, clearly state: \
65
+ "The available memories don't contain information relevant to this question."
66
+ - Don't fabricate information not present in the memories
67
+ - Focus on answering the question, not describing the memories themselves
68
+ - When memories show a pattern of changes (e.g., multiple fixes to the same file), \
69
+ summarize the evolution rather than listing each change"""
70
+
71
+
72
+ class LLMError(Exception):
73
+ pass
74
+
75
+
76
+ def synthesize_answer(
77
+ query: str,
78
+ memories: list[dict],
79
+ timeout: float = 60.0,
80
+ ) -> str | None:
81
+ api_key, model = _get_llm_config()
82
+
83
+ if not api_key:
84
+ raise LLMError("no_api_key")
85
+
86
+ context_parts = []
87
+ for i, mem in enumerate(memories, 1):
88
+ topics_str = ", ".join(mem.get("topics", []))
89
+ header = f"--- Memory {i} (type: {mem['type']}, score: {mem['score']:.3f}"
90
+ if topics_str:
91
+ header += f", topics: {topics_str}"
92
+ header += ") ---"
93
+ context_parts.append(f"{header}\n{mem['text']}")
94
+
95
+ context = "\n\n".join(context_parts)
96
+
97
+ user_msg = f"Question: {query}\n\nRetrieved memories ({len(memories)} results):\n\n{context}"
98
+
99
+ with httpx.Client(timeout=timeout) as client:
100
+ resp = client.post(
101
+ "https://api.openai.com/v1/chat/completions",
102
+ headers={
103
+ "Authorization": f"Bearer {api_key}",
104
+ "Content-Type": "application/json",
105
+ },
106
+ json={
107
+ "model": model,
108
+ "messages": [
109
+ {"role": "system", "content": SYSTEM_PROMPT},
110
+ {"role": "user", "content": user_msg},
111
+ ],
112
+ "max_completion_tokens": 1000,
113
+ },
114
+ )
115
+ if resp.status_code != 200:
116
+ error_msg = resp.json().get("error", {}).get("message", resp.text[:200])
117
+ raise LLMError(f"OpenAI API error ({resp.status_code}): {error_msg}")
118
+ data = resp.json()
119
+ return data["choices"][0]["message"]["content"]