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.
- slowave/__init__.py +10 -0
- slowave/__main__.py +13 -0
- slowave/cli/__init__.py +0 -0
- slowave/cli/main.py +607 -0
- slowave/core/__init__.py +0 -0
- slowave/core/config.py +56 -0
- slowave/core/consolidation.py +533 -0
- slowave/core/context.py +450 -0
- slowave/core/engine.py +751 -0
- slowave/core/paths.py +13 -0
- slowave/dashboard/__init__.py +0 -0
- slowave/dashboard/app.py +687 -0
- slowave/latent/__init__.py +0 -0
- slowave/latent/episodic_store.py +196 -0
- slowave/latent/graph_manager.py +160 -0
- slowave/latent/metrics.py +53 -0
- slowave/latent/replay_engine.py +497 -0
- slowave/latent/retrieval.py +514 -0
- slowave/latent/salience.py +61 -0
- slowave/latent/schema.py +298 -0
- slowave/latent/semantic_store.py +304 -0
- slowave/latent/synthetic.py +56 -0
- slowave/latent/temporal.py +130 -0
- slowave/latent/transition_model.py +57 -0
- slowave/latent/types.py +51 -0
- slowave/llm/__init__.py +37 -0
- slowave/llm/base.py +52 -0
- slowave/llm/ollama_backend.py +78 -0
- slowave/llm/openrouter_backend.py +204 -0
- slowave/llm/prompts/extract_schema.txt +103 -0
- slowave/llm/prompts/judge_contradiction.txt +23 -0
- slowave/mcp/__init__.py +0 -0
- slowave/mcp/server.py +337 -0
- slowave/storage/__init__.py +0 -0
- slowave/storage/schema.sql +205 -0
- slowave/storage/sqlite_db.py +161 -0
- slowave/symbolic/__init__.py +0 -0
- slowave/symbolic/contradiction.py +62 -0
- slowave/symbolic/encoder.py +77 -0
- slowave/symbolic/episode_text.py +91 -0
- slowave/symbolic/raw_log.py +152 -0
- slowave/symbolic/schema_extractor.py +122 -0
- slowave/symbolic/schema_store.py +732 -0
- slowave/utils/__init__.py +0 -0
- slowave/utils/logging.py +0 -0
- slowave/utils/vec.py +40 -0
- slowave-0.1.3.dist-info/METADATA +168 -0
- slowave-0.1.3.dist-info/RECORD +52 -0
- slowave-0.1.3.dist-info/WHEEL +5 -0
- slowave-0.1.3.dist-info/entry_points.txt +3 -0
- slowave-0.1.3.dist-info/licenses/LICENSE +21 -0
- 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()
|
slowave/cli/__init__.py
ADDED
|
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()
|
slowave/core/__init__.py
ADDED
|
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")
|