superlocalmemory 3.4.9 → 3.4.11

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.
Files changed (52) hide show
  1. package/README.md +23 -3
  2. package/docs/cloud-backup.md +174 -0
  3. package/docs/skill-evolution.md +256 -0
  4. package/ide/hooks/tool-event-hook.sh +101 -11
  5. package/package.json +1 -1
  6. package/pyproject.toml +3 -2
  7. package/src/superlocalmemory/cli/commands.py +359 -0
  8. package/src/superlocalmemory/cli/ingest_cmd.py +81 -29
  9. package/src/superlocalmemory/cli/main.py +32 -0
  10. package/src/superlocalmemory/cli/setup_wizard.py +54 -11
  11. package/src/superlocalmemory/core/config.py +35 -0
  12. package/src/superlocalmemory/core/consolidation_engine.py +138 -0
  13. package/src/superlocalmemory/core/embedding_worker.py +1 -1
  14. package/src/superlocalmemory/core/engine.py +19 -0
  15. package/src/superlocalmemory/core/fact_consolidator.py +425 -0
  16. package/src/superlocalmemory/core/graph_pruner.py +290 -0
  17. package/src/superlocalmemory/core/maintenance_scheduler.py +44 -3
  18. package/src/superlocalmemory/core/recall_pipeline.py +9 -0
  19. package/src/superlocalmemory/core/tier_manager.py +325 -0
  20. package/src/superlocalmemory/encoding/entity_resolver.py +96 -28
  21. package/src/superlocalmemory/evolution/__init__.py +29 -0
  22. package/src/superlocalmemory/evolution/blind_verifier.py +115 -0
  23. package/src/superlocalmemory/evolution/evolution_store.py +302 -0
  24. package/src/superlocalmemory/evolution/mutation_generator.py +181 -0
  25. package/src/superlocalmemory/evolution/skill_evolver.py +555 -0
  26. package/src/superlocalmemory/evolution/triggers.py +367 -0
  27. package/src/superlocalmemory/evolution/types.py +92 -0
  28. package/src/superlocalmemory/hooks/hook_handlers.py +13 -0
  29. package/src/superlocalmemory/infra/backup.py +63 -20
  30. package/src/superlocalmemory/infra/cloud_backup.py +703 -0
  31. package/src/superlocalmemory/learning/skill_performance_miner.py +422 -0
  32. package/src/superlocalmemory/mcp/server.py +4 -0
  33. package/src/superlocalmemory/mcp/tools_evolution.py +338 -0
  34. package/src/superlocalmemory/retrieval/engine.py +64 -4
  35. package/src/superlocalmemory/retrieval/forgetting_filter.py +22 -7
  36. package/src/superlocalmemory/retrieval/strategy.py +2 -2
  37. package/src/superlocalmemory/server/routes/backup.py +512 -8
  38. package/src/superlocalmemory/server/routes/behavioral.py +39 -17
  39. package/src/superlocalmemory/server/routes/evolution.py +213 -0
  40. package/src/superlocalmemory/server/routes/tiers.py +195 -0
  41. package/src/superlocalmemory/server/unified_daemon.py +36 -5
  42. package/src/superlocalmemory/storage/schema_v3410.py +159 -0
  43. package/src/superlocalmemory/storage/schema_v3411.py +149 -0
  44. package/src/superlocalmemory/ui/index.html +59 -3
  45. package/src/superlocalmemory/ui/js/core.js +3 -0
  46. package/src/superlocalmemory/ui/js/lifecycle.js +83 -0
  47. package/src/superlocalmemory/ui/js/ng-entities.js +27 -3
  48. package/src/superlocalmemory/ui/js/ng-shell.js +33 -0
  49. package/src/superlocalmemory/ui/js/ng-skills.js +611 -0
  50. package/src/superlocalmemory/ui/js/settings.js +311 -1
  51. package/src/superlocalmemory.egg-info/PKG-INFO +16 -1
  52. package/src/superlocalmemory.egg-info/SOURCES.txt +18 -0
@@ -58,10 +58,15 @@ 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
64
66
  "ingest": cmd_ingest,
67
+ # V3.4.11 skill evolution
68
+ "config": cmd_config,
69
+ "evolve": cmd_evolve,
65
70
  }
66
71
  handler = handlers.get(args.command)
67
72
  if handler:
@@ -146,12 +151,366 @@ def cmd_serve(args: Namespace) -> None:
146
151
  # -- Ingestion Adapters (V3.4.3) ------------------------------------------
147
152
 
148
153
 
154
+ def cmd_restart(args: Namespace) -> None:
155
+ """Nuclear restart: kill ALL orphans, clean state, start fresh, verify health.
156
+
157
+ 5-step pipeline:
158
+ 1. Kill ALL SLM processes (daemon + workers + orphans)
159
+ 2. Clean stale PID/port/lock files
160
+ 3. Start fresh daemon
161
+ 4. Wait for engine warmup + verify health
162
+ 5. Optionally open dashboard
163
+ """
164
+ import os
165
+ import time
166
+ from pathlib import Path
167
+
168
+ use_json = getattr(args, "json", False)
169
+ open_dashboard = getattr(args, "dashboard", False)
170
+ slm_dir = Path.home() / ".superlocalmemory"
171
+ steps: list[dict] = []
172
+
173
+ def _log(step: int, name: str, status: str, detail: str = ""):
174
+ entry = {"step": step, "name": name, "status": status, "detail": detail}
175
+ steps.append(entry)
176
+ if not use_json:
177
+ icon = {"ok": "+", "warn": "!", "fail": "x"}.get(status, " ")
178
+ print(f" [{icon}] Step {step}: {name}" + (f" — {detail}" if detail else ""))
179
+
180
+ if not use_json:
181
+ print()
182
+ print(" SLM Full System Restart")
183
+ print(" " + "=" * 40)
184
+ print()
185
+
186
+ # Step 1: Kill ALL SLM processes
187
+ killed = 0
188
+ try:
189
+ import psutil
190
+ my_pid = os.getpid()
191
+ targets = [
192
+ "superlocalmemory.server.unified_daemon",
193
+ "superlocalmemory.core.embedding_worker",
194
+ "superlocalmemory.core.recall_worker",
195
+ "superlocalmemory.core.reranker_worker",
196
+ "superlocalmemory.cli.daemon",
197
+ ]
198
+ for proc in psutil.process_iter(["pid", "cmdline"]):
199
+ try:
200
+ if proc.pid == my_pid:
201
+ continue
202
+ cmdline = " ".join(proc.info.get("cmdline") or [])
203
+ if any(t in cmdline for t in targets):
204
+ for child in proc.children(recursive=True):
205
+ try:
206
+ child.kill()
207
+ killed += 1
208
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
209
+ pass
210
+ proc.kill()
211
+ killed += 1
212
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
213
+ pass
214
+ except ImportError:
215
+ # Fallback: pkill
216
+ import subprocess as _sp
217
+ for pattern in [
218
+ "superlocalmemory.server.unified_daemon",
219
+ "superlocalmemory.core.embedding_worker",
220
+ "superlocalmemory.core.recall_worker",
221
+ "superlocalmemory.core.reranker_worker",
222
+ ]:
223
+ try:
224
+ r = _sp.run(["pkill", "-9", "-f", pattern], capture_output=True, timeout=5)
225
+ if r.returncode == 0:
226
+ killed += 1
227
+ except Exception:
228
+ pass
229
+
230
+ _log(1, "Kill all SLM processes", "ok", f"{killed} processes killed")
231
+ time.sleep(3)
232
+
233
+ # Step 2: Clean stale files
234
+ cleaned = []
235
+ for fname in ("daemon.pid", "daemon.port", "daemon.lock"):
236
+ fpath = slm_dir / fname
237
+ if fpath.exists():
238
+ fpath.unlink(missing_ok=True)
239
+ cleaned.append(fname)
240
+ _log(2, "Clean stale state files", "ok",
241
+ f"removed: {', '.join(cleaned)}" if cleaned else "already clean")
242
+
243
+ # Step 3: Start fresh daemon
244
+ time.sleep(1)
245
+ from superlocalmemory.cli.daemon import ensure_daemon
246
+ started = ensure_daemon()
247
+ _log(3, "Start fresh daemon", "ok" if started else "fail",
248
+ "daemon started" if started else "failed to start — check slm doctor")
249
+
250
+ if not started:
251
+ if use_json:
252
+ from superlocalmemory.cli.json_output import json_print
253
+ json_print("restart", data={"steps": steps, "success": False},
254
+ next_actions=[{"command": "slm doctor", "description": "Diagnose issues"}])
255
+ else:
256
+ print("\n Restart FAILED at step 3. Run: slm doctor")
257
+ return
258
+
259
+ # Step 4: Wait for warmup + verify health
260
+ if not use_json:
261
+ print(" [ ] Step 4: Waiting for engine warmup (up to 30s)...", end="", flush=True)
262
+
263
+ health = None
264
+ engine_ok = False
265
+ for attempt in range(15):
266
+ time.sleep(2)
267
+ try:
268
+ from superlocalmemory.cli.daemon import daemon_request
269
+ health = daemon_request("GET", "/health")
270
+ if health and health.get("engine") == "initialized":
271
+ engine_ok = True
272
+ break
273
+ except Exception:
274
+ pass
275
+
276
+ if not use_json:
277
+ print("\r", end="") # Clear the waiting line
278
+
279
+ if engine_ok:
280
+ version = health.get("version", "?")
281
+ pid = health.get("pid", "?")
282
+ _log(4, "Engine health verified", "ok", f"v{version}, PID {pid}, engine=initialized")
283
+ else:
284
+ engine_state = health.get("engine", "unknown") if health else "unreachable"
285
+ _log(4, "Engine health check", "warn",
286
+ f"engine={engine_state} — may still be warming up. Try again in 30s.")
287
+
288
+ # Step 5: Database integrity check
289
+ try:
290
+ import sqlite3
291
+ db_path = slm_dir / "memory.db"
292
+ if db_path.exists():
293
+ conn = sqlite3.connect(str(db_path))
294
+ integrity = conn.execute("PRAGMA integrity_check").fetchone()[0]
295
+ fact_count = conn.execute("SELECT COUNT(*) FROM atomic_facts").fetchone()[0]
296
+ entity_count = conn.execute("SELECT COUNT(*) FROM canonical_entities").fetchone()[0]
297
+ conn.close()
298
+ _log(5, "Database integrity", "ok" if integrity == "ok" else "fail",
299
+ f"integrity={integrity}, {fact_count} facts, {entity_count} entities")
300
+ else:
301
+ _log(5, "Database check", "warn", "no database yet — will create on first use")
302
+ except Exception as exc:
303
+ _log(5, "Database check", "warn", str(exc))
304
+
305
+ # Step 6 (optional): Open dashboard
306
+ if open_dashboard:
307
+ try:
308
+ import webbrowser
309
+ from superlocalmemory.cli.daemon import _get_port
310
+ port = _get_port()
311
+ url = f"http://localhost:{port}"
312
+ webbrowser.open(url)
313
+ _log(6, "Dashboard opened", "ok", url)
314
+ except Exception as exc:
315
+ _log(6, "Dashboard open", "fail", str(exc))
316
+
317
+ # Summary
318
+ all_ok = all(s["status"] == "ok" for s in steps)
319
+
320
+ if use_json:
321
+ from superlocalmemory.cli.json_output import json_print
322
+ json_print("restart", data={
323
+ "steps": steps, "success": all_ok,
324
+ "processes_killed": killed,
325
+ "version": health.get("version") if health else None,
326
+ }, next_actions=[
327
+ {"command": "slm serve status", "description": "Check daemon status"},
328
+ {"command": "slm dashboard", "description": "Open dashboard"},
329
+ ])
330
+ return
331
+
332
+ print()
333
+ if all_ok:
334
+ print(" All systems operational.")
335
+ else:
336
+ warnings = [s for s in steps if s["status"] != "ok"]
337
+ print(f" {len(warnings)} issue(s) — check details above.")
338
+ print()
339
+
340
+
149
341
  def cmd_ingest(args: Namespace) -> None:
150
342
  """Import external observations into SLM learning pipeline."""
151
343
  from superlocalmemory.cli.ingest_cmd import cmd_ingest as _ingest
152
344
  _ingest(args)
153
345
 
154
346
 
347
+ # -- Config & Evolution (V3.4.11) -----------------------------------------------
348
+
349
+
350
+ def cmd_config(args: Namespace) -> None:
351
+ """Get or set config values (dot-notation).
352
+
353
+ Usage:
354
+ slm config set evolution.enabled true
355
+ slm config set evolution.backend auto
356
+ slm config get evolution.enabled
357
+ slm config get evolution.backend
358
+ """
359
+ import json
360
+ from pathlib import Path
361
+
362
+ use_json = getattr(args, "json", False)
363
+ action = getattr(args, "action", "get")
364
+ key = getattr(args, "key", "")
365
+ value = getattr(args, "value", None)
366
+
367
+ config_path = Path.home() / ".superlocalmemory" / "config.json"
368
+
369
+ # Read existing config
370
+ cfg: dict = {}
371
+ if config_path.exists():
372
+ try:
373
+ cfg = json.loads(config_path.read_text())
374
+ except (json.JSONDecodeError, OSError):
375
+ pass
376
+
377
+ if action == "get":
378
+ # Parse dot-notation key (e.g. "evolution.enabled")
379
+ parts = key.split(".") if key else []
380
+ node = cfg
381
+ for part in parts:
382
+ if isinstance(node, dict) and part in node:
383
+ node = node[part]
384
+ else:
385
+ node = None
386
+ break
387
+
388
+ if use_json:
389
+ from superlocalmemory.cli.json_output import json_print
390
+ json_print("config", data={"key": key, "value": node})
391
+ else:
392
+ if node is None:
393
+ print(f"{key}: (not set)")
394
+ else:
395
+ print(f"{key}: {node}")
396
+
397
+ elif action == "set":
398
+ _ALLOWED_CONFIG_KEYS = {
399
+ "evolution.enabled", "evolution.backend", "evolution.max_evolutions_per_cycle",
400
+ "mesh_enabled", "daemon_idle_timeout", "entity_compilation_enabled",
401
+ }
402
+ if key not in _ALLOWED_CONFIG_KEYS:
403
+ if use_json:
404
+ from superlocalmemory.cli.json_output import json_print
405
+ json_print("config", error={
406
+ "code": "DISALLOWED_KEY",
407
+ "message": f"'{key}' is not a configurable key. Allowed: {', '.join(sorted(_ALLOWED_CONFIG_KEYS))}",
408
+ })
409
+ else:
410
+ print(f"Error: '{key}' is not a configurable key. Allowed: {', '.join(sorted(_ALLOWED_CONFIG_KEYS))}")
411
+ sys.exit(1)
412
+
413
+ if not key or value is None:
414
+ if use_json:
415
+ from superlocalmemory.cli.json_output import json_print
416
+ json_print("config", error={
417
+ "code": "INVALID_INPUT",
418
+ "message": "Usage: slm config set <key> <value>",
419
+ })
420
+ else:
421
+ print("Usage: slm config set <key> <value>")
422
+ sys.exit(1)
423
+
424
+ # Parse value: booleans, numbers, strings
425
+ parsed_value: object
426
+ if value.lower() in ("true", "yes", "on"):
427
+ parsed_value = True
428
+ elif value.lower() in ("false", "no", "off"):
429
+ parsed_value = False
430
+ else:
431
+ try:
432
+ parsed_value = int(value)
433
+ except ValueError:
434
+ try:
435
+ parsed_value = float(value)
436
+ except ValueError:
437
+ parsed_value = value
438
+
439
+ # Set via dot-notation (e.g. "evolution.enabled" -> cfg["evolution"]["enabled"])
440
+ parts = key.split(".")
441
+ node = cfg
442
+ for part in parts[:-1]:
443
+ if part not in node or not isinstance(node.get(part), dict):
444
+ node[part] = {}
445
+ node = node[part]
446
+ old_value = node.get(parts[-1])
447
+ node[parts[-1]] = parsed_value
448
+
449
+ # Write back
450
+ config_path.parent.mkdir(parents=True, exist_ok=True)
451
+ config_path.write_text(json.dumps(cfg, indent=2) + "\n")
452
+
453
+ if use_json:
454
+ from superlocalmemory.cli.json_output import json_print
455
+ json_print("config", data={
456
+ "key": key, "old_value": old_value, "new_value": parsed_value,
457
+ })
458
+ else:
459
+ print(f"{key}: {old_value} -> {parsed_value}")
460
+
461
+ else:
462
+ if use_json:
463
+ from superlocalmemory.cli.json_output import json_print
464
+ json_print("config", error={
465
+ "code": "UNKNOWN_ACTION",
466
+ "message": f"Unknown action: {action}. Use 'get' or 'set'.",
467
+ })
468
+ else:
469
+ print(f"Unknown config action: {action}. Use 'get' or 'set'.")
470
+ sys.exit(1)
471
+
472
+
473
+ def cmd_evolve(args: Namespace) -> None:
474
+ """Run skill evolution for a session (called from Stop hook).
475
+
476
+ Reads config.json to check if evolution is enabled.
477
+ If enabled, imports SkillEvolver and runs run_post_session().
478
+ If disabled, exits silently (zero output for fire-and-forget).
479
+ """
480
+ import json
481
+ from pathlib import Path
482
+
483
+ session_id = getattr(args, "session", "") or ""
484
+ profile = getattr(args, "profile", "default") or "default"
485
+
486
+ if not session_id:
487
+ return # Silent exit — nothing to do without a session
488
+
489
+ # Check if evolution is enabled via config.json
490
+ config_path = Path.home() / ".superlocalmemory" / "config.json"
491
+ try:
492
+ cfg = json.loads(config_path.read_text()) if config_path.exists() else {}
493
+ except (json.JSONDecodeError, OSError):
494
+ return # Config unreadable — silent exit
495
+
496
+ evolution_cfg = cfg.get("evolution", {})
497
+ if not evolution_cfg.get("enabled", False):
498
+ return # Disabled — silent exit
499
+
500
+ # Heavy imports only if enabled (this runs as a Popen child)
501
+ try:
502
+ from superlocalmemory.evolution.skill_evolver import SkillEvolver
503
+
504
+ db_path = Path.home() / ".superlocalmemory" / "memory.db"
505
+ if not db_path.exists():
506
+ return
507
+
508
+ evolver = SkillEvolver(db_path)
509
+ evolver.run_post_session(session_id, profile)
510
+ except Exception:
511
+ pass # Best-effort — don't crash the Stop hook
512
+
513
+
155
514
  def cmd_adapters(args: Namespace) -> None:
156
515
  """Manage ingestion adapters (Gmail, Calendar, Transcript).
157
516
 
@@ -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(
@@ -302,6 +313,26 @@ def main() -> None:
302
313
  )
303
314
  ingest_p.add_argument("--json", action="store_true", help="Output structured JSON")
304
315
 
316
+ # V3.4.11: Config get/set (dot-notation)
317
+ config_p = sub.add_parser(
318
+ "config",
319
+ help="Get or set config values (e.g. slm config set evolution.enabled true)",
320
+ )
321
+ config_p.add_argument(
322
+ "action", choices=["get", "set"], help="Action: get or set",
323
+ )
324
+ config_p.add_argument("key", help="Config key in dot notation (e.g. evolution.enabled)")
325
+ config_p.add_argument("value", nargs="?", default=None, help="Value to set (for 'set' action)")
326
+ config_p.add_argument("--json", action="store_true", help="Output structured JSON")
327
+
328
+ # V3.4.11: Skill evolution (called from Stop hook, fire-and-forget)
329
+ evolve_p = sub.add_parser(
330
+ "evolve",
331
+ help="Run post-session skill evolution (internal, called by Stop hook)",
332
+ )
333
+ evolve_p.add_argument("--session", default="", help="Session ID to process")
334
+ evolve_p.add_argument("--profile", default="default", help="Profile ID")
335
+
305
336
  args = parser.parse_args()
306
337
 
307
338
  if not args.command:
@@ -317,6 +348,7 @@ def main() -> None:
317
348
  # Cross-platform: macOS + Windows + Linux.
318
349
  _NO_DAEMON_COMMANDS = {
319
350
  "setup", "mode", "provider", "connect", "migrate", "mcp", "warmup",
351
+ "config", "evolve",
320
352
  }
321
353
  if args.command not in _NO_DAEMON_COMMANDS:
322
354
  try: