memex-plugin 0.20.1__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.
- memex/__init__.py +47 -0
- memex/__main__.py +3 -0
- memex/ask.py +452 -0
- memex/auth.py +75 -0
- memex/cli.py +1164 -0
- memex/config.py +138 -0
- memex/credentials.py +122 -0
- memex/db_utils.py +192 -0
- memex/dreamer.py +793 -0
- memex/extract.py +605 -0
- memex/llm.py +45 -0
- memex/observations.py +810 -0
- memex/paths.py +141 -0
- memex/py.typed +1 -0
- memex/scripts/__init__.py +0 -0
- memex/scripts/backfill_has_memo.py +269 -0
- memex/scripts/backfill_tokens.py +199 -0
- memex/scripts/backfill_topic_tags.py +256 -0
- memex/scripts/batch_extract_observations.py +378 -0
- memex/scripts/check_tag_canonical.py +280 -0
- memex/scripts/crystallization_check.py +1030 -0
- memex/scripts/curation_audit.py +330 -0
- memex/scripts/date_utils.py +209 -0
- memex/scripts/discover_sessions.py +863 -0
- memex/scripts/embeddings.py +1932 -0
- memex/scripts/extract_observations.py +6 -0
- memex/scripts/frontmatter_audit.py +238 -0
- memex/scripts/graph_queries.py +705 -0
- memex/scripts/hybrid_search.py +1094 -0
- memex/scripts/index_rebuild.py +2082 -0
- memex/scripts/portable_plugin.py +249 -0
- memex/scripts/project_audit.py +272 -0
- memex/scripts/reconcile_orphans.py +497 -0
- memex/scripts/scrub.py +6 -0
- memex/scripts/search.py +561 -0
- memex/scripts/similarity_detection.py +262 -0
- memex/scripts/sync_auto_memory.py +704 -0
- memex/scripts/temporal_scan.py +328 -0
- memex/scripts/topic_resolve.py +216 -0
- memex/scripts/transcript_to_md.py +1210 -0
- memex/scripts/utils.py +887 -0
- memex/scripts/wikilink_filters.py +74 -0
- memex/scrub.py +444 -0
- memex/sidecars.py +1006 -0
- memex_plugin-0.20.1.dist-info/METADATA +364 -0
- memex_plugin-0.20.1.dist-info/RECORD +49 -0
- memex_plugin-0.20.1.dist-info/WHEEL +4 -0
- memex_plugin-0.20.1.dist-info/entry_points.txt +2 -0
- memex_plugin-0.20.1.dist-info/licenses/LICENSE +21 -0
memex/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Memex - Personal knowledge base for Claude Code sessions."""
|
|
2
|
+
import tomllib
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
UNKNOWN_VERSION = "0.0.0+unknown"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _pyproject_version(pyproject: Path) -> str | None:
|
|
9
|
+
"""Version from ``pyproject`` only when it really describes memex.
|
|
10
|
+
|
|
11
|
+
A vendored copy can sit under a host application's ``src/``; its
|
|
12
|
+
pyproject names the host (and may use dynamic versioning with no
|
|
13
|
+
``version`` key). Neither must be reported as memex's version.
|
|
14
|
+
"""
|
|
15
|
+
try:
|
|
16
|
+
with pyproject.open("rb") as f:
|
|
17
|
+
project = tomllib.load(f).get("project", {})
|
|
18
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
19
|
+
return None
|
|
20
|
+
if project.get("name") not in {"memex", "memex-plugin"}:
|
|
21
|
+
return None
|
|
22
|
+
version = project.get("version")
|
|
23
|
+
return version if isinstance(version, str) and version else None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _read_version() -> str:
|
|
27
|
+
"""Read live source metadata in checkouts, distribution metadata in wheels."""
|
|
28
|
+
source_dir = Path(__file__).resolve().parent.parent
|
|
29
|
+
pyproject = source_dir.parent / "pyproject.toml"
|
|
30
|
+
# Vaults and plugin caches preserve src/memex; prefer the live version
|
|
31
|
+
# there even if an editable install's distribution metadata is stale.
|
|
32
|
+
if source_dir.name == "src" and pyproject.is_file():
|
|
33
|
+
version = _pyproject_version(pyproject)
|
|
34
|
+
if version:
|
|
35
|
+
return version
|
|
36
|
+
|
|
37
|
+
# Installed wheels do not contain the source tree's pyproject.toml.
|
|
38
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
return version("memex-plugin")
|
|
42
|
+
except PackageNotFoundError:
|
|
43
|
+
# Vendored or copied without metadata: importing must still succeed.
|
|
44
|
+
return _pyproject_version(pyproject) or UNKNOWN_VERSION
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
__version__ = _read_version()
|
memex/__main__.py
ADDED
memex/ask.py
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import sqlite3
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import asdict, dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from memex.db_utils import connect_index
|
|
12
|
+
from memex.paths import get_index_path
|
|
13
|
+
|
|
14
|
+
from memex.observations import (
|
|
15
|
+
fetch_observations,
|
|
16
|
+
init_observation_schema,
|
|
17
|
+
search_observations_fts,
|
|
18
|
+
vector_search_observations,
|
|
19
|
+
)
|
|
20
|
+
from memex.scripts.wikilink_filters import strip_code_spans
|
|
21
|
+
|
|
22
|
+
STOP_WORDS = {
|
|
23
|
+
"why", "what", "how", "when", "where", "who", "which",
|
|
24
|
+
"did", "do", "does", "is", "was", "were", "are", "been",
|
|
25
|
+
"we", "you", "they", "our", "the", "a", "an",
|
|
26
|
+
"about", "with", "from", "for", "of", "in", "on", "to", "at",
|
|
27
|
+
"this", "that", "these", "those",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
_TOKENIZER = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _get_tokenizer():
|
|
34
|
+
global _TOKENIZER
|
|
35
|
+
if _TOKENIZER is None:
|
|
36
|
+
import tiktoken
|
|
37
|
+
_TOKENIZER = tiktoken.get_encoding("cl100k_base")
|
|
38
|
+
return _TOKENIZER
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(slots=True)
|
|
42
|
+
class AskResult:
|
|
43
|
+
path: str
|
|
44
|
+
title: str
|
|
45
|
+
project: str
|
|
46
|
+
date: str
|
|
47
|
+
score: float
|
|
48
|
+
match_type: str
|
|
49
|
+
content: str
|
|
50
|
+
outgoing_links: list[str] = field(default_factory=list)
|
|
51
|
+
backlink_count: int = 0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(slots=True)
|
|
55
|
+
class ObservationResult:
|
|
56
|
+
content: str
|
|
57
|
+
obs_type: str
|
|
58
|
+
source_memo: str
|
|
59
|
+
confidence: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(slots=True)
|
|
63
|
+
class AskResponse:
|
|
64
|
+
results: list[AskResult]
|
|
65
|
+
observations: list[ObservationResult]
|
|
66
|
+
query_info: dict[str, Any]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def extract_keywords(question: str) -> str:
|
|
70
|
+
# Alphanumeric runs cannot introduce FTS operators or punctuation. Keep
|
|
71
|
+
# short acronyms, and sanitize the fallback for queries such as "C++?".
|
|
72
|
+
words = re.findall(r"[^\W_]+", question.lower())
|
|
73
|
+
keywords = [word for word in words if word not in STOP_WORDS and len(word) >= 2]
|
|
74
|
+
return " OR ".join((keywords or words)[:6])
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def ask(
|
|
78
|
+
question: str,
|
|
79
|
+
index_path: Path,
|
|
80
|
+
vault_path: Path,
|
|
81
|
+
project: str | None = None,
|
|
82
|
+
depth: str = "quick",
|
|
83
|
+
max_content_tokens: int = 800,
|
|
84
|
+
) -> AskResponse:
|
|
85
|
+
limit = 10 if depth == "quick" else 20
|
|
86
|
+
return_count = 5 if depth == "quick" else 10
|
|
87
|
+
fts_query = extract_keywords(question)
|
|
88
|
+
vector_query = question
|
|
89
|
+
if not fts_query:
|
|
90
|
+
return AskResponse(
|
|
91
|
+
results=[],
|
|
92
|
+
observations=[],
|
|
93
|
+
query_info={
|
|
94
|
+
"fts_query": "",
|
|
95
|
+
"vector_query": vector_query,
|
|
96
|
+
"total_candidates": 0,
|
|
97
|
+
"results_returned": 0,
|
|
98
|
+
"gaps": _infer_gaps([], []),
|
|
99
|
+
},
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Pre-compute query embedding once for thorough mode (used by both doc + obs vector search)
|
|
103
|
+
query_embedding = None
|
|
104
|
+
if depth == "thorough":
|
|
105
|
+
pipeline = _build_pipeline()
|
|
106
|
+
if pipeline is not None and getattr(pipeline, "enabled", False):
|
|
107
|
+
query_embedding = pipeline.embed_query(question)
|
|
108
|
+
|
|
109
|
+
conn = connect_index(index_path)
|
|
110
|
+
try:
|
|
111
|
+
init_observation_schema(conn)
|
|
112
|
+
fts_rows = _fts_candidates(conn, fts_query, project=project, limit=limit)
|
|
113
|
+
if depth == "thorough" and query_embedding:
|
|
114
|
+
vector_rows = _vector_candidates(conn, query_embedding, project=project, limit=limit)
|
|
115
|
+
merged = _merge_candidates(fts_rows, vector_rows)
|
|
116
|
+
else:
|
|
117
|
+
merged = fts_rows
|
|
118
|
+
top_results = merged[:return_count]
|
|
119
|
+
|
|
120
|
+
results: list[AskResult] = []
|
|
121
|
+
for row in top_results:
|
|
122
|
+
rel_path = str(row["path"])
|
|
123
|
+
content = _read_and_truncate(vault_path, rel_path, max_content_tokens)
|
|
124
|
+
results.append(
|
|
125
|
+
AskResult(
|
|
126
|
+
path=rel_path,
|
|
127
|
+
title=str(row["title"]),
|
|
128
|
+
project=str(row["project"] or ""),
|
|
129
|
+
date=str(row["date"] or ""),
|
|
130
|
+
score=float(row["score"]),
|
|
131
|
+
match_type=str(row["match_type"]),
|
|
132
|
+
content=content,
|
|
133
|
+
outgoing_links=_extract_wikilinks(content),
|
|
134
|
+
backlink_count=_get_backlink_count(conn, rel_path),
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
observations = _observation_results(
|
|
139
|
+
conn,
|
|
140
|
+
fts_query=fts_query,
|
|
141
|
+
query_embedding=query_embedding,
|
|
142
|
+
project=project,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
query_info = {
|
|
146
|
+
"fts_query": fts_query,
|
|
147
|
+
"vector_query": vector_query,
|
|
148
|
+
"total_candidates": len(merged),
|
|
149
|
+
"results_returned": len(results),
|
|
150
|
+
"gaps": _infer_gaps(results, observations),
|
|
151
|
+
}
|
|
152
|
+
return AskResponse(results=results, observations=observations, query_info=query_info)
|
|
153
|
+
finally:
|
|
154
|
+
conn.close()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _fts_candidates(
|
|
158
|
+
conn: sqlite3.Connection,
|
|
159
|
+
query: str,
|
|
160
|
+
*,
|
|
161
|
+
project: str | None,
|
|
162
|
+
limit: int,
|
|
163
|
+
) -> list[dict[str, Any]]:
|
|
164
|
+
sql = """
|
|
165
|
+
SELECT path, title, project, date, bm25(fts_content) AS score
|
|
166
|
+
FROM fts_content
|
|
167
|
+
WHERE fts_content MATCH ?
|
|
168
|
+
"""
|
|
169
|
+
params: list[object] = [query]
|
|
170
|
+
if project:
|
|
171
|
+
sql += " AND project = ?"
|
|
172
|
+
params.append(project)
|
|
173
|
+
sql += " ORDER BY score LIMIT ?"
|
|
174
|
+
params.append(limit)
|
|
175
|
+
rows = conn.execute(sql, params).fetchall()
|
|
176
|
+
results: list[dict[str, Any]] = []
|
|
177
|
+
for rank, row in enumerate(rows, start=1):
|
|
178
|
+
results.append(
|
|
179
|
+
{
|
|
180
|
+
"path": row[0],
|
|
181
|
+
"title": row[1],
|
|
182
|
+
"project": row[2],
|
|
183
|
+
"date": row[3],
|
|
184
|
+
"score": float(row[4]),
|
|
185
|
+
"rank": rank,
|
|
186
|
+
"match_type": "fts",
|
|
187
|
+
"source": "fts",
|
|
188
|
+
}
|
|
189
|
+
)
|
|
190
|
+
return results
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _vector_candidates(
|
|
194
|
+
conn: sqlite3.Connection,
|
|
195
|
+
query_embedding,
|
|
196
|
+
*,
|
|
197
|
+
project: str | None,
|
|
198
|
+
limit: int,
|
|
199
|
+
) -> list[dict[str, Any]]:
|
|
200
|
+
# Align the native-dim query vector to vec_chunks' stored dimension so
|
|
201
|
+
# search still works during a config-flip-before-migrate window (and at
|
|
202
|
+
# any index_dimensions setting). Lazy import avoids a circular dependency.
|
|
203
|
+
from memex.scripts.embeddings import match_query_dim
|
|
204
|
+
query_embedding = match_query_dim(conn, "vec_chunks", query_embedding)
|
|
205
|
+
|
|
206
|
+
# Run the KNN FIRST (vec_chunks → chunks by rowid only), then enrich with
|
|
207
|
+
# fts_content metadata in a single batched lookup. Joining fts_content
|
|
208
|
+
# INSIDE the KNN query makes SQLite scan fts_content as the OUTER loop and
|
|
209
|
+
# re-run the vec match once per doc — O(docs × KNN), which hangs for minutes
|
|
210
|
+
# on a real-size index. fts_content has no path index (FTS5), so it must
|
|
211
|
+
# never drive the join. (Mirrors hybrid_search.vector_search.)
|
|
212
|
+
knn_sql = (
|
|
213
|
+
"SELECT c.doc_path, v.distance "
|
|
214
|
+
"FROM vec_chunks v JOIN chunks c ON c.id = v.rowid "
|
|
215
|
+
"WHERE v.embedding MATCH ? AND k = ?"
|
|
216
|
+
)
|
|
217
|
+
knn: list | None = None
|
|
218
|
+
if project:
|
|
219
|
+
# Push the project filter into the KNN via the v0.15.0 metadata column.
|
|
220
|
+
try:
|
|
221
|
+
knn = conn.execute(
|
|
222
|
+
knn_sql + " AND v.doc_project = ?",
|
|
223
|
+
[query_embedding, limit, project],
|
|
224
|
+
).fetchall()
|
|
225
|
+
except sqlite3.OperationalError:
|
|
226
|
+
knn = None # pre-metadata index → bare KNN + post-filter below
|
|
227
|
+
if knn is None:
|
|
228
|
+
try:
|
|
229
|
+
knn = conn.execute(knn_sql, [query_embedding, limit]).fetchall()
|
|
230
|
+
except sqlite3.OperationalError:
|
|
231
|
+
return []
|
|
232
|
+
if not knn:
|
|
233
|
+
return []
|
|
234
|
+
|
|
235
|
+
# Best (smallest) distance per doc.
|
|
236
|
+
dist_by_path: dict[str, float] = {}
|
|
237
|
+
for doc_path, distance in knn:
|
|
238
|
+
if doc_path not in dist_by_path or distance < dist_by_path[doc_path]:
|
|
239
|
+
dist_by_path[doc_path] = distance
|
|
240
|
+
|
|
241
|
+
placeholders = ",".join("?" for _ in dist_by_path)
|
|
242
|
+
meta_rows = conn.execute(
|
|
243
|
+
f"SELECT path, title, project, date FROM fts_content WHERE path IN ({placeholders})",
|
|
244
|
+
list(dist_by_path.keys()),
|
|
245
|
+
).fetchall()
|
|
246
|
+
|
|
247
|
+
results: list[dict[str, Any]] = []
|
|
248
|
+
for path, title, proj, date in meta_rows:
|
|
249
|
+
if project and proj != project:
|
|
250
|
+
continue # safety net for the bare-KNN fallback path
|
|
251
|
+
results.append({
|
|
252
|
+
"path": path,
|
|
253
|
+
"title": title,
|
|
254
|
+
"project": proj,
|
|
255
|
+
"date": date,
|
|
256
|
+
"score": max(0.0, 1.0 - float(dist_by_path[path])),
|
|
257
|
+
})
|
|
258
|
+
# RRF must preserve nearest-neighbor order even when the displayed score
|
|
259
|
+
# clamps multiple L2 distances greater than or equal to 1 to zero.
|
|
260
|
+
results.sort(key=lambda item: dist_by_path[item["path"]])
|
|
261
|
+
return [
|
|
262
|
+
{**row, "rank": rank, "source": "vector"}
|
|
263
|
+
for rank, row in enumerate(results[:limit], start=1)
|
|
264
|
+
]
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _merge_candidates(
|
|
268
|
+
fts_rows: list[dict[str, Any]],
|
|
269
|
+
vector_rows: list[dict[str, Any]],
|
|
270
|
+
) -> list[dict[str, Any]]:
|
|
271
|
+
scores: dict[str, dict[str, Any]] = {}
|
|
272
|
+
|
|
273
|
+
for row in fts_rows:
|
|
274
|
+
entry = scores.setdefault(
|
|
275
|
+
row["path"],
|
|
276
|
+
{
|
|
277
|
+
"path": row["path"],
|
|
278
|
+
"title": row["title"],
|
|
279
|
+
"project": row["project"],
|
|
280
|
+
"date": row["date"],
|
|
281
|
+
"score": 0.0,
|
|
282
|
+
"sources": set(),
|
|
283
|
+
},
|
|
284
|
+
)
|
|
285
|
+
entry["score"] += 1.0 / (60 + row["rank"])
|
|
286
|
+
entry["sources"].add("fts")
|
|
287
|
+
|
|
288
|
+
for row in vector_rows:
|
|
289
|
+
entry = scores.setdefault(
|
|
290
|
+
row["path"],
|
|
291
|
+
{
|
|
292
|
+
"path": row["path"],
|
|
293
|
+
"title": row["title"],
|
|
294
|
+
"project": row["project"],
|
|
295
|
+
"date": row["date"],
|
|
296
|
+
"score": 0.0,
|
|
297
|
+
"sources": set(),
|
|
298
|
+
},
|
|
299
|
+
)
|
|
300
|
+
entry["score"] += 1.0 / (60 + row["rank"])
|
|
301
|
+
entry["sources"].add("vector")
|
|
302
|
+
|
|
303
|
+
merged = []
|
|
304
|
+
for entry in scores.values():
|
|
305
|
+
sources = entry.pop("sources")
|
|
306
|
+
if sources == {"fts", "vector"}:
|
|
307
|
+
entry["match_type"] = "hybrid"
|
|
308
|
+
elif sources == {"vector"}:
|
|
309
|
+
entry["match_type"] = "vector"
|
|
310
|
+
else:
|
|
311
|
+
entry["match_type"] = "fts"
|
|
312
|
+
merged.append(entry)
|
|
313
|
+
merged.sort(key=lambda item: item["score"], reverse=True)
|
|
314
|
+
return merged
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _observation_results(
|
|
318
|
+
conn: sqlite3.Connection,
|
|
319
|
+
*,
|
|
320
|
+
fts_query: str,
|
|
321
|
+
query_embedding=None,
|
|
322
|
+
project: str | None,
|
|
323
|
+
) -> list[ObservationResult]:
|
|
324
|
+
fts_matches = search_observations_fts(conn, fts_query, project=project, limit=10)
|
|
325
|
+
ranked: dict[int, tuple[float, ObservationResult]] = {}
|
|
326
|
+
|
|
327
|
+
for rank, match in enumerate(fts_matches, start=1):
|
|
328
|
+
ranked[match.id] = (
|
|
329
|
+
1.0 / (60 + rank),
|
|
330
|
+
ObservationResult(
|
|
331
|
+
content=match.content,
|
|
332
|
+
obs_type=match.obs_type,
|
|
333
|
+
source_memo=match.doc_path,
|
|
334
|
+
confidence=match.confidence,
|
|
335
|
+
),
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
if query_embedding is not None:
|
|
339
|
+
for rank, (match, distance) in enumerate(
|
|
340
|
+
vector_search_observations(conn, query_embedding, project=project, limit=10),
|
|
341
|
+
start=1,
|
|
342
|
+
):
|
|
343
|
+
score = 1.0 / (60 + rank) + max(0.0, 1.0 - float(distance))
|
|
344
|
+
existing = ranked.get(match.id)
|
|
345
|
+
result = ObservationResult(
|
|
346
|
+
content=match.content,
|
|
347
|
+
obs_type=match.obs_type,
|
|
348
|
+
source_memo=match.doc_path,
|
|
349
|
+
confidence=match.confidence,
|
|
350
|
+
)
|
|
351
|
+
if existing is None or score > existing[0]:
|
|
352
|
+
ranked[match.id] = (score, result)
|
|
353
|
+
|
|
354
|
+
ordered = [item[1] for item in sorted(ranked.values(), key=lambda value: value[0], reverse=True)]
|
|
355
|
+
return ordered[:10]
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _read_and_truncate(vault_path: Path, rel_path: str, max_tokens: int) -> str:
|
|
359
|
+
try:
|
|
360
|
+
content = (vault_path / rel_path).read_text()
|
|
361
|
+
except (FileNotFoundError, OSError):
|
|
362
|
+
return "[File not found]"
|
|
363
|
+
if content.startswith("---"):
|
|
364
|
+
end = content.find("\n---", 3)
|
|
365
|
+
if end != -1:
|
|
366
|
+
content = content[end + 4 :].strip()
|
|
367
|
+
tokens = _get_tokenizer().encode(content)
|
|
368
|
+
if len(tokens) <= max_tokens:
|
|
369
|
+
return content
|
|
370
|
+
return _get_tokenizer().decode(tokens[:max_tokens]) + "\n\n[Truncated]"
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _extract_wikilinks(content: str) -> list[str]:
|
|
374
|
+
# Strip code spans so fenced examples / inline code don't seed graph-hop
|
|
375
|
+
# expansion with phantom links (shared with the indexer + crystallization
|
|
376
|
+
# checker via wikilink_filters).
|
|
377
|
+
return re.findall(r"\[\[([^\]]+)\]\]", strip_code_spans(content))
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _get_backlink_count(conn: sqlite3.Connection, path: str) -> int:
|
|
381
|
+
try:
|
|
382
|
+
row = conn.execute(
|
|
383
|
+
"SELECT count(*) FROM wikilinks WHERE target_path = ?",
|
|
384
|
+
(path,),
|
|
385
|
+
).fetchone()
|
|
386
|
+
return int(row[0]) if row else 0
|
|
387
|
+
except sqlite3.OperationalError:
|
|
388
|
+
return 0
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _infer_gaps(
|
|
392
|
+
results: list[AskResult],
|
|
393
|
+
observations: list[ObservationResult],
|
|
394
|
+
) -> list[str]:
|
|
395
|
+
gaps: list[str] = []
|
|
396
|
+
if not results:
|
|
397
|
+
gaps.append("No matching memos or notes were found.")
|
|
398
|
+
if results and not observations:
|
|
399
|
+
gaps.append("Relevant documents were found, but no atomic observations matched directly.")
|
|
400
|
+
return gaps
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _build_pipeline():
|
|
404
|
+
try:
|
|
405
|
+
from memex.scripts.embeddings import EmbeddingPipeline
|
|
406
|
+
|
|
407
|
+
pipeline = EmbeddingPipeline()
|
|
408
|
+
if pipeline.enabled:
|
|
409
|
+
return pipeline
|
|
410
|
+
except Exception:
|
|
411
|
+
return None
|
|
412
|
+
return None
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def response_to_dict(response: AskResponse) -> dict[str, Any]:
|
|
416
|
+
return {
|
|
417
|
+
"results": [asdict(item) for item in response.results],
|
|
418
|
+
"observations": [asdict(item) for item in response.observations],
|
|
419
|
+
"query_info": response.query_info,
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def main() -> None:
|
|
424
|
+
import argparse
|
|
425
|
+
|
|
426
|
+
parser = argparse.ArgumentParser(description="Ask a deep question of the memex index")
|
|
427
|
+
parser.add_argument("question")
|
|
428
|
+
parser.add_argument("--index", type=Path, help="Path to index (auto-detected)")
|
|
429
|
+
parser.add_argument("--vault", type=Path, help="Path to vault (auto-detected)")
|
|
430
|
+
parser.add_argument("--project")
|
|
431
|
+
parser.add_argument("--depth", choices=["quick", "thorough"], default="quick")
|
|
432
|
+
args = parser.parse_args()
|
|
433
|
+
|
|
434
|
+
# Auto-detect paths if not provided
|
|
435
|
+
if args.vault is None:
|
|
436
|
+
from memex.paths import get_memex_path
|
|
437
|
+
args.vault = get_memex_path()
|
|
438
|
+
if args.index is None:
|
|
439
|
+
args.index = get_index_path(args.vault)
|
|
440
|
+
|
|
441
|
+
response = ask(
|
|
442
|
+
question=args.question,
|
|
443
|
+
index_path=args.index,
|
|
444
|
+
vault_path=args.vault,
|
|
445
|
+
project=args.project,
|
|
446
|
+
depth=args.depth,
|
|
447
|
+
)
|
|
448
|
+
print(json.dumps(response_to_dict(response), indent=2))
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
if __name__ == "__main__":
|
|
452
|
+
main()
|
memex/auth.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Local credential setup commands; no authentication or API calls on their own."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(help="Set up Gemini credentials for this installation.", no_args_is_help=True)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@app.command("set-key")
|
|
14
|
+
def set_key(
|
|
15
|
+
from_env: bool = typer.Option(False, "--from-env", help="Save the current environment key instead of prompting."),
|
|
16
|
+
) -> None:
|
|
17
|
+
"""Save a Gemini key locally for automatic loading on future runs."""
|
|
18
|
+
from memex.config import get_settings
|
|
19
|
+
from memex.credentials import gemini_key_from_env, gemini_key_path, save_gemini_key
|
|
20
|
+
|
|
21
|
+
typer.echo(f"Stores an unencrypted key in {gemini_key_path()} (owner-only permissions).")
|
|
22
|
+
try:
|
|
23
|
+
if from_env:
|
|
24
|
+
credential = gemini_key_from_env(get_settings().embeddings.api_key_env)
|
|
25
|
+
if credential is None:
|
|
26
|
+
raise ValueError("No Gemini key found in the environment; nothing was saved.")
|
|
27
|
+
value = credential.value
|
|
28
|
+
elif sys.stdin.isatty():
|
|
29
|
+
value = typer.prompt("Gemini API key", hide_input=True)
|
|
30
|
+
else:
|
|
31
|
+
# Piped input: read one line directly. getpass's non-TTY fallback
|
|
32
|
+
# would print a "may be echoed" warning and a traceback fragment.
|
|
33
|
+
value = sys.stdin.readline()
|
|
34
|
+
if not value:
|
|
35
|
+
raise ValueError("No key provided on stdin; nothing was saved.")
|
|
36
|
+
save_gemini_key(value)
|
|
37
|
+
except (ValueError, OSError) as exc:
|
|
38
|
+
typer.echo(f"Error: {exc}", err=True)
|
|
39
|
+
raise typer.Exit(1) from exc
|
|
40
|
+
typer.echo("Saved. Memex will load this key automatically when no environment key is set.")
|
|
41
|
+
typer.echo("The key has not been sent to Gemini or validated with the API.")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.command("status")
|
|
45
|
+
def status() -> None:
|
|
46
|
+
"""Show the credential source without printing keys or contacting Gemini."""
|
|
47
|
+
from memex.config import get_settings
|
|
48
|
+
from memex.credentials import missing_gemini_key_help, resolve_gemini_key
|
|
49
|
+
|
|
50
|
+
settings = get_settings()
|
|
51
|
+
typer.echo(f"Embedding provider: {settings.embeddings.provider}")
|
|
52
|
+
typer.echo(f"Embeddings enabled: {settings.embeddings.enabled}")
|
|
53
|
+
try:
|
|
54
|
+
credential = resolve_gemini_key(settings.embeddings.api_key_env)
|
|
55
|
+
except ValueError as exc:
|
|
56
|
+
typer.echo(f"Error: {exc}", err=True)
|
|
57
|
+
raise typer.Exit(1) from exc
|
|
58
|
+
if credential is None:
|
|
59
|
+
typer.echo(missing_gemini_key_help(settings.embeddings.api_key_env))
|
|
60
|
+
raise typer.Exit(1)
|
|
61
|
+
typer.echo(f"Gemini credential source: {credential.source}")
|
|
62
|
+
typer.echo("Local configuration only; API validity has not been checked.")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.command("clear-key")
|
|
66
|
+
def clear_key() -> None:
|
|
67
|
+
"""Remove the locally saved key. Environment variables still take precedence."""
|
|
68
|
+
from memex.credentials import clear_gemini_key
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
removed = clear_gemini_key()
|
|
72
|
+
except (OSError, ValueError) as exc:
|
|
73
|
+
typer.echo(f"Error: {exc}", err=True)
|
|
74
|
+
raise typer.Exit(1) from exc
|
|
75
|
+
typer.echo("Removed the saved Gemini key." if removed else "No saved Gemini key to remove.")
|