weft-memory 0.1.0a0__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.
weft/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Weft package: evidence-bound memory substrate."""
2
+
3
+ __version__ = "0.1.0a0"
weft/adapters.py ADDED
@@ -0,0 +1,238 @@
1
+ """Phase 3 real adapters (spec D3-6). Local models only; no network.
2
+
3
+ QwenVLM image -> text (OCR and description), Qwen3.5-2B 8-bit via mlx-vlm
4
+ WhisperTranscriber audio -> text, whisper large-v3-turbo q5_0 via whisper.cpp
5
+
6
+ Each adapter is a callable taking bytes and returning text, with a `model_id`.
7
+ Models load lazily on first use and stay resident for the adapter's lifetime.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import re
14
+ import shutil
15
+ import subprocess
16
+ import tempfile
17
+ from pathlib import Path
18
+
19
+ _SCRATCH = Path(os.path.expanduser("~/.hermes/profiles/arien/cache/scratch"))
20
+
21
+
22
+ def _scratch_dir() -> str:
23
+ _SCRATCH.mkdir(parents=True, exist_ok=True)
24
+ return str(_SCRATCH)
25
+
26
+
27
+ def _image_suffix(data: bytes) -> str:
28
+ if data.startswith(b"\x89PNG"):
29
+ return ".png"
30
+ if data.startswith(b"\xff\xd8\xff"):
31
+ return ".jpg"
32
+ if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
33
+ return ".webp"
34
+ raise ValueError("unsupported image format")
35
+
36
+
37
+ class QwenVLM:
38
+ """OCR and description in one pass. Alpha OCR is accepted per the user (Oct 25)."""
39
+
40
+ PROMPT = (
41
+ "Transcribe all text visible in this image exactly as written. "
42
+ "Then give a one-sentence description of the image."
43
+ )
44
+
45
+ def __init__(self, model_dir, prompt: str | None = None, max_tokens: int = 512):
46
+ self.model_dir = Path(model_dir)
47
+ self.prompt = prompt or self.PROMPT
48
+ self.max_tokens = max_tokens
49
+ self.model_id = "Qwen3.5-2B-8bit (mlx-vlm)"
50
+ self._model = None
51
+ self._processor = None
52
+ self._config = None
53
+ self._apply = None
54
+
55
+ def _load(self):
56
+ import json
57
+ from mlx_vlm import load
58
+ from mlx_vlm.prompt_utils import apply_chat_template
59
+ self._model, self._processor = load(str(self.model_dir))
60
+ self._config = json.loads((self.model_dir / "config.json").read_text())
61
+ self._apply = apply_chat_template
62
+
63
+ def __call__(self, data: bytes) -> str:
64
+ if self._model is None:
65
+ self._load()
66
+ from mlx_vlm import generate
67
+ fd, path = tempfile.mkstemp(suffix=_image_suffix(data), dir=_scratch_dir())
68
+ try:
69
+ with os.fdopen(fd, "wb") as fh:
70
+ fh.write(data)
71
+ formatted = self._apply(self._processor, self._config, self.prompt, num_images=1)
72
+ result = generate(self._model, self._processor, prompt=formatted,
73
+ image=path, verbose=False, max_tokens=self.max_tokens)
74
+ finally:
75
+ os.unlink(path)
76
+ return (getattr(result, "text", None) or str(result)).strip()
77
+
78
+
79
+ class WhisperTranscriber:
80
+ """Audio is converted to 16 kHz mono WAV with ffmpeg, then transcribed locally."""
81
+
82
+ def __init__(self, model_path):
83
+ self.model_path = Path(model_path)
84
+ self.model_id = "whisper-large-v3-turbo-q5_0 (whisper.cpp)"
85
+ self._model = None
86
+
87
+ def _load(self):
88
+ from pywhispercpp.model import Model
89
+ self._model = Model(str(self.model_path))
90
+
91
+ def _to_wav(self, data: bytes) -> str:
92
+ ffmpeg = shutil.which("ffmpeg")
93
+ if ffmpeg is None:
94
+ raise RuntimeError("ffmpeg not found")
95
+ fd_in, src = tempfile.mkstemp(suffix=".bin", dir=_scratch_dir())
96
+ dst = None
97
+ try:
98
+ with os.fdopen(fd_in, "wb") as fh:
99
+ fh.write(data)
100
+ fd_out, dst = tempfile.mkstemp(suffix=".wav", dir=_scratch_dir())
101
+ os.close(fd_out)
102
+ subprocess.run([ffmpeg, "-y", "-loglevel", "error", "-i", src,
103
+ "-ar", "16000", "-ac", "1", dst], check=True, timeout=600)
104
+ except BaseException:
105
+ if dst is not None:
106
+ os.unlink(dst)
107
+ raise
108
+ finally:
109
+ os.unlink(src)
110
+ return dst
111
+
112
+ def __call__(self, data: bytes) -> str:
113
+ if self._model is None:
114
+ self._load()
115
+ wav = self._to_wav(data)
116
+ try:
117
+ segments = self._model.transcribe(wav)
118
+ return " ".join(seg.text.strip() for seg in segments).strip()
119
+ finally:
120
+ os.unlink(wav)
121
+
122
+
123
+ class HarrierEmbedder:
124
+ """Local text embeddings: microsoft/harrier-oss-v1-0.6b (MIT), 1024-dim, last-token pooling.
125
+
126
+ Runs through mlx-embeddings (GPL-3.0; dependency licence recorded in the ADR). Queries
127
+ take the model's retrieval instruction prefix; documents do not. Vectors are L2-normalised.
128
+ """
129
+
130
+ dim = 1024
131
+ QUERY_PREFIX = (
132
+ "Instruct: Given a web search query, retrieve relevant passages that answer the query\n"
133
+ "Query: "
134
+ )
135
+
136
+ def __init__(self, model_dir):
137
+ self.model_dir = Path(model_dir)
138
+ self.model_id = "microsoft/harrier-oss-v1-0.6b (mlx-embeddings)"
139
+ self._model = None
140
+ self._tok = None
141
+
142
+ def _load(self):
143
+ from mlx_embeddings.utils import load
144
+ self._model, self._tok = load(str(self.model_dir))
145
+
146
+ def _embed(self, text: str) -> list:
147
+ if self._model is None:
148
+ self._load()
149
+ import mlx.core as mx
150
+ ids = self._tok.encode(text) if hasattr(self._tok, "encode") else self._tok.tokenizer.encode(text)
151
+ out = self._model(mx.array([ids]))
152
+ hidden = getattr(out, "last_hidden_state", None)
153
+ if hidden is None:
154
+ hidden = out.text_embeds if hasattr(out, "text_embeds") else out[0]
155
+ vec = hidden[0, -1, :]
156
+ norm = mx.sqrt(mx.sum(vec * vec)) + 1e-12
157
+ return [float(x) for x in (vec / norm).tolist()]
158
+
159
+ def embed_document(self, text: str) -> list:
160
+ return self._embed(text)
161
+
162
+ def embed_query(self, text: str) -> list:
163
+ return self._embed(self.QUERY_PREFIX + text)
164
+
165
+
166
+ class Qwen3Embedder(HarrierEmbedder):
167
+ """Qwen/Qwen3-Embedding-0.6B (Apache-2.0), 1024-dim, last-token pooling, Qwen3 query instruction.
168
+
169
+ Same mlx-embeddings path as HarrierEmbedder; only the model and the query prefix differ.
170
+ """
171
+
172
+ QUERY_PREFIX = (
173
+ "Instruct: Given a web search query, retrieve relevant passages that answer the query\n"
174
+ "Query:"
175
+ )
176
+
177
+ def __init__(self, model_dir):
178
+ super().__init__(model_dir)
179
+ self.model_id = "Qwen/Qwen3-Embedding-0.6B (mlx-embeddings)"
180
+
181
+
182
+ class BgeM3Embedder(HarrierEmbedder):
183
+ """BAAI/bge-m3 dense embeddings (MIT), 1024-dim, CLS pooling, no query instruction.
184
+
185
+ Loaded from the mlx-community 4-bit build through mlx-embeddings (weights ~340 MB).
186
+ """
187
+
188
+ QUERY_PREFIX = ""
189
+
190
+ def __init__(self, model_dir):
191
+ super().__init__(model_dir)
192
+ # Review ec112bc RESIDUAL-1: the id names the repo and revision, so different
193
+ # checkpoints or quantisations never share a vector table.
194
+ repo = re.search(r"models--([^/]+)", str(model_dir))
195
+ rev = re.search(r"snapshots/([0-9a-f]+)", str(model_dir))
196
+ name = repo.group(1).replace("--", "/") if repo else "local"
197
+ self.model_id = f"BAAI/bge-m3 via {name}@{rev.group(1)[:12] if rev else 'unpinned'} (mlx-embeddings)"
198
+
199
+ def _embed(self, text: str) -> list:
200
+ if self._model is None:
201
+ self._load()
202
+ import mlx.core as mx
203
+ ids = self._tok.encode(text) if hasattr(self._tok, "encode") else self._tok.tokenizer.encode(text)
204
+ out = self._model(mx.array([ids]))
205
+ vec = out.last_hidden_state[0, 0, :] # CLS token
206
+ norm = mx.sqrt(mx.sum(vec * vec)) + 1e-12
207
+ return [float(x) for x in (vec / norm).tolist()]
208
+
209
+ def embed_query(self, text: str) -> list:
210
+ return self._embed(text)
211
+
212
+
213
+ def default_text_embedder():
214
+ """Load and verify the adopted BAAI/bge-m3 4-bit MLX model from the local HF cache.
215
+
216
+ Raises RuntimeError when the snapshot is absent, incomplete, or cannot produce a valid
217
+ embedding. There is no silent cloud or lexical fallback.
218
+ """
219
+ import glob
220
+
221
+ snapshots = sorted(glob.glob(os.path.expanduser(
222
+ "~/.cache/huggingface/hub/models--mlx-community--bge-m3-mlx-4bit/snapshots/*")))
223
+ if not snapshots:
224
+ raise RuntimeError(
225
+ "embedding model not present: expected mlx-community/bge-m3-mlx-4bit in the local "
226
+ "Hugging Face cache")
227
+ model_dir = Path(snapshots[-1])
228
+ required = (model_dir / "config.json", model_dir / "tokenizer.json", model_dir / "model.safetensors")
229
+ if any(not path.is_file() or path.stat().st_size == 0 for path in required):
230
+ raise RuntimeError("embedding model unavailable: local BGE-M3 snapshot is incomplete")
231
+ embedder = BgeM3Embedder(model_dir)
232
+ try:
233
+ probe = embedder.embed_query("Weft local model readiness check")
234
+ if len(probe) != embedder.dim:
235
+ raise ValueError(f"expected {embedder.dim} dimensions, got {len(probe)}")
236
+ except Exception as exc:
237
+ raise RuntimeError("embedding model unavailable: local BGE-M3 checkpoint failed to load") from exc
238
+ return embedder
weft/cli.py ADDED
@@ -0,0 +1,168 @@
1
+ """Local single-scope CLI composition root for Weft.
2
+
3
+ This is an operator-local tool, not a multi-user service boundary. Each invocation
4
+ can read/write only the explicitly named scope; there is no cross-scope grant path.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import os
12
+ import sqlite3
13
+ import sys
14
+ from contextlib import closing
15
+ from pathlib import Path
16
+
17
+ from weft.adapters import default_text_embedder
18
+ from weft.ingest import Ingestor, Rejection
19
+ from weft.retrieval import recall
20
+ from weft.scope import ScopeContext, ScopeEngine
21
+ from weft.substrate import SCHEMA_VERSION, Substrate, SubstrateError
22
+
23
+
24
+ def _parser() -> argparse.ArgumentParser:
25
+ parser = argparse.ArgumentParser(prog="weft", description="Local, single-scope Weft memory CLI")
26
+ parser.add_argument(
27
+ "--db", default=os.path.expanduser("~/.weft/weft.sqlite3"),
28
+ help="SQLite store path (default: ~/.weft/weft.sqlite3)",
29
+ )
30
+ commands = parser.add_subparsers(dest="command", required=True)
31
+
32
+ ingest = commands.add_parser("ingest", help="ingest a local text, Markdown, PDF, or DOCX file")
33
+ ingest.add_argument("--scope", required=True, help="the one scope this invocation may write")
34
+ ingest.add_argument("--file", required=True, help="path to a local file")
35
+ ingest.add_argument("--author", default="local-user", help="document author label")
36
+
37
+ search = commands.add_parser("recall", help="search one explicitly named scope")
38
+ search.add_argument("--scope", required=True, help="the one scope this invocation may read")
39
+ search.add_argument("--query", required=True, help="natural-language search query")
40
+ search.add_argument("--limit", type=int, default=5)
41
+ serve = commands.add_parser("serve", help="start local HTTP service for desktop integration")
42
+ serve.add_argument("--host", default="127.0.0.1", help="bind address (default: 127.0.0.1)")
43
+ serve.add_argument("--port", type=int, default=8765, help="bind port (default: 8765)")
44
+ rebuild = commands.add_parser("rebuild-fts", help="rebuild one scope's local text index")
45
+ rebuild.add_argument("--scope", required=True)
46
+ return parser
47
+
48
+
49
+ def _context(scope: str) -> tuple[ScopeEngine, ScopeContext]:
50
+ if not scope.strip():
51
+ raise ValueError("scope must be non-empty")
52
+ engine = ScopeEngine(known_scopes={scope})
53
+ # This local CLI treats the operator's explicit invocation as the authority
54
+ # for exactly one scope; it never accepts cross-scope targets or grants.
55
+ ctx = ScopeContext(granted_scopes=frozenset({scope}), user_authored=True, actor="local-user")
56
+ return engine, ctx
57
+
58
+
59
+ def _run_ingest(args) -> int:
60
+ path = Path(args.file).expanduser()
61
+ data = path.read_bytes()
62
+ engine, ctx = _context(args.scope)
63
+ embedder = default_text_embedder() # loads/verifies locally; no cloud or lexical fallback
64
+ sub = None
65
+ try:
66
+ sub = Substrate(args.db, engine)
67
+ result = Ingestor(sub, embedder=embedder).ingest_document(
68
+ path.name, data, ctx, args.scope, args.author
69
+ )
70
+ if isinstance(result, Rejection):
71
+ print(json.dumps({"status": "rejected", "reason": result.reason, "name": result.name}))
72
+ return 2
73
+ if not sub.vector_has_record(result, args.scope, ctx, model_id=embedder.model_id):
74
+ raise RuntimeError("ingestion completed without a vector for the adopted model")
75
+ print(json.dumps({
76
+ "status": "ingested", "record_id": result, "scope": args.scope,
77
+ "vector_indexed": True, "model_id": embedder.model_id,
78
+ }))
79
+ return 0
80
+ finally:
81
+ if sub is not None:
82
+ sub.close()
83
+
84
+
85
+ def _run_recall(args) -> int:
86
+ if args.limit < 1 or args.limit > 100:
87
+ raise ValueError("limit must be between 1 and 100")
88
+ db_path = Path(args.db).expanduser()
89
+ if not db_path.is_file():
90
+ raise FileNotFoundError(f"Weft database not found: {db_path}")
91
+ embedder = default_text_embedder() # hybrid recall requires the local query embedder
92
+ engine, ctx = _context(args.scope)
93
+ sub = None
94
+ try:
95
+ sub = Substrate(db_path, engine)
96
+ items, receipt = recall(sub, args.query, ctx, limit=args.limit, mode="hybrid", embedder=embedder)
97
+ print(json.dumps({
98
+ "mode": receipt.mode,
99
+ "scope_consulted": receipt.scope_consulted,
100
+ "selected": receipt.selected,
101
+ "tier": receipt.tier,
102
+ "items": [
103
+ {"record_id": item.record_id, "scope": item.scope, "text": item.text,
104
+ "tier": item.tier, "derived": item.derived}
105
+ for item in items
106
+ ],
107
+ }, ensure_ascii=False))
108
+ return 0
109
+ finally:
110
+ if sub is not None:
111
+ sub.close()
112
+
113
+
114
+ def _run_rebuild_fts(args) -> int:
115
+ db_path = Path(args.db).expanduser()
116
+ if not db_path.is_file():
117
+ raise FileNotFoundError(f"Weft database not found: {db_path}")
118
+ # Inspect without initializing or migrating an unrelated SQLite file. Old
119
+ # schema versions need a separate migration workflow, not this command.
120
+ with closing(sqlite3.connect(db_path.resolve().as_uri() + "?mode=ro", uri=True)) as check:
121
+ version = check.execute("PRAGMA user_version").fetchone()[0]
122
+ objects = {row[0] for row in check.execute(
123
+ "SELECT name FROM sqlite_master WHERE type='table'"
124
+ )}
125
+ if version != SCHEMA_VERSION or not {"records", "fts", "meta", "journal", "jobs"} <= objects:
126
+ raise ValueError("not a supported existing Weft store; refusing maintenance")
127
+ if check.execute("SELECT value FROM meta WHERE key='store_version'").fetchone() is None:
128
+ raise ValueError("not a supported existing Weft store; refusing maintenance")
129
+ engine, ctx = _context(args.scope)
130
+ sub = Substrate(db_path, engine)
131
+ try:
132
+ sub.rebuild_fts(args.scope, ctx)
133
+ print(json.dumps({"status": "rebuilt", "scope": args.scope, "index": "fts"}))
134
+ return 0
135
+ finally:
136
+ sub.close()
137
+
138
+
139
+ def _run_serve(args) -> int:
140
+ import uvicorn
141
+ from weft.server import app
142
+ print(json.dumps({"status": "serving", "host": args.host, "port": args.port}))
143
+ uvicorn.run(app, host=args.host, port=args.port, log_level="info")
144
+ return 0
145
+
146
+
147
+ def main(argv: list[str] | None = None) -> int:
148
+ parser = _parser()
149
+ args = parser.parse_args(argv)
150
+ try:
151
+ if args.command == "ingest":
152
+ return _run_ingest(args)
153
+ if args.command == "rebuild-fts":
154
+ return _run_rebuild_fts(args)
155
+ if args.command == "serve":
156
+ return _run_serve(args)
157
+ return _run_recall(args)
158
+ except (OSError, RuntimeError, ValueError, ImportError, sqlite3.Error, SubstrateError) as exc:
159
+ print(f"weft: {exc}", file=sys.stderr)
160
+ return 1
161
+
162
+
163
+ def entrypoint() -> None:
164
+ raise SystemExit(main())
165
+
166
+
167
+ if __name__ == "__main__":
168
+ entrypoint()