slowave 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. slowave/__init__.py +10 -0
  2. slowave/__main__.py +13 -0
  3. slowave/cli/__init__.py +0 -0
  4. slowave/cli/main.py +607 -0
  5. slowave/core/__init__.py +0 -0
  6. slowave/core/config.py +56 -0
  7. slowave/core/consolidation.py +533 -0
  8. slowave/core/context.py +450 -0
  9. slowave/core/engine.py +751 -0
  10. slowave/core/paths.py +13 -0
  11. slowave/dashboard/__init__.py +0 -0
  12. slowave/dashboard/app.py +687 -0
  13. slowave/latent/__init__.py +0 -0
  14. slowave/latent/episodic_store.py +196 -0
  15. slowave/latent/graph_manager.py +160 -0
  16. slowave/latent/metrics.py +53 -0
  17. slowave/latent/replay_engine.py +497 -0
  18. slowave/latent/retrieval.py +514 -0
  19. slowave/latent/salience.py +61 -0
  20. slowave/latent/schema.py +298 -0
  21. slowave/latent/semantic_store.py +304 -0
  22. slowave/latent/synthetic.py +56 -0
  23. slowave/latent/temporal.py +130 -0
  24. slowave/latent/transition_model.py +57 -0
  25. slowave/latent/types.py +51 -0
  26. slowave/llm/__init__.py +37 -0
  27. slowave/llm/base.py +52 -0
  28. slowave/llm/ollama_backend.py +78 -0
  29. slowave/llm/openrouter_backend.py +204 -0
  30. slowave/llm/prompts/extract_schema.txt +103 -0
  31. slowave/llm/prompts/judge_contradiction.txt +23 -0
  32. slowave/mcp/__init__.py +0 -0
  33. slowave/mcp/server.py +337 -0
  34. slowave/storage/__init__.py +0 -0
  35. slowave/storage/schema.sql +205 -0
  36. slowave/storage/sqlite_db.py +161 -0
  37. slowave/symbolic/__init__.py +0 -0
  38. slowave/symbolic/contradiction.py +62 -0
  39. slowave/symbolic/encoder.py +77 -0
  40. slowave/symbolic/episode_text.py +91 -0
  41. slowave/symbolic/raw_log.py +152 -0
  42. slowave/symbolic/schema_extractor.py +122 -0
  43. slowave/symbolic/schema_store.py +732 -0
  44. slowave/utils/__init__.py +0 -0
  45. slowave/utils/logging.py +0 -0
  46. slowave/utils/vec.py +40 -0
  47. slowave-0.1.3.dist-info/METADATA +168 -0
  48. slowave-0.1.3.dist-info/RECORD +52 -0
  49. slowave-0.1.3.dist-info/WHEEL +5 -0
  50. slowave-0.1.3.dist-info/entry_points.txt +3 -0
  51. slowave-0.1.3.dist-info/licenses/LICENSE +21 -0
  52. slowave-0.1.3.dist-info/top_level.txt +1 -0
slowave/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Slowave: brain-inspired memory for AI agents.
2
+
3
+ Public API entry points are in `slowave.core.engine.SlowaveEngine` and
4
+ `slowave.core.config.SlowaveConfig`.
5
+ """
6
+ from slowave.core.config import SlowaveConfig
7
+ from slowave.core.engine import SlowaveEngine
8
+
9
+ __all__ = ["SlowaveEngine", "SlowaveConfig"]
10
+ __version__ = "0.1.3"
slowave/__main__.py ADDED
@@ -0,0 +1,13 @@
1
+ """`python -m slowave` dispatches to the Click CLI."""
2
+ import os
3
+
4
+ # macOS note: FAISS + PyTorch can sometimes load multiple OpenMP runtimes.
5
+ # Pragmatic workaround to avoid a hard crash.
6
+ os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE")
7
+ os.environ.setdefault("OMP_NUM_THREADS", "1")
8
+ os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
9
+
10
+ from slowave.cli.main import main
11
+
12
+ if __name__ == "__main__":
13
+ main()
File without changes
slowave/cli/main.py ADDED
@@ -0,0 +1,607 @@
1
+ """Slowave CLI entry point.
2
+
3
+ Provides the agent-facing surface: session/event/remember/recall/context/show.
4
+
5
+ Design goals:
6
+ - Every command prints either JSON or a compact human-readable form.
7
+ - JSON mode is selected with --json (recommended for agent integrations).
8
+ - The CLI is fast on the hot paths (event_append, recall): no LLM call here.
9
+ - LLM is only invoked on `session end` (consolidation) or `consolidate`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import subprocess
17
+ import sys
18
+ from dataclasses import asdict
19
+ from typing import Any
20
+
21
+ # macOS: avoid OpenMP runtime crashes when FAISS, torch, and tokenizers coexist.
22
+ # `python -m slowave` sets these in __main__, but the installed console script
23
+ # enters here directly.
24
+ os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE")
25
+ os.environ.setdefault("OMP_NUM_THREADS", "1")
26
+ os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
27
+
28
+ import click
29
+
30
+ from slowave.core.config import SlowaveConfig
31
+ from slowave.core.paths import default_db_path
32
+ from slowave.core.engine import SlowaveEngine
33
+ from slowave.llm.base import LLMBackendConfig
34
+ from slowave.symbolic.encoder import EncoderConfig
35
+
36
+ DEFAULT_DB = "__DEFAULT_DB__"
37
+ DEFAULT_MODEL = os.environ.get("SLOWAVE_MODEL", "qwen2.5:7b-instruct")
38
+ DEFAULT_OLLAMA_URL = os.environ.get("SLOWAVE_OLLAMA_URL", "http://localhost:11434")
39
+
40
+
41
+ def _ensure_db_dir(path: str) -> None:
42
+ d = os.path.dirname(os.path.abspath(path))
43
+ if d and not os.path.exists(d):
44
+ os.makedirs(d, exist_ok=True)
45
+
46
+
47
+ def _resolve_db_path(db: str) -> str:
48
+ if db == DEFAULT_DB:
49
+ return default_db_path()
50
+ return os.path.expanduser(db)
51
+
52
+
53
+ def _build_engine(
54
+ db: str, *, disable_llm: bool = True, schema_mode: str = "latent"
55
+ ) -> SlowaveEngine:
56
+ db = _resolve_db_path(db)
57
+ _ensure_db_dir(db)
58
+ cfg = SlowaveConfig(
59
+ db_path=db,
60
+ dim=384,
61
+ encoder=EncoderConfig(),
62
+ llm=LLMBackendConfig(model=DEFAULT_MODEL, base_url=DEFAULT_OLLAMA_URL),
63
+ disable_llm=disable_llm,
64
+ schema_mode=schema_mode,
65
+ )
66
+ return SlowaveEngine(cfg)
67
+
68
+
69
+ def _print(obj: Any, as_json: bool) -> None:
70
+ if as_json:
71
+ click.echo(json.dumps(obj, ensure_ascii=False, indent=2, default=str))
72
+ else:
73
+ click.echo(
74
+ obj
75
+ if isinstance(obj, str)
76
+ else json.dumps(obj, ensure_ascii=False, indent=2, default=str)
77
+ )
78
+
79
+
80
+ @click.group()
81
+ @click.option(
82
+ "--db",
83
+ default=DEFAULT_DB,
84
+ show_default="SLOWAVE_DB or ~/.slowave/slowave.db",
85
+ help="SQLite db path override.",
86
+ )
87
+ @click.option("--no-llm", is_flag=True, help="Disable LLM (no schema extraction).")
88
+ @click.option("--json", "as_json", is_flag=True, help="JSON output.")
89
+ @click.pass_context
90
+ def cli(ctx: click.Context, db: str, no_llm: bool, as_json: bool) -> None:
91
+ """Slowave: brain-inspired memory for AI agents."""
92
+ ctx.ensure_object(dict)
93
+ ctx.obj["db"] = _resolve_db_path(db)
94
+ ctx.obj["no_llm"] = no_llm
95
+ ctx.obj["json"] = as_json
96
+
97
+
98
+ @cli.group()
99
+ def session() -> None:
100
+ """Session lifecycle."""
101
+
102
+
103
+ @session.command("start")
104
+ @click.option("--agent", default="cline-tui")
105
+ @click.option("--project", default=None)
106
+ @click.pass_context
107
+ def session_start(ctx: click.Context, agent: str, project: str | None) -> None:
108
+ eng = _build_engine(ctx.obj["db"], disable_llm=True) # no LLM needed at start
109
+ sid = eng.session_start(agent=agent, project=project)
110
+ _print({"session_id": sid}, ctx.obj["json"])
111
+ eng.close()
112
+
113
+
114
+ @session.command("end")
115
+ @click.argument("session_id")
116
+ @click.option(
117
+ "--consolidate",
118
+ is_flag=True,
119
+ help="Also run replay+LLM consolidation synchronously (slow). "
120
+ "Default: encode only; run 'slowave consolidate' separately.",
121
+ )
122
+ @click.pass_context
123
+ def session_end(ctx: click.Context, session_id: str, consolidate: bool) -> None:
124
+ """End a session and encode events into episodic memories.
125
+
126
+ Fast by default: no LLM, no blocking. Use --consolidate only in scripts
127
+ or tests. In production let the background worker handle consolidation.
128
+ """
129
+ eng = _build_engine(ctx.obj["db"], disable_llm=not consolidate or ctx.obj["no_llm"])
130
+ stats = eng.session_end(session_id, consolidate=consolidate and not ctx.obj["no_llm"])
131
+ _print(stats, ctx.obj["json"])
132
+ eng.close()
133
+
134
+
135
+ @cli.command("event")
136
+ @click.option("--session", "session_id", required=True)
137
+ @click.option("--type", "type_", required=True)
138
+ @click.option("--content", required=True)
139
+ @click.pass_context
140
+ def event_append(ctx: click.Context, session_id: str, type_: str, content: str) -> None:
141
+ """Append an event to a session."""
142
+ eng = _build_engine(ctx.obj["db"], disable_llm=True)
143
+ rid = eng.event_append(session_id=session_id, type=type_, content=content)
144
+ _print({"event_id": rid}, ctx.obj["json"])
145
+ eng.close()
146
+
147
+
148
+ @cli.command("remember")
149
+ @click.argument("content")
150
+ @click.option("--type", "type_", default="decision")
151
+ @click.option("--project", default=None)
152
+ @click.pass_context
153
+ def remember(ctx: click.Context, content: str, type_: str, project: str | None) -> None:
154
+ """Explicitly remember a typed claim."""
155
+ eng = _build_engine(ctx.obj["db"], disable_llm=True)
156
+ rid = eng.remember(content=content, type=type_, project=project)
157
+ _print({"event_id": rid, "type": type_}, ctx.obj["json"])
158
+ eng.close()
159
+
160
+
161
+ @cli.command("recall")
162
+ @click.argument("query")
163
+ @click.option("--top-k", default=5, show_default=True)
164
+ @click.option("--evidence", is_flag=True, help="Include raw event citations.")
165
+ @click.pass_context
166
+ def recall(ctx: click.Context, query: str, top_k: int, evidence: bool) -> None:
167
+ """Recall memories relevant to a query."""
168
+ eng = _build_engine(ctx.obj["db"], disable_llm=True)
169
+ result = eng.recall(query, top_k=top_k, evidence=evidence)
170
+ payload = {
171
+ "schemas": [asdict(s) for s in result.schemas],
172
+ "episodes": result.episode_texts,
173
+ "raw_events": result.raw_events,
174
+ "expanded_neighbors": {str(k): v for k, v in result.expanded_neighbors.items()},
175
+ }
176
+ if ctx.obj["json"]:
177
+ _print(payload, True)
178
+ else:
179
+ _format_recall_human(payload)
180
+ eng.close()
181
+
182
+
183
+ def _format_recall_human(payload: dict[str, Any]) -> None:
184
+ schemas = payload.get("schemas", [])
185
+ episodes = payload.get("episodes", [])
186
+ raw_events = payload.get("raw_events", [])
187
+ click.echo("=== Schemas ===")
188
+ if not schemas:
189
+ click.echo(" (none yet)")
190
+ for s in schemas:
191
+ click.echo(
192
+ f" [sch_{s['id']}] {s['content_text']}"
193
+ f" status={s.get('status', 'active')} sal={float(s.get('salience', 0.0)):.3f}"
194
+ f" tags={','.join(s.get('tags', []))}"
195
+ f" supports={len(s.get('supporting_episode_ids', []))}"
196
+ + (" needs_review" if s.get("needs_review") else "")
197
+ )
198
+ click.echo("\n=== Episodes ===")
199
+ for ep in episodes:
200
+ text = (ep.get("content_text") or "").replace("\n", " ")
201
+ if len(text) > 160:
202
+ text = text[:160] + "..."
203
+ click.echo(f" [epi_{ep['id']}] (sal={ep['salience']:.3f}) {text}")
204
+ if raw_events:
205
+ click.echo("\n=== Raw events (evidence) ===")
206
+ for r in raw_events:
207
+ text = (r.get("content") or "").replace("\n", " ")
208
+ if len(text) > 160:
209
+ text = text[:160] + "..."
210
+ click.echo(f" [evt_{r['id']}] {r['type']}: {text}")
211
+
212
+
213
+ @cli.command("context")
214
+ @click.option("--project", default=None)
215
+ @click.option("--query", default=None, help="Current task/chat cue for relevance gating.")
216
+ @click.option(
217
+ "--application",
218
+ default=None,
219
+ help="Calling app/channel cue, e.g. chatbot or cline-tui.",
220
+ )
221
+ @click.option("--topic", "topics", multiple=True, help="High-level topic cue; can be repeated.")
222
+ @click.option("--entity", "entities", multiple=True, help="Salient entity cue; can be repeated.")
223
+ @click.option(
224
+ "--mode",
225
+ default="default",
226
+ show_default=True,
227
+ type=click.Choice(["default", "broad", "debug"]),
228
+ )
229
+ @click.option("--limit", default=10, show_default=True)
230
+ @click.pass_context
231
+ def context_cmd(
232
+ ctx: click.Context,
233
+ project: str | None,
234
+ query: str | None,
235
+ application: str | None,
236
+ topics: tuple[str, ...],
237
+ entities: tuple[str, ...],
238
+ mode: str,
239
+ limit: int,
240
+ ) -> None:
241
+ """Return a gated working-memory brief for an agent/chatbot prompt."""
242
+ eng = _build_engine(ctx.obj["db"], disable_llm=True)
243
+ brief = eng.context_brief(
244
+ query=query,
245
+ project=project,
246
+ application=application,
247
+ topics=list(topics),
248
+ entities=list(entities),
249
+ mode=mode,
250
+ limit=limit,
251
+ )
252
+ if ctx.obj["json"]:
253
+ _print(
254
+ {
255
+ "project": project,
256
+ "query": query,
257
+ "application": application,
258
+ "topics": list(topics),
259
+ "entities": list(entities),
260
+ "mode": mode,
261
+ "rendered": brief.rendered,
262
+ "cue_terms": brief.cue_terms,
263
+ "suppressed": brief.suppressed,
264
+ "schemas": [
265
+ {
266
+ "id": item.schema.id,
267
+ "content_text": item.text,
268
+ "activation": item.activation,
269
+ "reason": item.reason,
270
+ "schema": asdict(item.schema),
271
+ }
272
+ for item in brief.items
273
+ ],
274
+ "activation_trace": (
275
+ [asdict(t) for t in brief.activation_trace] if mode == "debug" else []
276
+ ),
277
+ },
278
+ True,
279
+ )
280
+ else:
281
+ click.echo("=== Working Memory Context ===")
282
+ if not brief.items:
283
+ click.echo(" (no memories yet)")
284
+ for item in brief.items:
285
+ s = item.schema
286
+ click.echo(
287
+ f" [sch_{s.id}] {item.text}"
288
+ f" act={item.activation:.3f} status={s.status} sal={s.salience:.3f}"
289
+ f" supports={len(s.supporting_episode_ids)}"
290
+ f" tags={','.join(s.tags)}"
291
+ f" reason={item.reason}" + (" needs_review" if s.needs_review else "")
292
+ )
293
+ if mode == "debug":
294
+ click.echo(f"\nSuppressed: {brief.suppressed}")
295
+ click.echo("\nCite memories as [sch_xxx] or [epi_xxx] when you use them.")
296
+ eng.close()
297
+
298
+
299
+ @cli.command("show")
300
+ @click.argument("ref")
301
+ @click.pass_context
302
+ def show(ctx: click.Context, ref: str) -> None:
303
+ """Show a schema/episode/event by ref (sch_NN, epi_NN, evt_NN)."""
304
+ eng = _build_engine(ctx.obj["db"], disable_llm=True)
305
+ if ref.startswith("sch_"):
306
+ sid = int(ref[4:])
307
+ try:
308
+ s = eng.get_schema(sid)
309
+ _print(asdict(s), ctx.obj["json"])
310
+ except KeyError:
311
+ _print({"error": "not found"}, ctx.obj["json"])
312
+ elif ref.startswith("epi_"):
313
+ eid = int(ref[4:])
314
+ et = eng.episode_text.get(eid)
315
+ _print(asdict(et) if et else {"error": "not found"}, ctx.obj["json"])
316
+ elif ref.startswith("evt_"):
317
+ eid = int(ref[4:])
318
+ try:
319
+ e = eng.raw_log.get(eid)
320
+ _print(
321
+ {
322
+ "id": e.id,
323
+ "session_id": e.session_id,
324
+ "ts": e.ts,
325
+ "type": e.type,
326
+ "content": e.content,
327
+ "metadata": e.metadata,
328
+ },
329
+ ctx.obj["json"],
330
+ )
331
+ except KeyError:
332
+ _print({"error": "not found"}, ctx.obj["json"])
333
+ else:
334
+ _print({"error": f"unknown ref prefix: {ref}"}, ctx.obj["json"])
335
+ eng.close()
336
+
337
+
338
+ @cli.command("schema")
339
+ @click.option("--needs-review", is_flag=True)
340
+ @click.option("--limit", default=50, show_default=True)
341
+ @click.pass_context
342
+ def schema_list(ctx: click.Context, needs_review: bool, limit: int) -> None:
343
+ """List schemas (optionally filtered)."""
344
+ eng = _build_engine(ctx.obj["db"], disable_llm=True)
345
+ kwargs: dict[str, Any] = {"limit": limit}
346
+ if needs_review:
347
+ kwargs["needs_review"] = True
348
+ items = eng.list_schemas(**kwargs)
349
+ if ctx.obj["json"]:
350
+ _print([asdict(s) for s in items], True)
351
+ else:
352
+ for s in items:
353
+ click.echo(
354
+ f" [sch_{s.id}] {s.content_text}"
355
+ f" status={s.status} sal={s.salience:.3f} supports={len(s.supporting_episode_ids)}"
356
+ f" tags={','.join(s.tags)}" + (" needs_review" if s.needs_review else "")
357
+ )
358
+ eng.close()
359
+
360
+
361
+ @cli.command("stats")
362
+ @click.pass_context
363
+ def stats_cmd(ctx: click.Context) -> None:
364
+ """Print system stats."""
365
+ eng = _build_engine(ctx.obj["db"], disable_llm=True)
366
+ _print(eng.stats(), ctx.obj["json"])
367
+ eng.close()
368
+
369
+
370
+ def _slowave_processes() -> list[dict[str, Any]]:
371
+ """Best-effort local process snapshot for operational hygiene."""
372
+ try:
373
+ out = subprocess.check_output(
374
+ ["ps", "-axo", "pid,ppid,stat,rss,command"],
375
+ text=True,
376
+ stderr=subprocess.DEVNULL,
377
+ )
378
+ except Exception:
379
+ return []
380
+ rows: list[dict[str, Any]] = []
381
+ for line in out.splitlines()[1:]:
382
+ parts = line.strip().split(None, 4)
383
+ if len(parts) < 5:
384
+ continue
385
+ pid, ppid, stat, rss, command = parts
386
+ if (
387
+ "slowave-mcp" not in command
388
+ and "slowave worker" not in command
389
+ and "slowave.cli.main" not in command
390
+ ):
391
+ continue
392
+ rows.append(
393
+ {
394
+ "pid": int(pid),
395
+ "ppid": int(ppid),
396
+ "stat": stat,
397
+ "rss_kb": int(rss),
398
+ "command": command,
399
+ }
400
+ )
401
+ return rows
402
+
403
+
404
+ @cli.command("status")
405
+ @click.pass_context
406
+ def status_cmd(ctx: click.Context) -> None:
407
+ """Print DB, memory-health, and local process status."""
408
+ db = ctx.obj["db"]
409
+ eng = _build_engine(db, disable_llm=True)
410
+ payload = {
411
+ "db_path": os.path.abspath(os.path.expanduser(db)),
412
+ "db_exists": os.path.exists(os.path.expanduser(db)),
413
+ "stats": eng.stats(),
414
+ "schema_health": eng.schema_health(),
415
+ "processes": _slowave_processes(),
416
+ }
417
+ eng.close()
418
+ if ctx.obj["json"]:
419
+ _print(payload, True)
420
+ return
421
+ click.echo(f"DB: {payload['db_path']} ({'exists' if payload['db_exists'] else 'missing'})")
422
+ click.echo(f"Stats: {payload['stats']}")
423
+ h = payload["schema_health"]
424
+ click.echo(
425
+ "Schema health: "
426
+ f"active={h['active_schemas']} unique_exact={h['active_unique_exact_by_project']} "
427
+ f"dup_rows={h['active_exact_duplicate_rows']} "
428
+ f"dup_ratio={h['active_exact_duplicate_ratio']:.1%} "
429
+ f"status={h['schemas_by_status']}"
430
+ )
431
+ click.echo("Processes:")
432
+ for p in payload["processes"]:
433
+ click.echo(
434
+ f" pid={p['pid']} ppid={p['ppid']} rss={p['rss_kb']}KB "
435
+ f"stat={p['stat']} {p['command']}"
436
+ )
437
+
438
+
439
+ @cli.command("dashboard")
440
+ @click.option("--host", default="127.0.0.1", show_default=True, help="HTTP bind host.")
441
+ @click.option("--port", default=8765, show_default=True, help="HTTP bind port.")
442
+ @click.option("--refresh-ms", default=2000, show_default=True, help="Overview refresh interval.")
443
+ @click.option("--allow-actions", is_flag=True, help="Reserved for future mutating actions.")
444
+ @click.option("--no-open", is_flag=True, help="Do not open the browser automatically.")
445
+ @click.pass_context
446
+ def dashboard_cmd(
447
+ ctx: click.Context,
448
+ host: str,
449
+ port: int,
450
+ refresh_ms: int,
451
+ allow_actions: bool,
452
+ no_open: bool,
453
+ ) -> None:
454
+ """Run the local read-only Slowave web dashboard."""
455
+ from slowave.dashboard.app import run_dashboard
456
+
457
+ run_dashboard(
458
+ db_path=ctx.obj["db"],
459
+ host=host,
460
+ port=port,
461
+ refresh_ms=refresh_ms,
462
+ allow_actions=allow_actions,
463
+ open_browser=not no_open,
464
+ )
465
+
466
+
467
+ @cli.command("dedup-schemas")
468
+ @click.option("--apply", "apply_changes", is_flag=True, help="Apply cleanup. Default is dry-run.")
469
+ @click.pass_context
470
+ def dedup_schemas_cmd(ctx: click.Context, apply_changes: bool) -> None:
471
+ """Merge exact duplicate active schemas within each project namespace."""
472
+ eng = _build_engine(ctx.obj["db"], disable_llm=True)
473
+ before = eng.schema_health()
474
+ result = eng.dedup_schemas_exact(dry_run=not apply_changes)
475
+ after = eng.schema_health()
476
+ eng.close()
477
+ payload = {"before": before, "dedup": result, "after": after}
478
+ if ctx.obj["json"]:
479
+ _print(payload, True)
480
+ return
481
+ click.echo("Schema deduplication " + ("APPLIED" if apply_changes else "DRY RUN"))
482
+ click.echo(
483
+ f"Before: active={before['active_schemas']} unique_exact={before['active_unique_exact_by_project']} "
484
+ f"dup_rows={before['active_exact_duplicate_rows']} dup_ratio={before['active_exact_duplicate_ratio']:.1%}"
485
+ )
486
+ click.echo(f"Dedup: {result}")
487
+ click.echo(
488
+ f"After : active={after['active_schemas']} unique_exact={after['active_unique_exact_by_project']} "
489
+ f"dup_rows={after['active_exact_duplicate_rows']} dup_ratio={after['active_exact_duplicate_ratio']:.1%}"
490
+ )
491
+
492
+
493
+ @cli.command("consolidate")
494
+ @click.pass_context
495
+ def consolidate_cmd(ctx: click.Context) -> None:
496
+ """Manually trigger a replay + latent consolidation pass."""
497
+ eng = _build_engine(ctx.obj["db"], disable_llm=True, schema_mode="latent")
498
+ stats = eng.replay_engine.replay_once()
499
+ consolidation: dict[str, Any] = {}
500
+ if eng.consolidator is not None:
501
+ # Process all prototypes that have any episode mapping.
502
+ protos = eng._prototypes_for_episodes([])
503
+ cs = eng.consolidator.consolidate(prototype_ids=protos)
504
+ consolidation = {
505
+ "prototypes_processed": cs.prototypes_processed,
506
+ "schemas_created": cs.schemas_created,
507
+ "schemas_reinforced": cs.schemas_reinforced,
508
+ "schemas_contradicted": cs.schemas_contradicted,
509
+ "schemas_skipped": cs.schemas_skipped,
510
+ }
511
+ _print({"replay": stats, "consolidation": consolidation}, ctx.obj["json"])
512
+ eng.close()
513
+
514
+
515
+ @cli.command("worker")
516
+ @click.option(
517
+ "--interval",
518
+ default=300,
519
+ show_default=True,
520
+ help="Seconds between consolidation passes (simulates sleep cycles).",
521
+ )
522
+ @click.option(
523
+ "--once",
524
+ is_flag=True,
525
+ help="Run a single consolidation pass then exit (useful for cron/tests).",
526
+ )
527
+ @click.pass_context
528
+ def worker_cmd(ctx: click.Context, interval: int, once: bool) -> None:
529
+ """Background consolidation worker — the sleep simulator.
530
+
531
+ Runs replay + latent schema construction on a schedule, decoupled from session
532
+ ingest. Mimics slow-wave sleep: episodes accumulate during waking sessions,
533
+ then are consolidated offline.
534
+
535
+ In production: run as a background process or cron job.
536
+ In tests/scripts: use --once to trigger a single pass.
537
+
538
+ Examples:
539
+ slowave worker --once # one pass, then exit
540
+ slowave worker --interval 600 # consolidate every 10 min
541
+ slowave worker --interval 3600 & # background hourly consolidation
542
+ """
543
+ import time as _time
544
+ import signal
545
+
546
+ eng = _build_engine(ctx.obj["db"], disable_llm=True, schema_mode="latent")
547
+ stop = False
548
+
549
+ def _handle_signal(sig: int, frame: Any) -> None:
550
+ nonlocal stop
551
+ click.echo("\nworker: received signal, stopping after current pass.")
552
+ stop = True
553
+
554
+ signal.signal(signal.SIGTERM, _handle_signal)
555
+ signal.signal(signal.SIGINT, _handle_signal)
556
+
557
+ def _run_pass() -> dict[str, Any]:
558
+ replay_stats = eng.replay_engine.replay_once()
559
+ consolidation: dict[str, Any] = {}
560
+ if eng.consolidator is not None:
561
+ protos = eng._prototypes_for_episodes([])
562
+ cs = eng.consolidator.consolidate(prototype_ids=protos)
563
+ consolidation = {
564
+ "prototypes_processed": cs.prototypes_processed,
565
+ "schemas_created": cs.schemas_created,
566
+ "schemas_reinforced": cs.schemas_reinforced,
567
+ "schemas_contradicted": cs.schemas_contradicted,
568
+ "schemas_skipped": cs.schemas_skipped,
569
+ }
570
+ return {"replay": replay_stats, "consolidation": consolidation}
571
+
572
+ if once:
573
+ result = _run_pass()
574
+ _print(result, ctx.obj["json"])
575
+ eng.close()
576
+ return
577
+
578
+ click.echo(f"worker: starting (interval={interval}s). Ctrl-C or SIGTERM to stop.")
579
+ while not stop:
580
+ result = _run_pass()
581
+ if ctx.obj["json"]:
582
+ _print(result, True)
583
+ else:
584
+ cs = result.get("consolidation", {})
585
+ click.echo(
586
+ f"[{__import__('datetime').datetime.now().isoformat(timespec='seconds')}] "
587
+ f"consolidation: created={cs.get('schemas_created', 0)} "
588
+ f"reinforced={cs.get('schemas_reinforced', 0)} "
589
+ f"skipped={cs.get('schemas_skipped', 0)}"
590
+ )
591
+ # rebuild indices so next pass sees fresh state
592
+ eng.refresh_indices()
593
+ for _ in range(interval):
594
+ if stop:
595
+ break
596
+ _time.sleep(1)
597
+
598
+ eng.close()
599
+ click.echo("worker: stopped.")
600
+
601
+
602
+ def main() -> None:
603
+ cli(obj={})
604
+
605
+
606
+ if __name__ == "__main__":
607
+ main()
File without changes
slowave/core/config.py ADDED
@@ -0,0 +1,56 @@
1
+ """Top-level Slowave configuration.
2
+
3
+ Merges SlowWave's latent-side configs with new symbolic-side configs.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ from slowave.latent.graph_manager import GraphConfig
11
+ from slowave.latent.replay_engine import ReplayConfig
12
+ from slowave.latent.retrieval import RetrievalConfig
13
+ from slowave.latent.salience import SalienceConfig
14
+ from slowave.latent.transition_model import TransitionModelConfig
15
+ from slowave.llm.base import LLMBackendConfig
16
+ from slowave.symbolic.encoder import EncoderConfig
17
+ from slowave.core.paths import default_db_path
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class SlowaveConfig:
22
+ # storage
23
+ db_path: str = field(default_factory=default_db_path)
24
+ schema_path: str = "" # filled in by engine if empty
25
+
26
+ # latent layer (text embeddings will set dim automatically)
27
+ dim: int = 384
28
+
29
+ # encoder
30
+ encoder: EncoderConfig = field(default_factory=EncoderConfig)
31
+
32
+ # llm backend (used only at replay)
33
+ llm: LLMBackendConfig = field(default_factory=LLMBackendConfig)
34
+
35
+ # slowwave core configs
36
+ salience: SalienceConfig = field(default_factory=SalienceConfig)
37
+ replay: ReplayConfig = field(default_factory=ReplayConfig)
38
+ graph: GraphConfig = field(default_factory=GraphConfig)
39
+ retrieval: RetrievalConfig = field(default_factory=RetrievalConfig)
40
+ transition: TransitionModelConfig | None = None
41
+
42
+ # symbolic
43
+ schema_min_confidence: float = 0.4
44
+ # if True, fall back to text-only mode (no LLM, no schema extraction).
45
+ # Useful for tests and the synthetic demo.
46
+ disable_llm: bool = False
47
+ disable_encoder: bool = False
48
+ # Stage 6: how schemas are formed.
49
+ # "llm" — original path, LLM extracts text claims per prototype
50
+ # "latent" — brain-only path, schemas are pure prototype geometry
51
+ # (zero LLM calls during ingest or consolidation)
52
+ schema_mode: str = "latent"
53
+
54
+ @staticmethod
55
+ def default_schema_path() -> str:
56
+ return str(Path(__file__).resolve().parent.parent / "storage" / "schema.sql")