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 ADDED
@@ -0,0 +1,7 @@
1
+ """Rewind — 7-layer bio-inspired memory architecture for AI agents."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from rewind.client import RewindClient
6
+
7
+ __all__ = ["RewindClient", "__version__"]
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
+ ]