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.
- package/README.md +23 -3
- package/docs/cloud-backup.md +174 -0
- package/docs/skill-evolution.md +256 -0
- package/ide/hooks/tool-event-hook.sh +101 -11
- package/package.json +1 -1
- package/pyproject.toml +3 -2
- package/src/superlocalmemory/cli/commands.py +359 -0
- package/src/superlocalmemory/cli/ingest_cmd.py +81 -29
- package/src/superlocalmemory/cli/main.py +32 -0
- package/src/superlocalmemory/cli/setup_wizard.py +54 -11
- package/src/superlocalmemory/core/config.py +35 -0
- package/src/superlocalmemory/core/consolidation_engine.py +138 -0
- package/src/superlocalmemory/core/embedding_worker.py +1 -1
- package/src/superlocalmemory/core/engine.py +19 -0
- package/src/superlocalmemory/core/fact_consolidator.py +425 -0
- package/src/superlocalmemory/core/graph_pruner.py +290 -0
- package/src/superlocalmemory/core/maintenance_scheduler.py +44 -3
- package/src/superlocalmemory/core/recall_pipeline.py +9 -0
- package/src/superlocalmemory/core/tier_manager.py +325 -0
- package/src/superlocalmemory/encoding/entity_resolver.py +96 -28
- package/src/superlocalmemory/evolution/__init__.py +29 -0
- package/src/superlocalmemory/evolution/blind_verifier.py +115 -0
- package/src/superlocalmemory/evolution/evolution_store.py +302 -0
- package/src/superlocalmemory/evolution/mutation_generator.py +181 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +555 -0
- package/src/superlocalmemory/evolution/triggers.py +367 -0
- package/src/superlocalmemory/evolution/types.py +92 -0
- package/src/superlocalmemory/hooks/hook_handlers.py +13 -0
- package/src/superlocalmemory/infra/backup.py +63 -20
- package/src/superlocalmemory/infra/cloud_backup.py +703 -0
- package/src/superlocalmemory/learning/skill_performance_miner.py +422 -0
- package/src/superlocalmemory/mcp/server.py +4 -0
- package/src/superlocalmemory/mcp/tools_evolution.py +338 -0
- package/src/superlocalmemory/retrieval/engine.py +64 -4
- package/src/superlocalmemory/retrieval/forgetting_filter.py +22 -7
- package/src/superlocalmemory/retrieval/strategy.py +2 -2
- package/src/superlocalmemory/server/routes/backup.py +512 -8
- package/src/superlocalmemory/server/routes/behavioral.py +39 -17
- package/src/superlocalmemory/server/routes/evolution.py +213 -0
- package/src/superlocalmemory/server/routes/tiers.py +195 -0
- package/src/superlocalmemory/server/unified_daemon.py +36 -5
- package/src/superlocalmemory/storage/schema_v3410.py +159 -0
- package/src/superlocalmemory/storage/schema_v3411.py +149 -0
- package/src/superlocalmemory/ui/index.html +59 -3
- package/src/superlocalmemory/ui/js/core.js +3 -0
- package/src/superlocalmemory/ui/js/lifecycle.js +83 -0
- package/src/superlocalmemory/ui/js/ng-entities.js +27 -3
- package/src/superlocalmemory/ui/js/ng-shell.js +33 -0
- package/src/superlocalmemory/ui/js/ng-skills.js +611 -0
- package/src/superlocalmemory/ui/js/settings.js +311 -1
- package/src/superlocalmemory.egg-info/PKG-INFO +16 -1
- 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
|
|
79
|
+
"""Ingest ECC observations into SLM tool_events.
|
|
80
80
|
|
|
81
|
-
Scans
|
|
82
|
-
|
|
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
|
-
|
|
91
|
+
files: list[Path] = []
|
|
92
|
+
|
|
87
93
|
if file_path:
|
|
88
94
|
files = [Path(file_path)]
|
|
89
95
|
else:
|
|
90
|
-
#
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
#
|
|
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
|
-
"
|
|
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
|
-
"
|
|
214
|
+
"project_path": "",
|
|
215
|
+
"created_at": record.get("timestamp", now_iso),
|
|
174
216
|
})
|
|
175
217
|
|
|
176
|
-
#
|
|
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
|
-
"
|
|
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',
|
|
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:
|