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 +3 -0
- weft/adapters.py +238 -0
- weft/cli.py +168 -0
- weft/ingest.py +301 -0
- weft/knowledge.py +109 -0
- weft/logging_config.py +54 -0
- weft/models.py +99 -0
- weft/reasoning.py +336 -0
- weft/retrieval.py +158 -0
- weft/scope.py +352 -0
- weft/server.py +110 -0
- weft/server_common.py +10 -0
- weft/shadow.py +153 -0
- weft/substrate.py +1562 -0
- weft_memory-0.1.0a0.dist-info/METADATA +107 -0
- weft_memory-0.1.0a0.dist-info/RECORD +20 -0
- weft_memory-0.1.0a0.dist-info/WHEEL +5 -0
- weft_memory-0.1.0a0.dist-info/entry_points.txt +2 -0
- weft_memory-0.1.0a0.dist-info/licenses/LICENSE +661 -0
- weft_memory-0.1.0a0.dist-info/top_level.txt +1 -0
weft/__init__.py
ADDED
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()
|