rewind-memory 0.1.0__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.
- rewind/__init__.py +7 -0
- rewind/__main__.py +213 -0
- rewind/client.py +289 -0
- rewind/config.py +176 -0
- rewind/embedding/__init__.py +13 -0
- rewind/embedding/base.py +32 -0
- rewind/embedding/ollama.py +55 -0
- rewind/ingest.py +705 -0
- rewind/integrations/__init__.py +1 -0
- rewind/integrations/mcp_server.py +453 -0
- rewind/integrations/openclaw.py +273 -0
- rewind/layers/__init__.py +29 -0
- rewind/layers/l0_fts.py +115 -0
- rewind/layers/l1_system.py +143 -0
- rewind/layers/l2_orchestrator.py +287 -0
- rewind/layers/l3_graph_sqlite.py +316 -0
- rewind/layers/l4_vector.py +198 -0
- rewind/migrate.py +455 -0
- rewind/utils.py +83 -0
- rewind_memory-0.1.0.dist-info/METADATA +220 -0
- rewind_memory-0.1.0.dist-info/RECORD +25 -0
- rewind_memory-0.1.0.dist-info/WHEEL +5 -0
- rewind_memory-0.1.0.dist-info/entry_points.txt +4 -0
- rewind_memory-0.1.0.dist-info/licenses/LICENSE +17 -0
- rewind_memory-0.1.0.dist-info/top_level.txt +1 -0
rewind/__init__.py
ADDED
rewind/__main__.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Rewind CLI — memory stack for AI agents.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
rewind ingest <path> [--tier free|pro|enterprise] [--db ./data/vector.db]
|
|
5
|
+
rewind search <query> [--tier free|pro|enterprise] [--limit 10]
|
|
6
|
+
rewind health [--tier free|pro|enterprise]
|
|
7
|
+
rewind stats
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def cmd_ingest(args: argparse.Namespace) -> None:
|
|
19
|
+
"""Ingest files into the memory stack."""
|
|
20
|
+
from rewind.config import default_config
|
|
21
|
+
from rewind.ingest import IngestPipeline
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
cfg = default_config(args.tier)
|
|
25
|
+
if args.db:
|
|
26
|
+
cfg.vector_db_path = args.db
|
|
27
|
+
|
|
28
|
+
pipeline = IngestPipeline(cfg)
|
|
29
|
+
|
|
30
|
+
path = Path(args.path)
|
|
31
|
+
if path.is_dir():
|
|
32
|
+
stats = pipeline.ingest_directory(str(path))
|
|
33
|
+
elif path.is_file():
|
|
34
|
+
stats = pipeline.ingest_file(str(path))
|
|
35
|
+
else:
|
|
36
|
+
print(f"Error: {args.path} not found", file=sys.stderr)
|
|
37
|
+
sys.exit(1)
|
|
38
|
+
|
|
39
|
+
pipeline.close()
|
|
40
|
+
|
|
41
|
+
# Print summary
|
|
42
|
+
print(f"Files: {stats.get('files', 1)}")
|
|
43
|
+
print(f"Chunks: {stats.get('chunks', 0)}")
|
|
44
|
+
print(f"Vectors: {stats.get('vectors', 0)}")
|
|
45
|
+
print(f"Entities: {stats.get('entities', 0)}")
|
|
46
|
+
if "elapsed_seconds" in stats:
|
|
47
|
+
print(f"Time: {stats['elapsed_seconds']}s")
|
|
48
|
+
if stats.get("errors"):
|
|
49
|
+
print(f"Errors: {stats['errors']}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def cmd_search(args: argparse.Namespace) -> None:
|
|
53
|
+
"""Search the memory stack."""
|
|
54
|
+
from rewind.client import RewindClient
|
|
55
|
+
from rewind.config import default_config
|
|
56
|
+
|
|
57
|
+
cfg = default_config(args.tier)
|
|
58
|
+
if args.db:
|
|
59
|
+
cfg.vector_db_path = args.db
|
|
60
|
+
|
|
61
|
+
client = RewindClient(cfg)
|
|
62
|
+
results = client.search(args.query, limit=args.limit)
|
|
63
|
+
client.close()
|
|
64
|
+
|
|
65
|
+
if not results:
|
|
66
|
+
print("No results found.")
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
if args.json:
|
|
70
|
+
print(json.dumps([{
|
|
71
|
+
"score": r.score, "layer": r.layer,
|
|
72
|
+
"path": r.path, "text": r.text[:200],
|
|
73
|
+
} for r in results], indent=2))
|
|
74
|
+
else:
|
|
75
|
+
for i, r in enumerate(results, 1):
|
|
76
|
+
print(f"\n{'─' * 60}")
|
|
77
|
+
print(f" #{i} score={r.score:.3f} layer={r.layer}")
|
|
78
|
+
if r.path:
|
|
79
|
+
print(f" path: {r.path}")
|
|
80
|
+
print(f" {r.text[:200]}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def cmd_health(args: argparse.Namespace) -> None:
|
|
84
|
+
"""Health check."""
|
|
85
|
+
from rewind.client import RewindClient
|
|
86
|
+
from rewind.config import default_config
|
|
87
|
+
|
|
88
|
+
cfg = default_config(args.tier)
|
|
89
|
+
if args.db:
|
|
90
|
+
cfg.vector_db_path = args.db
|
|
91
|
+
|
|
92
|
+
client = RewindClient(cfg)
|
|
93
|
+
health = client.health()
|
|
94
|
+
client.close()
|
|
95
|
+
|
|
96
|
+
print(f"Tier: {health['tier']}")
|
|
97
|
+
print(f"Orchestrator: {'✅' if health['orchestrator'] else '❌'}")
|
|
98
|
+
print(f"Bio: {'✅' if health['bio'] else '❌'}")
|
|
99
|
+
print(f"Healthy: {'✅' if health['healthy'] else '❌'}")
|
|
100
|
+
print()
|
|
101
|
+
for name, status in health.get("layers", {}).items():
|
|
102
|
+
s = status.get("status", "unknown")
|
|
103
|
+
icon = "✅" if s == "ok" else "❌" if s == "error" else "⚠️"
|
|
104
|
+
extra = ""
|
|
105
|
+
if "nodes" in status:
|
|
106
|
+
extra = f" ({status['nodes']} nodes, {status.get('edges', 0)} edges)"
|
|
107
|
+
elif "total_chunks" in status:
|
|
108
|
+
extra = f" ({status['total_chunks']} chunks)"
|
|
109
|
+
elif "backend" in status:
|
|
110
|
+
extra = f" ({status['backend']})"
|
|
111
|
+
print(f" {icon} {name}: {s}{extra}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def cmd_migrate(args: argparse.Namespace) -> None:
|
|
115
|
+
"""Migrate SQLite backends to Neo4j/Qdrant."""
|
|
116
|
+
from rewind.migrate import Migrator
|
|
117
|
+
|
|
118
|
+
db = args.db or "./data/vector.db"
|
|
119
|
+
migrator = Migrator(sqlite_db=db)
|
|
120
|
+
|
|
121
|
+
if args.subcmd == "status":
|
|
122
|
+
import json
|
|
123
|
+
print(json.dumps(migrator.status(), indent=2))
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
if args.subcmd == "graph":
|
|
127
|
+
result = migrator.migrate_graph_to_neo4j(
|
|
128
|
+
uri=args.uri or "bolt://localhost:7687",
|
|
129
|
+
user=args.user or "neo4j",
|
|
130
|
+
password=args.password or "",
|
|
131
|
+
dry_run=args.dry_run,
|
|
132
|
+
)
|
|
133
|
+
elif args.subcmd == "vectors":
|
|
134
|
+
result = migrator.migrate_vectors_to_qdrant(
|
|
135
|
+
qdrant_url=args.uri or "http://localhost:6333",
|
|
136
|
+
collection=args.collection or "rewind_chunks",
|
|
137
|
+
dry_run=args.dry_run,
|
|
138
|
+
)
|
|
139
|
+
else:
|
|
140
|
+
print("Usage: rewind migrate {status|graph|vectors}", file=sys.stderr)
|
|
141
|
+
sys.exit(1)
|
|
142
|
+
|
|
143
|
+
if "error" in result:
|
|
144
|
+
print(f"Error: {result['error']}", file=sys.stderr)
|
|
145
|
+
sys.exit(1)
|
|
146
|
+
|
|
147
|
+
import json
|
|
148
|
+
print(json.dumps(result, indent=2))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def main() -> None:
|
|
152
|
+
parser = argparse.ArgumentParser(
|
|
153
|
+
prog="rewind",
|
|
154
|
+
description="Rewind — bio-inspired memory stack for AI agents",
|
|
155
|
+
)
|
|
156
|
+
parser.add_argument(
|
|
157
|
+
"-v", "--verbose", action="store_true",
|
|
158
|
+
help="Enable debug logging",
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
sub = parser.add_subparsers(dest="command")
|
|
162
|
+
|
|
163
|
+
# ingest
|
|
164
|
+
p_ingest = sub.add_parser("ingest", help="Ingest files into memory")
|
|
165
|
+
p_ingest.add_argument("path", help="File or directory to ingest")
|
|
166
|
+
p_ingest.add_argument("--tier", default="free", choices=["free", "pro", "enterprise"])
|
|
167
|
+
p_ingest.add_argument("--db", default=None, help="Path to vector DB")
|
|
168
|
+
|
|
169
|
+
# search
|
|
170
|
+
p_search = sub.add_parser("search", help="Search memory")
|
|
171
|
+
p_search.add_argument("query", help="Search query")
|
|
172
|
+
p_search.add_argument("--tier", default="free", choices=["free", "pro", "enterprise"])
|
|
173
|
+
p_search.add_argument("--db", default=None, help="Path to vector DB")
|
|
174
|
+
p_search.add_argument("--limit", type=int, default=10)
|
|
175
|
+
p_search.add_argument("--json", action="store_true", help="Output JSON")
|
|
176
|
+
|
|
177
|
+
# health
|
|
178
|
+
p_health = sub.add_parser("health", help="Health check")
|
|
179
|
+
p_health.add_argument("--tier", default="free", choices=["free", "pro", "enterprise"])
|
|
180
|
+
p_health.add_argument("--db", default=None, help="Path to vector DB")
|
|
181
|
+
|
|
182
|
+
# migrate
|
|
183
|
+
p_migrate = sub.add_parser("migrate", help="Migrate backends (Pro)")
|
|
184
|
+
p_migrate.add_argument("subcmd", choices=["status", "graph", "vectors"],
|
|
185
|
+
help="Migration target")
|
|
186
|
+
p_migrate.add_argument("--db", default=None, help="Path to SQLite DB")
|
|
187
|
+
p_migrate.add_argument("--uri", default=None, help="Target URI (bolt:// or http://)")
|
|
188
|
+
p_migrate.add_argument("--user", default="neo4j", help="Neo4j username")
|
|
189
|
+
p_migrate.add_argument("--password", default="", help="Neo4j password")
|
|
190
|
+
p_migrate.add_argument("--collection", default="rewind_chunks", help="Qdrant collection")
|
|
191
|
+
p_migrate.add_argument("--dry-run", action="store_true", help="Report only, don't write")
|
|
192
|
+
|
|
193
|
+
args = parser.parse_args()
|
|
194
|
+
|
|
195
|
+
if args.verbose:
|
|
196
|
+
logging.basicConfig(level=logging.DEBUG, format="%(levelname)s %(name)s: %(message)s")
|
|
197
|
+
else:
|
|
198
|
+
logging.basicConfig(level=logging.WARNING)
|
|
199
|
+
|
|
200
|
+
if args.command == "ingest":
|
|
201
|
+
cmd_ingest(args)
|
|
202
|
+
elif args.command == "search":
|
|
203
|
+
cmd_search(args)
|
|
204
|
+
elif args.command == "health":
|
|
205
|
+
cmd_health(args)
|
|
206
|
+
elif args.command == "migrate":
|
|
207
|
+
cmd_migrate(args)
|
|
208
|
+
else:
|
|
209
|
+
parser.print_help()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
if __name__ == "__main__":
|
|
213
|
+
main()
|
rewind/client.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""RewindClient — unified interface to the memory stack.
|
|
2
|
+
|
|
3
|
+
Free tier: L0 (FTS5), L1 (System), L3 (SQLite Graph), L4 (Vector).
|
|
4
|
+
Pro tier: extends with L5, L6, Bio, Feedback via rewind-memory-pro plugin.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
from rewind.config import RewindConfig, default_config
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# Result type
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Result:
|
|
26
|
+
path: str = ""
|
|
27
|
+
text: str = ""
|
|
28
|
+
score: float = 0.0
|
|
29
|
+
layer: str = ""
|
|
30
|
+
source: str = ""
|
|
31
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Plugin registry — Pro layers register themselves here
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
_PRO_PLUGIN: Optional[Any] = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def register_pro_plugin(plugin: Any) -> None:
|
|
42
|
+
"""Called by rewind-memory-pro on import to extend the client."""
|
|
43
|
+
global _PRO_PLUGIN
|
|
44
|
+
_PRO_PLUGIN = plugin
|
|
45
|
+
log.info("Rewind Pro plugin registered")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Client
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
class RewindClient:
|
|
53
|
+
"""Unified gateway to the memory architecture.
|
|
54
|
+
|
|
55
|
+
Usage::
|
|
56
|
+
|
|
57
|
+
from rewind.client import RewindClient
|
|
58
|
+
from rewind.config import default_config
|
|
59
|
+
|
|
60
|
+
client = RewindClient(default_config("free"))
|
|
61
|
+
results = client.search("what is Hebbian learning")
|
|
62
|
+
for r in results:
|
|
63
|
+
print(f"{r.score:.2f} [{r.layer}] {r.text[:80]}")
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
config: Optional[RewindConfig] = None,
|
|
69
|
+
embedding_fn: Optional[Callable[[str], List[float]]] = None,
|
|
70
|
+
api_key: Optional[str] = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
self.config = config or default_config()
|
|
73
|
+
self._embedding_fn = embedding_fn
|
|
74
|
+
self._api_key = api_key
|
|
75
|
+
self._layers: Dict[str, Any] = {}
|
|
76
|
+
self._orchestrator = None
|
|
77
|
+
self._initialised = False
|
|
78
|
+
|
|
79
|
+
# -- lazy init --------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
def _init_layers(self) -> None:
|
|
82
|
+
if self._initialised:
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
cfg = self.config
|
|
86
|
+
lf = cfg.layers
|
|
87
|
+
|
|
88
|
+
workspace = os.path.expanduser(cfg.workspace_path)
|
|
89
|
+
db_path = os.path.expanduser(cfg.vector_db_path)
|
|
90
|
+
|
|
91
|
+
# -- L0: FTS5/BM25 keyword search --
|
|
92
|
+
if lf.l0_sensory:
|
|
93
|
+
try:
|
|
94
|
+
from rewind.layers.l0_fts import L0FullTextSearch
|
|
95
|
+
self._layers["l0"] = L0FullTextSearch(db_path)
|
|
96
|
+
log.info("L0 (FTS5) initialised: %s", db_path)
|
|
97
|
+
except Exception as exc:
|
|
98
|
+
log.warning("L0 init failed: %s", exc)
|
|
99
|
+
|
|
100
|
+
# -- L1: System files --
|
|
101
|
+
if lf.l1_stm:
|
|
102
|
+
try:
|
|
103
|
+
from rewind.layers.l1_system import L1SystemFiles
|
|
104
|
+
self._layers["l1"] = L1SystemFiles(workspace)
|
|
105
|
+
log.info("L1 (System Files) initialised: %s", workspace)
|
|
106
|
+
except Exception as exc:
|
|
107
|
+
log.warning("L1 init failed: %s", exc)
|
|
108
|
+
|
|
109
|
+
# -- L3: Knowledge graph (SQLite) --
|
|
110
|
+
if lf.l3_graph:
|
|
111
|
+
try:
|
|
112
|
+
from rewind.layers.l3_graph_sqlite import L3GraphSQLite
|
|
113
|
+
graph_db = str(Path(db_path).parent / "graph.db")
|
|
114
|
+
self._layers["l3"] = L3GraphSQLite(graph_db)
|
|
115
|
+
log.info("L3 (Graph/SQLite) initialised: %s", graph_db)
|
|
116
|
+
except Exception as exc:
|
|
117
|
+
log.warning("L3 init failed: %s", exc)
|
|
118
|
+
|
|
119
|
+
# -- L4: Vector search (sqlite-vec) --
|
|
120
|
+
if lf.l4_workspace:
|
|
121
|
+
try:
|
|
122
|
+
from rewind.layers.l4_vector import L4Vector
|
|
123
|
+
self._layers["l4"] = L4Vector(
|
|
124
|
+
db_path=db_path,
|
|
125
|
+
embedding_fn=self._get_embedding_fn(),
|
|
126
|
+
)
|
|
127
|
+
log.info("L4 (Vector) initialised: %s", db_path)
|
|
128
|
+
except Exception as exc:
|
|
129
|
+
log.warning("L4 init failed: %s", exc)
|
|
130
|
+
|
|
131
|
+
# -- Pro layers (via plugin) --
|
|
132
|
+
if _PRO_PLUGIN and self._api_key:
|
|
133
|
+
try:
|
|
134
|
+
pro_layers = _PRO_PLUGIN.init_pro_layers(
|
|
135
|
+
config=cfg,
|
|
136
|
+
api_key=self._api_key,
|
|
137
|
+
db_path=db_path,
|
|
138
|
+
embedding_fn=self._get_embedding_fn(),
|
|
139
|
+
)
|
|
140
|
+
self._layers.update(pro_layers)
|
|
141
|
+
log.info("Pro layers initialised: %s", list(pro_layers.keys()))
|
|
142
|
+
except Exception as exc:
|
|
143
|
+
log.warning("Pro layer init failed: %s", exc)
|
|
144
|
+
elif any([lf.l5_comms, lf.l6_docs, lf.bio]):
|
|
145
|
+
log.warning(
|
|
146
|
+
"Pro features requested but rewind-memory-pro is not installed "
|
|
147
|
+
"or no API key provided. Install with: "
|
|
148
|
+
"pip install git+https://github.com/saraidefence/rewind-memory-pro.git"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# -- L2: Orchestrator --
|
|
152
|
+
if self._layers:
|
|
153
|
+
try:
|
|
154
|
+
from rewind.layers.l2_orchestrator import L2Orchestrator
|
|
155
|
+
orch_config = {f"enable_{k}": True for k in self._layers}
|
|
156
|
+
self._orchestrator = L2Orchestrator(orch_config, self._layers)
|
|
157
|
+
log.info("L2 (Orchestrator) initialised with layers: %s",
|
|
158
|
+
list(self._layers.keys()))
|
|
159
|
+
except Exception as exc:
|
|
160
|
+
log.warning("L2 orchestrator init failed: %s", exc)
|
|
161
|
+
|
|
162
|
+
self._initialised = True
|
|
163
|
+
|
|
164
|
+
def _get_embedding_fn(self) -> Optional[Callable[[str], List[float]]]:
|
|
165
|
+
"""Return the embedding function."""
|
|
166
|
+
if self._embedding_fn:
|
|
167
|
+
return self._embedding_fn
|
|
168
|
+
|
|
169
|
+
cfg = self.config.embedding
|
|
170
|
+
if cfg.provider == "ollama":
|
|
171
|
+
return self._make_ollama_embedder(cfg.url, cfg.model)
|
|
172
|
+
|
|
173
|
+
# Pro embedding providers handled by plugin
|
|
174
|
+
if _PRO_PLUGIN and self._api_key:
|
|
175
|
+
fn = _PRO_PLUGIN.get_embedding_fn(cfg, self._api_key)
|
|
176
|
+
if fn:
|
|
177
|
+
return fn
|
|
178
|
+
|
|
179
|
+
return self._make_ollama_embedder(cfg.url, cfg.model)
|
|
180
|
+
|
|
181
|
+
@staticmethod
|
|
182
|
+
def _make_ollama_embedder(url: str, model: str) -> Callable[[str], List[float]]:
|
|
183
|
+
def embed(text: str) -> List[float]:
|
|
184
|
+
try:
|
|
185
|
+
import httpx
|
|
186
|
+
resp = httpx.post(
|
|
187
|
+
f"{url}/api/embed",
|
|
188
|
+
json={"model": model, "input": text},
|
|
189
|
+
timeout=10,
|
|
190
|
+
)
|
|
191
|
+
resp.raise_for_status()
|
|
192
|
+
data = resp.json()
|
|
193
|
+
return data["embeddings"][0] if "embeddings" in data else data["embedding"]
|
|
194
|
+
except Exception:
|
|
195
|
+
return []
|
|
196
|
+
return embed
|
|
197
|
+
|
|
198
|
+
# -- public API -------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
def search(
|
|
201
|
+
self,
|
|
202
|
+
query: str,
|
|
203
|
+
limit: int = 16,
|
|
204
|
+
namespace: Optional[str] = None,
|
|
205
|
+
) -> List[Result]:
|
|
206
|
+
"""Search across all enabled layers."""
|
|
207
|
+
self._init_layers()
|
|
208
|
+
|
|
209
|
+
if self._orchestrator:
|
|
210
|
+
raw_results = self._orchestrator.search(
|
|
211
|
+
query, limit=limit, namespace=namespace,
|
|
212
|
+
)
|
|
213
|
+
return [
|
|
214
|
+
Result(
|
|
215
|
+
path=r.get("path", ""),
|
|
216
|
+
text=r.get("text", ""),
|
|
217
|
+
score=r.get("score", 0.0),
|
|
218
|
+
layer=r.get("layer", ""),
|
|
219
|
+
source=r.get("source", ""),
|
|
220
|
+
metadata={k: v for k, v in r.items()
|
|
221
|
+
if k not in ("path", "text", "score", "layer", "source")},
|
|
222
|
+
)
|
|
223
|
+
for r in raw_results
|
|
224
|
+
][:limit]
|
|
225
|
+
|
|
226
|
+
# Fallback: sequential search
|
|
227
|
+
results: List[Result] = []
|
|
228
|
+
for name, layer in self._layers.items():
|
|
229
|
+
try:
|
|
230
|
+
layer_results = layer.search(query, limit=limit, namespace=namespace)
|
|
231
|
+
if isinstance(layer_results, list):
|
|
232
|
+
for r in layer_results:
|
|
233
|
+
if isinstance(r, dict):
|
|
234
|
+
results.append(Result(
|
|
235
|
+
path=r.get("path", ""),
|
|
236
|
+
text=r.get("text", ""),
|
|
237
|
+
score=r.get("score", 0.0),
|
|
238
|
+
layer=r.get("layer", name),
|
|
239
|
+
source=r.get("source", ""),
|
|
240
|
+
))
|
|
241
|
+
except Exception as exc:
|
|
242
|
+
log.warning("Search failed on %s: %s", name, exc)
|
|
243
|
+
|
|
244
|
+
results.sort(key=lambda r: r.score, reverse=True)
|
|
245
|
+
return results[:limit]
|
|
246
|
+
|
|
247
|
+
def ingest(self, path: str, arena: str = "general") -> dict:
|
|
248
|
+
"""Ingest a document through L0 sensory buffer."""
|
|
249
|
+
self._init_layers()
|
|
250
|
+
l0 = self._layers.get("l0")
|
|
251
|
+
if l0 and hasattr(l0, "ingest"):
|
|
252
|
+
return l0.ingest(path, arena)
|
|
253
|
+
return {"status": "error", "detail": "L0 sensory layer not available"}
|
|
254
|
+
|
|
255
|
+
def health(self) -> dict:
|
|
256
|
+
"""Health check across all enabled layers."""
|
|
257
|
+
self._init_layers()
|
|
258
|
+
report: Dict[str, Any] = {
|
|
259
|
+
"tier": self.config.tier,
|
|
260
|
+
"layers": {},
|
|
261
|
+
"orchestrator": self._orchestrator is not None,
|
|
262
|
+
"pro_installed": _PRO_PLUGIN is not None,
|
|
263
|
+
}
|
|
264
|
+
for key, layer in self._layers.items():
|
|
265
|
+
try:
|
|
266
|
+
if hasattr(layer, "health"):
|
|
267
|
+
report["layers"][key] = layer.health()
|
|
268
|
+
else:
|
|
269
|
+
report["layers"][key] = {"status": "ok"}
|
|
270
|
+
except Exception as exc:
|
|
271
|
+
report["layers"][key] = {"status": "error", "detail": str(exc)}
|
|
272
|
+
|
|
273
|
+
report["healthy"] = all(
|
|
274
|
+
v.get("status") in ("ok", "degraded")
|
|
275
|
+
for v in report["layers"].values()
|
|
276
|
+
)
|
|
277
|
+
return report
|
|
278
|
+
|
|
279
|
+
def close(self) -> None:
|
|
280
|
+
"""Shut down all layer connections."""
|
|
281
|
+
for name, layer in self._layers.items():
|
|
282
|
+
try:
|
|
283
|
+
if hasattr(layer, "close"):
|
|
284
|
+
layer.close()
|
|
285
|
+
except Exception as exc:
|
|
286
|
+
log.warning("Close failed on %s: %s", name, exc)
|
|
287
|
+
self._layers.clear()
|
|
288
|
+
self._orchestrator = None
|
|
289
|
+
self._initialised = False
|
rewind/config.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Rewind configuration — YAML-driven, tier-aware, no hardcoded paths."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, Optional
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Sub-configs
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class EmbeddingConfig:
|
|
20
|
+
provider: str = "ollama" # ollama | nv_embed | openai
|
|
21
|
+
url: str = "http://localhost:11434"
|
|
22
|
+
model: str = "nomic-embed-text"
|
|
23
|
+
dimension: int = 768
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Neo4jConfig:
|
|
28
|
+
uri: str = "bolt://localhost:7687"
|
|
29
|
+
user: str = "neo4j"
|
|
30
|
+
password: str = ""
|
|
31
|
+
secrets_file: Optional[str] = None # path to JSON with {"password": "..."}
|
|
32
|
+
|
|
33
|
+
def resolve_password(self) -> str:
|
|
34
|
+
"""Return password, loading from secrets file if configured."""
|
|
35
|
+
if self.secrets_file and not self.password:
|
|
36
|
+
p = Path(self.secrets_file).expanduser()
|
|
37
|
+
if p.exists():
|
|
38
|
+
data = json.loads(p.read_text())
|
|
39
|
+
self.password = data.get("password", "")
|
|
40
|
+
return self.password
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class QdrantConfig:
|
|
45
|
+
url: str = "http://localhost:6333"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class LayerFlags:
|
|
50
|
+
l0_sensory: bool = True # always on — FTS5/BM25 keyword search
|
|
51
|
+
l1_stm: bool = True # short-term memory (sqlite-vec)
|
|
52
|
+
l2_orchestrator: bool = True # search router — implicitly on when any store is
|
|
53
|
+
l3_graph: bool = True # knowledge graph (SQLite default, Neo4j optional)
|
|
54
|
+
l4_workspace: bool = True # file-system workspace memory (sqlite-vec)
|
|
55
|
+
l5_comms: bool = False # communications store (sqlite-vec default, Qdrant optional)
|
|
56
|
+
l6_docs: bool = False # document store
|
|
57
|
+
bio: bool = False # bio-inspired lifecycle (decay, consolidation, pruning)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# Main config
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class RewindConfig:
|
|
66
|
+
layers: LayerFlags = field(default_factory=LayerFlags)
|
|
67
|
+
embedding: EmbeddingConfig = field(default_factory=EmbeddingConfig)
|
|
68
|
+
neo4j: Neo4jConfig = field(default_factory=Neo4jConfig)
|
|
69
|
+
qdrant: QdrantConfig = field(default_factory=QdrantConfig)
|
|
70
|
+
vector_db_path: str = "./data/vector.db"
|
|
71
|
+
workspace_path: str = "./workspace"
|
|
72
|
+
tier: str = "free"
|
|
73
|
+
|
|
74
|
+
# Arbitrary extra keys from YAML
|
|
75
|
+
extra: Dict[str, Any] = field(default_factory=dict)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# Tier presets
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
_TIER_LAYERS: Dict[str, Dict[str, bool]] = {
|
|
83
|
+
"free": dict(
|
|
84
|
+
l0_sensory=True, l1_stm=True, l2_orchestrator=True,
|
|
85
|
+
l3_graph=True, l4_workspace=True, l5_comms=False,
|
|
86
|
+
l6_docs=False, bio=False,
|
|
87
|
+
),
|
|
88
|
+
"pro": dict(
|
|
89
|
+
l0_sensory=True, l1_stm=True, l2_orchestrator=True,
|
|
90
|
+
l3_graph=True, l4_workspace=True, l5_comms=True,
|
|
91
|
+
l6_docs=True, bio=False,
|
|
92
|
+
),
|
|
93
|
+
"enterprise": dict(
|
|
94
|
+
l0_sensory=True, l1_stm=True, l2_orchestrator=True,
|
|
95
|
+
l3_graph=True, l4_workspace=True, l5_comms=True,
|
|
96
|
+
l6_docs=True, bio=True,
|
|
97
|
+
),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
_TIER_EMBEDDING: Dict[str, Dict[str, Any]] = {
|
|
101
|
+
"free": dict(provider="ollama", url="http://localhost:11434",
|
|
102
|
+
model="nomic-embed-text", dimension=768),
|
|
103
|
+
"pro": dict(provider="nv_embed", url="http://localhost:8080",
|
|
104
|
+
model="NV-Embed-v2", dimension=4096),
|
|
105
|
+
"enterprise": dict(provider="nv_embed", url="http://localhost:8080",
|
|
106
|
+
model="NV-Embed-v2", dimension=4096),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def detect_tier(layers: LayerFlags) -> str:
|
|
111
|
+
"""Infer tier string from enabled layers."""
|
|
112
|
+
if layers.bio:
|
|
113
|
+
return "enterprise"
|
|
114
|
+
if layers.l5_comms and layers.l6_docs:
|
|
115
|
+
return "pro"
|
|
116
|
+
return "free"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
# Builders
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def _build_sub(cls, raw: dict | None):
|
|
124
|
+
"""Instantiate a dataclass from a dict, ignoring unknown keys."""
|
|
125
|
+
if not raw:
|
|
126
|
+
return cls()
|
|
127
|
+
valid = {f.name for f in cls.__dataclass_fields__.values()}
|
|
128
|
+
return cls(**{k: v for k, v in raw.items() if k in valid})
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def load_config(path: str) -> RewindConfig:
|
|
132
|
+
"""Load an RewindConfig from a YAML file."""
|
|
133
|
+
p = Path(path).expanduser()
|
|
134
|
+
with p.open() as f:
|
|
135
|
+
raw: dict = yaml.safe_load(f) or {}
|
|
136
|
+
|
|
137
|
+
layers = _build_sub(LayerFlags, raw.get("layers"))
|
|
138
|
+
embedding = _build_sub(EmbeddingConfig, raw.get("embedding"))
|
|
139
|
+
neo4j = _build_sub(Neo4jConfig, raw.get("neo4j"))
|
|
140
|
+
qdrant = _build_sub(QdrantConfig, raw.get("qdrant"))
|
|
141
|
+
|
|
142
|
+
# Resolve neo4j password from env or secrets file
|
|
143
|
+
if not neo4j.password:
|
|
144
|
+
neo4j.password = os.environ.get("REWIND_NEO4J_PASSWORD", "")
|
|
145
|
+
neo4j.resolve_password()
|
|
146
|
+
|
|
147
|
+
known = {"layers", "embedding", "neo4j", "qdrant",
|
|
148
|
+
"vector_db_path", "workspace_path", "tier"}
|
|
149
|
+
extra = {k: v for k, v in raw.items() if k not in known}
|
|
150
|
+
|
|
151
|
+
cfg = RewindConfig(
|
|
152
|
+
layers=layers,
|
|
153
|
+
embedding=embedding,
|
|
154
|
+
neo4j=neo4j,
|
|
155
|
+
qdrant=qdrant,
|
|
156
|
+
vector_db_path=raw.get("vector_db_path", "./data/vector.db"),
|
|
157
|
+
workspace_path=raw.get("workspace_path", "./workspace"),
|
|
158
|
+
tier=raw.get("tier", detect_tier(layers)),
|
|
159
|
+
extra=extra,
|
|
160
|
+
)
|
|
161
|
+
return cfg
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def default_config(tier: str = "free") -> RewindConfig:
|
|
165
|
+
"""Return a sensible default config for the given tier."""
|
|
166
|
+
tier = tier.lower()
|
|
167
|
+
if tier not in _TIER_LAYERS:
|
|
168
|
+
raise ValueError(f"Unknown tier {tier!r}; choose free/pro/enterprise")
|
|
169
|
+
|
|
170
|
+
return RewindConfig(
|
|
171
|
+
layers=LayerFlags(**_TIER_LAYERS[tier]),
|
|
172
|
+
embedding=EmbeddingConfig(**_TIER_EMBEDDING[tier]),
|
|
173
|
+
neo4j=Neo4jConfig(),
|
|
174
|
+
qdrant=QdrantConfig(),
|
|
175
|
+
tier=tier,
|
|
176
|
+
)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Embedding providers."""
|
|
2
|
+
|
|
3
|
+
from .base import EmbeddingProvider
|
|
4
|
+
from .nv_embed import NVEmbedProvider
|
|
5
|
+
from .ollama import OllamaProvider
|
|
6
|
+
from .openai import OpenAIProvider
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"EmbeddingProvider",
|
|
10
|
+
"NVEmbedProvider",
|
|
11
|
+
"OllamaProvider",
|
|
12
|
+
"OpenAIProvider",
|
|
13
|
+
]
|