superlocalmemory 3.4.8 → 3.4.10

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,6 +58,8 @@ def dispatch(args: Namespace) -> None:
58
58
  "reap": cmd_reap,
59
59
  # V3.3.21 daemon
60
60
  "serve": cmd_serve,
61
+ # V3.4.9 nuclear restart
62
+ "restart": cmd_restart,
61
63
  # V3.4.3 ingestion adapters
62
64
  "adapters": cmd_adapters,
63
65
  # V3.4.8 external observation ingestion
@@ -146,6 +148,193 @@ def cmd_serve(args: Namespace) -> None:
146
148
  # -- Ingestion Adapters (V3.4.3) ------------------------------------------
147
149
 
148
150
 
151
+ def cmd_restart(args: Namespace) -> None:
152
+ """Nuclear restart: kill ALL orphans, clean state, start fresh, verify health.
153
+
154
+ 5-step pipeline:
155
+ 1. Kill ALL SLM processes (daemon + workers + orphans)
156
+ 2. Clean stale PID/port/lock files
157
+ 3. Start fresh daemon
158
+ 4. Wait for engine warmup + verify health
159
+ 5. Optionally open dashboard
160
+ """
161
+ import os
162
+ import time
163
+ from pathlib import Path
164
+
165
+ use_json = getattr(args, "json", False)
166
+ open_dashboard = getattr(args, "dashboard", False)
167
+ slm_dir = Path.home() / ".superlocalmemory"
168
+ steps: list[dict] = []
169
+
170
+ def _log(step: int, name: str, status: str, detail: str = ""):
171
+ entry = {"step": step, "name": name, "status": status, "detail": detail}
172
+ steps.append(entry)
173
+ if not use_json:
174
+ icon = {"ok": "+", "warn": "!", "fail": "x"}.get(status, " ")
175
+ print(f" [{icon}] Step {step}: {name}" + (f" — {detail}" if detail else ""))
176
+
177
+ if not use_json:
178
+ print()
179
+ print(" SLM Full System Restart")
180
+ print(" " + "=" * 40)
181
+ print()
182
+
183
+ # Step 1: Kill ALL SLM processes
184
+ killed = 0
185
+ try:
186
+ import psutil
187
+ my_pid = os.getpid()
188
+ targets = [
189
+ "superlocalmemory.server.unified_daemon",
190
+ "superlocalmemory.core.embedding_worker",
191
+ "superlocalmemory.core.recall_worker",
192
+ "superlocalmemory.core.reranker_worker",
193
+ "superlocalmemory.cli.daemon",
194
+ ]
195
+ for proc in psutil.process_iter(["pid", "cmdline"]):
196
+ try:
197
+ if proc.pid == my_pid:
198
+ continue
199
+ cmdline = " ".join(proc.info.get("cmdline") or [])
200
+ if any(t in cmdline for t in targets):
201
+ for child in proc.children(recursive=True):
202
+ try:
203
+ child.kill()
204
+ killed += 1
205
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
206
+ pass
207
+ proc.kill()
208
+ killed += 1
209
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
210
+ pass
211
+ except ImportError:
212
+ # Fallback: pkill
213
+ import subprocess as _sp
214
+ for pattern in [
215
+ "superlocalmemory.server.unified_daemon",
216
+ "superlocalmemory.core.embedding_worker",
217
+ "superlocalmemory.core.recall_worker",
218
+ "superlocalmemory.core.reranker_worker",
219
+ ]:
220
+ try:
221
+ r = _sp.run(["pkill", "-9", "-f", pattern], capture_output=True, timeout=5)
222
+ if r.returncode == 0:
223
+ killed += 1
224
+ except Exception:
225
+ pass
226
+
227
+ _log(1, "Kill all SLM processes", "ok", f"{killed} processes killed")
228
+ time.sleep(3)
229
+
230
+ # Step 2: Clean stale files
231
+ cleaned = []
232
+ for fname in ("daemon.pid", "daemon.port", "daemon.lock"):
233
+ fpath = slm_dir / fname
234
+ if fpath.exists():
235
+ fpath.unlink(missing_ok=True)
236
+ cleaned.append(fname)
237
+ _log(2, "Clean stale state files", "ok",
238
+ f"removed: {', '.join(cleaned)}" if cleaned else "already clean")
239
+
240
+ # Step 3: Start fresh daemon
241
+ time.sleep(1)
242
+ from superlocalmemory.cli.daemon import ensure_daemon
243
+ started = ensure_daemon()
244
+ _log(3, "Start fresh daemon", "ok" if started else "fail",
245
+ "daemon started" if started else "failed to start — check slm doctor")
246
+
247
+ if not started:
248
+ if use_json:
249
+ from superlocalmemory.cli.json_output import json_print
250
+ json_print("restart", data={"steps": steps, "success": False},
251
+ next_actions=[{"command": "slm doctor", "description": "Diagnose issues"}])
252
+ else:
253
+ print("\n Restart FAILED at step 3. Run: slm doctor")
254
+ return
255
+
256
+ # Step 4: Wait for warmup + verify health
257
+ if not use_json:
258
+ print(" [ ] Step 4: Waiting for engine warmup (up to 30s)...", end="", flush=True)
259
+
260
+ health = None
261
+ engine_ok = False
262
+ for attempt in range(15):
263
+ time.sleep(2)
264
+ try:
265
+ from superlocalmemory.cli.daemon import daemon_request
266
+ health = daemon_request("GET", "/health")
267
+ if health and health.get("engine") == "initialized":
268
+ engine_ok = True
269
+ break
270
+ except Exception:
271
+ pass
272
+
273
+ if not use_json:
274
+ print("\r", end="") # Clear the waiting line
275
+
276
+ if engine_ok:
277
+ version = health.get("version", "?")
278
+ pid = health.get("pid", "?")
279
+ _log(4, "Engine health verified", "ok", f"v{version}, PID {pid}, engine=initialized")
280
+ else:
281
+ engine_state = health.get("engine", "unknown") if health else "unreachable"
282
+ _log(4, "Engine health check", "warn",
283
+ f"engine={engine_state} — may still be warming up. Try again in 30s.")
284
+
285
+ # Step 5: Database integrity check
286
+ try:
287
+ import sqlite3
288
+ db_path = slm_dir / "memory.db"
289
+ if db_path.exists():
290
+ conn = sqlite3.connect(str(db_path))
291
+ integrity = conn.execute("PRAGMA integrity_check").fetchone()[0]
292
+ fact_count = conn.execute("SELECT COUNT(*) FROM atomic_facts").fetchone()[0]
293
+ entity_count = conn.execute("SELECT COUNT(*) FROM canonical_entities").fetchone()[0]
294
+ conn.close()
295
+ _log(5, "Database integrity", "ok" if integrity == "ok" else "fail",
296
+ f"integrity={integrity}, {fact_count} facts, {entity_count} entities")
297
+ else:
298
+ _log(5, "Database check", "warn", "no database yet — will create on first use")
299
+ except Exception as exc:
300
+ _log(5, "Database check", "warn", str(exc))
301
+
302
+ # Step 6 (optional): Open dashboard
303
+ if open_dashboard:
304
+ try:
305
+ import webbrowser
306
+ from superlocalmemory.cli.daemon import _get_port
307
+ port = _get_port()
308
+ url = f"http://localhost:{port}"
309
+ webbrowser.open(url)
310
+ _log(6, "Dashboard opened", "ok", url)
311
+ except Exception as exc:
312
+ _log(6, "Dashboard open", "fail", str(exc))
313
+
314
+ # Summary
315
+ all_ok = all(s["status"] == "ok" for s in steps)
316
+
317
+ if use_json:
318
+ from superlocalmemory.cli.json_output import json_print
319
+ json_print("restart", data={
320
+ "steps": steps, "success": all_ok,
321
+ "processes_killed": killed,
322
+ "version": health.get("version") if health else None,
323
+ }, next_actions=[
324
+ {"command": "slm serve status", "description": "Check daemon status"},
325
+ {"command": "slm dashboard", "description": "Open dashboard"},
326
+ ])
327
+ return
328
+
329
+ print()
330
+ if all_ok:
331
+ print(" All systems operational.")
332
+ else:
333
+ warnings = [s for s in steps if s["status"] != "ok"]
334
+ print(f" {len(warnings)} issue(s) — check details above.")
335
+ print()
336
+
337
+
149
338
  def cmd_ingest(args: Namespace) -> None:
150
339
  """Import external observations into SLM learning pipeline."""
151
340
  from superlocalmemory.cli.ingest_cmd import cmd_ingest as _ingest
@@ -76,28 +76,43 @@ def _error(msg: str, use_json: bool) -> None:
76
76
 
77
77
 
78
78
  def _ingest_ecc(file_path: str, *, dry_run: bool = False) -> dict:
79
- """Ingest ECC session summaries from transcript JSONL files.
79
+ """Ingest ECC observations into SLM tool_events.
80
80
 
81
- Scans the Claude projects directory for session JSONL files and
82
- extracts tool usage patterns from them.
81
+ Scans TWO sources:
82
+ 1. ECC observation files (~/.claude/homunculus/projects/*/observations.jsonl)
83
+ — rich data: tool input/output, session_id, project context
84
+ 2. Claude session transcript files (~/.claude/projects/*.jsonl)
85
+ — fallback: tool_use blocks from conversation history
86
+
87
+ v3.4.10: Now preserves input_summary and output_summary from ECC observations.
83
88
  """
84
89
  result = {"source": "ecc", "ingested": 0, "skipped": 0, "dry_run": dry_run}
85
90
 
86
- # Find ECC session files
91
+ files: list[Path] = []
92
+
87
93
  if file_path:
88
94
  files = [Path(file_path)]
89
95
  else:
90
- # Auto-discover: scan Claude project session files
96
+ # Source 1: ECC observation files (RICH data — preferred)
97
+ ecc_dir = Path.home() / ".claude" / "homunculus" / "projects"
98
+ if ecc_dir.exists():
99
+ ecc_files = sorted(
100
+ ecc_dir.rglob("observations.jsonl"),
101
+ key=lambda p: p.stat().st_mtime, reverse=True,
102
+ )
103
+ files.extend(ecc_files[:20])
104
+
105
+ # Source 2: Claude session transcripts (fallback)
91
106
  claude_dir = Path.home() / ".claude" / "projects"
92
- if not claude_dir.exists():
93
- result["error"] = f"Claude projects dir not found: {claude_dir}"
94
- return result
95
- files = sorted(claude_dir.rglob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True)
96
- # Limit to recent files (last 20)
97
- files = files[:20]
107
+ if claude_dir.exists():
108
+ claude_files = sorted(
109
+ claude_dir.rglob("*.jsonl"),
110
+ key=lambda p: p.stat().st_mtime, reverse=True,
111
+ )
112
+ files.extend(claude_files[:20])
98
113
 
99
114
  if not files:
100
- result["error"] = "No session files found"
115
+ result["error"] = "No ECC observation or session files found"
101
116
  return result
102
117
 
103
118
  result["files_scanned"] = len(files)
@@ -116,7 +131,6 @@ def _ingest_ecc(file_path: str, *, dry_run: bool = False) -> dict:
116
131
  result["skipped"] += 1
117
132
  continue
118
133
 
119
- # Extract tool usage from ECC session records
120
134
  extracted = _extract_tool_events_from_record(record)
121
135
  events.extend(extracted)
122
136
  except (OSError, PermissionError):
@@ -128,7 +142,6 @@ def _ingest_ecc(file_path: str, *, dry_run: bool = False) -> dict:
128
142
  result["sample"] = events[:5]
129
143
  return result
130
144
 
131
- # Write to tool_events table
132
145
  if events:
133
146
  ingested = _write_tool_events(events)
134
147
  result["ingested"] = ingested
@@ -139,47 +152,79 @@ def _ingest_ecc(file_path: str, *, dry_run: bool = False) -> dict:
139
152
 
140
153
 
141
154
  def _extract_tool_events_from_record(record: dict) -> list[dict]:
142
- """Extract tool events from a single ECC/Claude session JSONL record."""
155
+ """Extract tool events from a single ECC/Claude session JSONL record.
156
+
157
+ Handles three formats:
158
+ 1. ECC observation format: {"event": "tool_complete", "tool": "X", "input": "...", "output": "..."}
159
+ 2. Claude transcript format: {"type": "assistant", "content": [{"type": "tool_use", ...}]}
160
+ 3. Direct tool event format: {"tool_name": "X", "event_type": "complete"}
161
+
162
+ v3.4.10: Preserves input_summary and output_summary from all formats.
163
+ """
143
164
  events = []
165
+ now_iso = datetime.now(timezone.utc).isoformat()
166
+
167
+ # Format 1: ECC observation format (from ~/.claude/homunculus/projects/*/observations.jsonl)
168
+ if "event" in record and "tool" in record:
169
+ event_type_raw = record.get("event", "")
170
+ if event_type_raw in ("tool_complete", "tool_start"):
171
+ event_type = "complete" if event_type_raw == "tool_complete" else "invoke"
172
+ events.append({
173
+ "tool_name": record["tool"],
174
+ "event_type": event_type,
175
+ "input_summary": str(record.get("input", ""))[:500],
176
+ "output_summary": str(record.get("output", ""))[:500],
177
+ "session_id": record.get("session", "ecc_import"),
178
+ "project_path": record.get("project_name", ""),
179
+ "created_at": record.get("timestamp", now_iso),
180
+ })
181
+ return events
144
182
 
145
- # Handle ECC summary format
183
+ # Format 2: Claude transcript format
146
184
  if "type" in record:
147
185
  rtype = record.get("type", "")
148
-
149
- # Tool use records
150
186
  if rtype == "assistant" and "content" in record:
151
187
  content = record.get("content", [])
152
188
  if isinstance(content, list):
153
189
  for block in content:
154
190
  if isinstance(block, dict) and block.get("type") == "tool_use":
155
191
  tool_name = block.get("name", "unknown")
192
+ raw_input = block.get("input", {})
193
+ input_str = json.dumps(raw_input, default=str)[:500] if isinstance(raw_input, dict) else str(raw_input)[:500]
156
194
  events.append({
157
195
  "tool_name": tool_name,
158
196
  "event_type": "complete",
197
+ "input_summary": input_str,
198
+ "output_summary": "",
159
199
  "session_id": record.get("session_id", "ecc_import"),
160
- "created_at": record.get("timestamp", datetime.now(timezone.utc).isoformat()),
200
+ "project_path": "",
201
+ "created_at": record.get("timestamp", now_iso),
161
202
  })
162
-
163
- # Also extract from tool_use type directly
164
- if isinstance(content, list):
165
- for block in content:
166
- if isinstance(block, dict) and block.get("type") == "tool_result":
203
+ elif isinstance(block, dict) and block.get("type") == "tool_result":
167
204
  tool_name = block.get("tool_use_id", "unknown")
168
205
  is_error = block.get("is_error", False)
206
+ raw_content = block.get("content", "")
207
+ output_str = str(raw_content)[:500] if raw_content else ""
169
208
  events.append({
170
209
  "tool_name": tool_name,
171
210
  "event_type": "error" if is_error else "complete",
211
+ "input_summary": "",
212
+ "output_summary": output_str,
172
213
  "session_id": record.get("session_id", "ecc_import"),
173
- "created_at": record.get("timestamp", datetime.now(timezone.utc).isoformat()),
214
+ "project_path": "",
215
+ "created_at": record.get("timestamp", now_iso),
174
216
  })
175
217
 
176
- # Handle direct tool event format (from hook output)
218
+ # Format 3: Direct tool event format (from hook output)
177
219
  if "tool_name" in record and "event_type" in record:
178
220
  events.append({
179
221
  "tool_name": record["tool_name"],
180
222
  "event_type": record.get("event_type", "complete"),
223
+ "input_summary": str(record.get("input_summary", ""))[:500],
224
+ "output_summary": str(record.get("output_summary", ""))[:500],
181
225
  "session_id": record.get("session_id", "ecc_import"),
182
- "created_at": record.get("created_at", datetime.now(timezone.utc).isoformat()),
226
+ "project_path": record.get("project_path", ""),
227
+ "created_at": record.get("created_at", now_iso),
183
228
  })
184
229
 
185
230
  return events
@@ -228,7 +273,11 @@ def _ingest_jsonl(file_path: str, *, dry_run: bool = False) -> dict:
228
273
 
229
274
 
230
275
  def _write_tool_events(events: list[dict]) -> int:
231
- """Write tool events to SLM's memory.db tool_events table."""
276
+ """Write tool events to SLM's memory.db tool_events table.
277
+
278
+ v3.4.10: Preserves input_summary, output_summary, and project_path
279
+ from enriched sources (ECC observations, enriched hook).
280
+ """
232
281
  db_path = MEMORY_DB
233
282
  if not db_path.exists():
234
283
  return 0
@@ -243,11 +292,14 @@ def _write_tool_events(events: list[dict]) -> int:
243
292
  "INSERT INTO tool_events "
244
293
  "(session_id, profile_id, project_path, tool_name, event_type, "
245
294
  " input_summary, output_summary, duration_ms, metadata, created_at) "
246
- "VALUES (?, 'default', '', ?, ?, '', '', 0, '{}', ?)",
295
+ "VALUES (?, 'default', ?, ?, ?, ?, ?, 0, '{}', ?)",
247
296
  (
248
297
  ev.get("session_id", "import"),
298
+ ev.get("project_path", ""),
249
299
  ev["tool_name"],
250
300
  ev.get("event_type", "complete"),
301
+ ev.get("input_summary", ""),
302
+ ev.get("output_summary", ""),
251
303
  ev.get("created_at", datetime.now(timezone.utc).isoformat()),
252
304
  ),
253
305
  )
@@ -199,6 +199,17 @@ def main() -> None:
199
199
  help="start (default), stop, status, install (OS service), uninstall",
200
200
  )
201
201
 
202
+ # V3.4.9: Full system restart with health verification
203
+ restart_p = sub.add_parser(
204
+ "restart",
205
+ help="Nuclear restart: kill orphans, clean state, start fresh, verify health",
206
+ )
207
+ restart_p.add_argument(
208
+ "--dashboard", action="store_true",
209
+ help="Open dashboard after restart",
210
+ )
211
+ restart_p.add_argument("--json", action="store_true", help="Output structured JSON")
212
+
202
213
  # -- Profiles ------------------------------------------------------
203
214
  profile_p = sub.add_parser("profile", help="Profile management (list/switch/create)")
204
215
  profile_p.add_argument(
@@ -178,6 +178,16 @@ class ConsolidationEngine:
178
178
  logger.debug("Soft prompt generation (non-fatal): %s", exc)
179
179
  results["soft_prompts"] = {"error": str(exc)}
180
180
 
181
+ # Step 10 (v3.4.10): Mine skill performance from tool events.
182
+ # Creates per-skill assertions and skill correlation patterns.
183
+ try:
184
+ from superlocalmemory.learning.skill_performance_miner import SkillPerformanceMiner
185
+ spm = SkillPerformanceMiner(self._db.db_path)
186
+ results["skill_performance"] = spm.mine(profile_id)
187
+ except Exception as exc:
188
+ logger.debug("Skill performance mining (non-fatal): %s", exc)
189
+ results["skill_performance"] = {"error": str(exc)}
190
+
181
191
  results["success"] = True
182
192
  except Exception as exc:
183
193
  logger.warning(
@@ -139,6 +139,13 @@ class MemoryEngine:
139
139
  except Exception as exc:
140
140
  logger.debug("V3.4.7 schema migration: %s", exc)
141
141
 
142
+ # V3.4.10: Apply "Fortress" schema (backup_destinations, entity_blacklist)
143
+ try:
144
+ from superlocalmemory.storage.schema_v3410 import apply_v3410_schema
145
+ apply_v3410_schema(str(self._db.db_path))
146
+ except Exception as exc:
147
+ logger.debug("V3.4.10 schema migration: %s", exc)
148
+
142
149
  self._embedder = init_embedder(self._config)
143
150
 
144
151
  if self._caps.llm_fact_extraction:
@@ -75,7 +75,7 @@ class MaintenanceScheduler:
75
75
  self._timer.start()
76
76
 
77
77
  def _run(self) -> None:
78
- """Execute maintenance and schedule next run."""
78
+ """Execute maintenance + auto-backup check, then schedule next run."""
79
79
  if not self._running:
80
80
  return
81
81
  try:
@@ -84,8 +84,29 @@ class MaintenanceScheduler:
84
84
  logger.info("Scheduled maintenance complete: %s", counts)
85
85
  except Exception as exc:
86
86
  logger.warning("Scheduled maintenance failed: %s", exc)
87
- finally:
88
- self._schedule_next()
87
+
88
+ # V3.4.10: Check if auto-backup is due
89
+ try:
90
+ from superlocalmemory.infra.backup import BackupManager
91
+ manager = BackupManager(db_path=self._db.db_path)
92
+ filename = manager.check_and_backup()
93
+ if filename:
94
+ logger.info("Auto-backup created: %s", filename)
95
+ self._sync_cloud_destinations(manager)
96
+ except Exception as exc:
97
+ logger.debug("Auto-backup check skipped: %s", exc)
98
+
99
+ self._schedule_next()
100
+
101
+ def _sync_cloud_destinations(self, manager: object) -> None:
102
+ """Push latest backup to configured cloud destinations."""
103
+ try:
104
+ from superlocalmemory.infra.cloud_backup import sync_all_destinations
105
+ sync_all_destinations(self._db.db_path)
106
+ except ImportError:
107
+ pass # cloud_backup module not available yet
108
+ except Exception as exc:
109
+ logger.warning("Cloud sync failed (non-critical): %s", exc)
89
110
 
90
111
  def __del__(self) -> None:
91
112
  try: