claude-memory-agent 3.1.0 → 3.2.0

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.
@@ -58,7 +58,9 @@ function runSync(cmd, cwd) {
58
58
  */
59
59
  function buildEnvContent(config, agentDir) {
60
60
  const timestamp = new Date().toISOString();
61
- const dbPath = config.dbPath || path.join(agentDir, 'memories.db').replace(/\\/g, '/');
61
+ const homedir = require('os').homedir();
62
+ const defaultDbPath = path.join(homedir, '.claude-memory', 'memories.db').replace(/\\/g, '/');
63
+ const dbPath = config.dbPath || defaultDbPath;
62
64
  const memoryUrl = 'http://' + (config.host === '0.0.0.0' ? 'localhost' : config.host) + ':' + config.port;
63
65
 
64
66
  const lines = [
@@ -44,7 +44,7 @@ async function promptAdvanced() {
44
44
  });
45
45
 
46
46
  const dbPath = await input({
47
- message: 'Database path (leave empty for agent directory):',
47
+ message: 'Database path (leave empty for ~/.claude-memory/):',
48
48
  default: '',
49
49
  });
50
50
 
package/config.py CHANGED
@@ -21,6 +21,9 @@ logger = logging.getLogger(__name__)
21
21
  AGENT_DIR = Path(__file__).parent.resolve()
22
22
  load_dotenv(AGENT_DIR / ".env")
23
23
 
24
+ # User data directory — safe from code updates (zip, git pull, npm update)
25
+ USER_DATA_DIR = Path(os.getenv("USER_DATA_DIR", str(Path.home() / ".claude-memory")))
26
+
24
27
 
25
28
  class Config:
26
29
  """Configuration singleton with environment variable loading."""
@@ -30,11 +33,15 @@ class Config:
30
33
  self.AGENT_DIR = AGENT_DIR
31
34
  self.DATABASE_PATH = Path(os.getenv(
32
35
  "DATABASE_PATH",
33
- str(AGENT_DIR / "memories.db")
36
+ str(USER_DATA_DIR / "memories.db")
34
37
  ))
35
38
  self.INDEX_DIR = Path(os.getenv(
36
39
  "INDEX_DIR",
37
- str(AGENT_DIR / "indexes")
40
+ str(USER_DATA_DIR / "indexes")
41
+ ))
42
+ self.QUEUE_DB_PATH = Path(os.getenv(
43
+ "QUEUE_DB_PATH",
44
+ str(USER_DATA_DIR / "queue.db")
38
45
  ))
39
46
  self.LOG_FILE = AGENT_DIR / "memory-agent.log"
40
47
  self.LOCK_FILE = AGENT_DIR / "memory-agent.lock"
@@ -207,3 +214,4 @@ OLLAMA_HOST = config.OLLAMA_HOST
207
214
  EMBEDDING_PROVIDER = config.EMBEDDING_PROVIDER
208
215
  EMBEDDING_MODEL = config.EMBEDDING_MODEL
209
216
  DATABASE_PATH = config.DATABASE_PATH
217
+ QUEUE_DB_PATH = config.QUEUE_DB_PATH
@@ -66,10 +66,23 @@ PATTERN_PATTERNS = [
66
66
  re.compile(r"(?:^|\n)\s*(?:Always|Never|Should always|Should never|Must always|Must never) (.*?)(?:\.|$)", re.IGNORECASE | re.MULTILINE),
67
67
  ]
68
68
 
69
+ # Workflow/procedure patterns
70
+ WORKFLOW_PATTERNS = [
71
+ # "To build X, run Y" / "To deploy X, do Y"
72
+ re.compile(r"(?:^|\n)\s*(?:To|to) (\w[\w\s]{3,30}),\s*(?:run|do|use|execute|type) (.{10,}?)(?:\n|$)", re.IGNORECASE | re.MULTILINE),
73
+ # "learned how to..."
74
+ re.compile(r"(?:^|\n)\s*(?:I learned|We learned|learned how to|figured out how to) (.{20,}?)(?:\.|$)", re.IGNORECASE | re.MULTILINE),
75
+ # Step-by-step: "1. ...\n2. ...\n3. ..."
76
+ re.compile(r"(?:^|\n)\s*1[.)]\s+(.+)\n\s*2[.)]\s+(.+)\n\s*3[.)]\s+(.+)", re.MULTILINE),
77
+ # "The workflow is..." / "The process is..."
78
+ re.compile(r"(?:^|\n)\s*(?:The workflow|The process|The procedure|Steps to) (?:is|are|for)[:\s]+(.*?)(?:\n\n|\Z)", re.IGNORECASE | re.DOTALL),
79
+ ]
80
+
69
81
  # Broader keyword triggers (used for line-level scanning)
70
82
  DECISION_KEYWORDS = {"decided", "let's use", "going with", "chose", "choosing", "will use", "the plan is", "approach is", "strategy is", "i'll implement", "we'll implement"}
71
83
  ERROR_KEYWORDS = {"error", "bug", "fix", "issue", "traceback", "exception", "failed", "failure", "broken", "crash", "root cause"}
72
84
  PATTERN_KEYWORDS = {"pattern", "approach", "architecture", "convention", "best practice", "always", "never", "rule"}
85
+ WORKFLOW_KEYWORDS = {"workflow", "procedure", "steps to", "how to", "process for", "pipeline", "build steps", "deploy steps"}
73
86
 
74
87
 
75
88
  # ---------------------------------------------------------------------------
@@ -224,6 +237,13 @@ def extract_from_text(text: str, existing_hashes: set) -> List[Dict[str, Any]]:
224
237
  if len(context) > 30:
225
238
  add_extraction(context, "code", 6, ["pattern"])
226
239
 
240
+ # Workflows / Procedures
241
+ for pattern in WORKFLOW_PATTERNS:
242
+ for match in pattern.finditer(text):
243
+ context = extract_context_around(text, match.start(), match.end(), context_chars=300)
244
+ if len(context) > 40:
245
+ add_extraction(context, "code", 7, ["workflow", "procedure"])
246
+
227
247
  # --- Line-level keyword scanning (fallback for cases regex misses) ---
228
248
  # Only do this if we have not yet hit our cap
229
249
  if len(extractions) < MAX_MEMORIES_PER_RUN:
@@ -256,6 +276,12 @@ def extract_from_text(text: str, existing_hashes: set) -> List[Dict[str, Any]]:
256
276
  if len(block) > 30:
257
277
  add_extraction(block, "code", 5, ["pattern", "keyword-match"])
258
278
 
279
+ # Check for workflow keywords
280
+ elif any(kw in line_lower for kw in WORKFLOW_KEYWORDS):
281
+ block = '\n'.join(lines[i:i+5]).strip() # Wider context for workflows
282
+ if len(block) > 40:
283
+ add_extraction(block, "code", 6, ["workflow", "keyword-match"])
284
+
259
285
  i += 1
260
286
 
261
287
  return extractions
@@ -313,6 +339,84 @@ def store_memory_sync(extraction: Dict[str, Any], project_path: Optional[str] =
313
339
  return False
314
340
 
315
341
 
342
+ # ---------------------------------------------------------------------------
343
+ # Workflow / Bash command extraction from JSONL transcript
344
+ # ---------------------------------------------------------------------------
345
+
346
+ def extract_bash_commands(transcript_path: str, byte_offset: int = 0) -> List[str]:
347
+ """Extract successful bash commands from JSONL transcript.
348
+
349
+ Looks for tool_use blocks with tool=Bash that were followed by success results.
350
+ Returns deduplicated command list.
351
+ """
352
+ path = Path(transcript_path)
353
+ if not path.exists():
354
+ return []
355
+
356
+ commands = []
357
+ seen = set()
358
+ try:
359
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
360
+ if byte_offset > 0:
361
+ f.seek(byte_offset)
362
+ f.readline() # skip partial line
363
+ for line in f:
364
+ line = line.strip()
365
+ if not line:
366
+ continue
367
+ try:
368
+ msg = json.loads(line)
369
+ content = msg.get("content", [])
370
+ if not isinstance(content, list):
371
+ continue
372
+ for part in content:
373
+ if not isinstance(part, dict):
374
+ continue
375
+ if part.get("type") == "tool_use" and part.get("name") == "Bash":
376
+ cmd = ""
377
+ inp = part.get("input", {})
378
+ if isinstance(inp, dict):
379
+ cmd = inp.get("command", "")
380
+ if cmd and len(cmd) > 5 and cmd not in seen:
381
+ # Skip trivial commands
382
+ if not cmd.strip().startswith(("ls", "pwd", "echo", "cat ")):
383
+ seen.add(cmd)
384
+ commands.append(cmd)
385
+ except (json.JSONDecodeError, TypeError):
386
+ continue
387
+ except OSError:
388
+ pass
389
+ return commands[-20:] # Keep last 20 commands
390
+
391
+
392
+ def capture_workflow_sync(name: str, steps: List[str], commands: List[str],
393
+ project_path: Optional[str] = None) -> bool:
394
+ """POST a captured workflow to /api/workflow/capture."""
395
+ import urllib.request
396
+ import urllib.error
397
+
398
+ payload = json.dumps({
399
+ "name": name,
400
+ "steps": steps,
401
+ "commands": commands,
402
+ "project_path": project_path or "",
403
+ }).encode("utf-8")
404
+
405
+ headers = {"Content-Type": "application/json"}
406
+ if API_KEY:
407
+ headers["X-Memory-Key"] = API_KEY
408
+
409
+ try:
410
+ req = urllib.request.Request(
411
+ f"{MEMORY_AGENT_URL}/api/workflow/capture",
412
+ data=payload, headers=headers, method="POST",
413
+ )
414
+ with urllib.request.urlopen(req, timeout=2) as resp:
415
+ return resp.status == 200
416
+ except (urllib.error.URLError, urllib.error.HTTPError, OSError, TimeoutError):
417
+ return False
418
+
419
+
316
420
  # ---------------------------------------------------------------------------
317
421
  # Main entry point
318
422
  # ---------------------------------------------------------------------------
@@ -7,14 +7,23 @@ server-side.
7
7
 
8
8
  Also replaces: session_start.py, problem-detector.py, memory-first-reminder.py
9
9
 
10
- Output: compact [MEM] line (<150 tokens)
11
- Timeout: 3 seconds, silent fail
10
+ Fresh session detection:
11
+ - Tracks last grounded session_id via .claude_session_meta
12
+ - Fresh session -> calls /api/grounding-context/rich (~500-800 tokens)
13
+ - Continuing session -> calls /api/grounding-context (~150 tokens)
14
+
15
+ Design constraints:
16
+ - Uses stdlib only (no pip dependencies) -- urllib.request, not requests
17
+ - Timeout: 3 seconds, silent fail
18
+ - Always exits 0 -- never blocks Claude Code
12
19
  """
13
20
 
14
21
  import os
15
22
  import sys
16
23
  import json
17
24
  import logging
25
+ import urllib.request
26
+ import urllib.error
18
27
  from pathlib import Path
19
28
 
20
29
  logging.basicConfig(
@@ -26,15 +35,61 @@ logger = logging.getLogger("grounding-v2")
26
35
 
27
36
  MEMORY_AGENT_URL = os.getenv("MEMORY_AGENT_URL", "http://localhost:8102")
28
37
  TIMEOUT = 3 # seconds
38
+ SESSION_META_DIR = Path.home() / ".claude"
39
+ SESSION_META_FILE = SESSION_META_DIR / ".claude_session_meta"
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # HTTP helper (stdlib only -- no requests dependency)
44
+ # ---------------------------------------------------------------------------
45
+
46
+ def _http_post(url: str, payload: dict, timeout: float = TIMEOUT):
47
+ """POST JSON to url and return parsed response dict, or None on failure."""
48
+ data = json.dumps(payload).encode("utf-8")
49
+ req = urllib.request.Request(
50
+ url, data=data,
51
+ headers={"Content-Type": "application/json"},
52
+ method="POST",
53
+ )
54
+ try:
55
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
56
+ if resp.status == 200:
57
+ return json.loads(resp.read().decode("utf-8"))
58
+ except (urllib.error.URLError, urllib.error.HTTPError, OSError, TimeoutError):
59
+ pass
60
+ return None
61
+
29
62
 
63
+ # ---------------------------------------------------------------------------
64
+ # Stdin / session ID helpers
65
+ # ---------------------------------------------------------------------------
30
66
 
31
- def get_session_id() -> str:
32
- """Get session ID from env or .claude_session file."""
67
+ def read_stdin_payload() -> dict:
68
+ """Read the full JSON payload from Claude Code stdin (once)."""
69
+ try:
70
+ if not sys.stdin.isatty():
71
+ data = sys.stdin.read()
72
+ if data:
73
+ return json.loads(data)
74
+ except Exception:
75
+ pass
76
+ return {}
77
+
78
+
79
+ def get_session_id(payload: dict, project_path: str) -> str:
80
+ """Get session ID from stdin payload, env, or .claude_session file."""
81
+ # 1. stdin payload (Claude Code's actual format)
82
+ sid = payload.get("session_id", "")
83
+ if sid:
84
+ return sid
85
+
86
+ # 2. env var
33
87
  sid = os.getenv("CLAUDE_SESSION_ID", "")
34
88
  if sid:
35
89
  return sid
36
90
 
37
- session_file = Path(os.getcwd()) / ".claude_session"
91
+ # 3. .claude_session file
92
+ session_file = Path(project_path) / ".claude_session"
38
93
  if session_file.exists():
39
94
  try:
40
95
  content = session_file.read_text().strip()
@@ -45,31 +100,87 @@ def get_session_id() -> str:
45
100
  return ""
46
101
 
47
102
 
48
- def get_user_input() -> str:
49
- """Extract user input from hook stdin."""
103
+ # ---------------------------------------------------------------------------
104
+ # Fresh session detection
105
+ # ---------------------------------------------------------------------------
106
+
107
+ def is_fresh_session(session_id: str, project_path: str) -> bool:
108
+ """Detect if this is a fresh/resumed session by comparing to last grounded session_id.
109
+
110
+ Uses .claude_session_meta to track what we last grounded for this project.
111
+ Returns True if session_id differs from last grounded (= new session or context cleared).
112
+ """
50
113
  try:
51
- if not sys.stdin.isatty():
52
- data = sys.stdin.read()
53
- if data:
54
- hook_data = json.loads(data)
55
- return hook_data.get("prompt", hook_data.get("user_prompt", ""))
56
- except Exception:
114
+ if SESSION_META_FILE.exists():
115
+ meta = json.loads(SESSION_META_FILE.read_text(encoding="utf-8"))
116
+ last_grounded = meta.get(project_path, {}).get("last_grounded_session", "")
117
+ last_grounded_count = meta.get(project_path, {}).get("grounding_count", 0)
118
+ if last_grounded == session_id and last_grounded_count > 0:
119
+ return False
120
+ return True
121
+ except (json.JSONDecodeError, OSError):
57
122
  pass
58
- return ""
123
+ return True # Default to fresh if can't read meta
59
124
 
60
125
 
126
+ def update_session_meta(session_id: str, project_path: str):
127
+ """Record that we've grounded this session_id for this project."""
128
+ try:
129
+ SESSION_META_DIR.mkdir(parents=True, exist_ok=True)
130
+ meta = {}
131
+ if SESSION_META_FILE.exists():
132
+ try:
133
+ meta = json.loads(SESSION_META_FILE.read_text(encoding="utf-8"))
134
+ except (json.JSONDecodeError, OSError):
135
+ meta = {}
136
+
137
+ if project_path not in meta:
138
+ meta[project_path] = {}
139
+
140
+ current = meta[project_path]
141
+ if current.get("last_grounded_session") == session_id:
142
+ current["grounding_count"] = current.get("grounding_count", 0) + 1
143
+ else:
144
+ current["last_grounded_session"] = session_id
145
+ current["grounding_count"] = 1
146
+
147
+ # Prune old projects (keep 20)
148
+ if len(meta) > 20:
149
+ for old_key in sorted(meta.keys())[:-20]:
150
+ del meta[old_key]
151
+
152
+ SESSION_META_FILE.write_text(json.dumps(meta, indent=2), encoding="utf-8")
153
+ except OSError:
154
+ pass
155
+
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # Self-reflection hint (appended to every grounding context)
159
+ # ---------------------------------------------------------------------------
160
+
161
+ LEARNING_HINT = (
162
+ "\nIf you discovered something new/unique this response "
163
+ "(a non-obvious fix, a codebase quirk, a useful workflow), "
164
+ "append a hidden tag at the very end: <!-- LEARNED: one-sentence description -->"
165
+ )
166
+
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # Main
170
+ # ---------------------------------------------------------------------------
171
+
61
172
  def main():
62
- session_id = get_session_id()
63
- project_path = os.getcwd()
64
- user_input = get_user_input()
173
+ payload = read_stdin_payload()
174
+ project_path = payload.get("cwd", "") or os.getcwd()
175
+ user_input = payload.get("prompt", "") or payload.get("user_prompt", "")
176
+ session_id = get_session_id(payload, project_path)
65
177
 
66
178
  if not session_id:
67
179
  # No session - try to initialize one via A2A
68
180
  try:
69
- import requests
70
- resp = requests.post(
181
+ result = _http_post(
71
182
  f"{MEMORY_AGENT_URL}/a2a",
72
- json={
183
+ {
73
184
  "jsonrpc": "2.0",
74
185
  "id": "grounding-v2-init",
75
186
  "method": "tasks/send",
@@ -81,16 +192,13 @@ def main():
81
192
  },
82
193
  },
83
194
  },
84
- timeout=TIMEOUT,
85
195
  )
86
- if resp.status_code == 200:
87
- result = resp.json()
196
+ if result:
88
197
  try:
89
198
  text = result["result"]["artifacts"][0]["parts"][0]["text"]
90
199
  data = json.loads(text)
91
200
  session_id = data.get("session_id", "")
92
201
  if session_id:
93
- # Save for future hooks
94
202
  sf = Path(project_path) / ".claude_session"
95
203
  sf.write_text(json.dumps({"session_id": session_id}))
96
204
  except (KeyError, IndexError, json.JSONDecodeError):
@@ -101,26 +209,54 @@ def main():
101
209
  if not session_id:
102
210
  sys.exit(0)
103
211
 
104
- # Single aggregated call
212
+ # Register session as active (for cross-session awareness)
213
+ try:
214
+ _http_post(
215
+ f"{MEMORY_AGENT_URL}/api/sessions/register",
216
+ {"session_id": session_id, "project_path": project_path},
217
+ )
218
+ except Exception as e:
219
+ logger.debug(f"Session register failed: {e}")
220
+
221
+ # Detect fresh vs continuing session
222
+ fresh = is_fresh_session(session_id, project_path)
223
+
224
+ if fresh:
225
+ # Fresh session: use rich grounding context (~500-800 tokens)
226
+ try:
227
+ data = _http_post(
228
+ f"{MEMORY_AGENT_URL}/api/grounding-context/rich",
229
+ {"session_id": session_id, "project_path": project_path},
230
+ )
231
+ if data:
232
+ context = data.get("context", "")
233
+ if context:
234
+ print(context + LEARNING_HINT)
235
+ update_session_meta(session_id, project_path)
236
+ sys.exit(0)
237
+ except Exception as e:
238
+ logger.debug(f"Rich grounding context call failed: {e}")
239
+ # Fall through to slim context
240
+
241
+ # Continuing session (or rich context failed): use slim grounding context (~150 tokens)
105
242
  try:
106
- import requests
107
- resp = requests.post(
243
+ data = _http_post(
108
244
  f"{MEMORY_AGENT_URL}/api/grounding-context",
109
- json={
245
+ {
110
246
  "session_id": session_id,
111
247
  "project_path": project_path,
112
248
  "user_input": user_input,
113
249
  },
114
- timeout=TIMEOUT,
115
250
  )
116
- if resp.status_code == 200:
117
- data = resp.json()
251
+ if data:
118
252
  context = data.get("context", "")
119
253
  if context:
120
- print(context)
254
+ print(context + LEARNING_HINT)
121
255
  except Exception as e:
122
256
  logger.debug(f"Grounding context call failed: {e}")
123
- # Silent fail - don't break Claude Code
257
+
258
+ # Update meta (even for slim context, so we track this session)
259
+ update_session_meta(session_id, project_path)
124
260
 
125
261
  sys.exit(0)
126
262