agentdir-cli 0.7.5__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.
agentdir/memory.py ADDED
@@ -0,0 +1,1274 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import importlib.util
5
+ import json
6
+ import math
7
+ import re
8
+ import sqlite3
9
+ from datetime import UTC, datetime
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from .store import AgentDirError, require_root
14
+
15
+ DEFAULT_VECTOR_DIM = 1024
16
+ DEFAULT_MIN_SCORE = 0.05
17
+ SESSION_SUMMARY_PENALTY = 0.75
18
+ DEFAULT_PASSAGE_TOKEN_LIMIT = 160
19
+ DEFAULT_PASSAGE_TOKEN_OVERLAP = 32
20
+ SOURCE_MESSAGE = "message"
21
+ SOURCE_SESSION_SUMMARY = "session_summary"
22
+ RETRIEVAL_HYBRID = "hybrid"
23
+ RETRIEVAL_DOCUMENT = "document"
24
+ RETRIEVAL_SEMANTIC = "semantic"
25
+ RETRIEVAL_MODES = (RETRIEVAL_HYBRID, RETRIEVAL_DOCUMENT, RETRIEVAL_SEMANTIC)
26
+ MEMORY_CONFIG_FILE = "memory-backends.json"
27
+ DEFAULT_FASTEMBED_MODEL = "BAAI/bge-small-en-v1.5"
28
+ TOKEN_RE = re.compile(r"[a-z0-9][a-z0-9_+#.:-]{1,}", re.IGNORECASE)
29
+ STOPWORDS = {
30
+ "about",
31
+ "after",
32
+ "again",
33
+ "also",
34
+ "and",
35
+ "are",
36
+ "because",
37
+ "before",
38
+ "but",
39
+ "can",
40
+ "could",
41
+ "for",
42
+ "from",
43
+ "has",
44
+ "have",
45
+ "into",
46
+ "not",
47
+ "that",
48
+ "the",
49
+ "then",
50
+ "this",
51
+ "was",
52
+ "were",
53
+ "with",
54
+ "would",
55
+ }
56
+
57
+
58
+ def now_iso() -> str:
59
+ return datetime.now(UTC).isoformat()
60
+
61
+
62
+ def memory_schema_sql() -> str:
63
+ return """
64
+ create table if not exists memory_documents (
65
+ id integer primary key,
66
+ source_kind text not null,
67
+ source_id text not null unique,
68
+ message_rowid integer references messages(id) on delete cascade,
69
+ message_id text,
70
+ session_id text,
71
+ event_type text,
72
+ subject text,
73
+ from_actor text,
74
+ to_actor text,
75
+ task_id text,
76
+ git_head text,
77
+ workspace text,
78
+ tool text,
79
+ tool_exit_code integer,
80
+ date_header text,
81
+ date_utc text,
82
+ file_path text,
83
+ body_text text not null,
84
+ vector_dim integer not null,
85
+ vector_json text not null,
86
+ text_sha256 text not null,
87
+ token_count integer not null,
88
+ indexed_at text not null
89
+ );
90
+ create index if not exists memory_documents_source_idx on memory_documents(source_kind, source_id);
91
+ create index if not exists memory_documents_session_idx on memory_documents(session_id);
92
+ create index if not exists memory_documents_event_type_idx on memory_documents(event_type);
93
+ create table if not exists memory_passages (
94
+ id integer primary key,
95
+ memory_document_id integer not null references memory_documents(id) on delete cascade,
96
+ source_kind text not null,
97
+ source_id text not null,
98
+ message_rowid integer references messages(id) on delete cascade,
99
+ session_id text,
100
+ event_type text,
101
+ tool text,
102
+ git_head text,
103
+ workspace text,
104
+ date_utc text,
105
+ ordinal integer not null,
106
+ body_text text not null,
107
+ token_count integer not null,
108
+ vector_dim integer not null,
109
+ vector_json text not null,
110
+ text_sha256 text not null
111
+ );
112
+ create index if not exists memory_passages_document_idx on memory_passages(memory_document_id);
113
+ create index if not exists memory_passages_source_idx on memory_passages(source_kind, source_id);
114
+ create table if not exists memory_terms (
115
+ term text not null,
116
+ passage_id integer not null references memory_passages(id) on delete cascade,
117
+ tf integer not null,
118
+ field_mask integer not null,
119
+ primary key(term, passage_id)
120
+ );
121
+ create index if not exists memory_terms_passage_idx on memory_terms(passage_id);
122
+ create table if not exists semantic_embeddings (
123
+ source_id text not null,
124
+ model text not null,
125
+ text_sha256 text not null,
126
+ dimensions integer not null,
127
+ vector_json text not null,
128
+ indexed_at text not null,
129
+ primary key(source_id, model)
130
+ );
131
+ """
132
+
133
+
134
+ def build_memory_text(
135
+ *,
136
+ message_id: str | None = None,
137
+ session_id: str | None = None,
138
+ event_type: str | None = None,
139
+ subject: str | None = None,
140
+ tool: str | None = None,
141
+ workspace: str | None = None,
142
+ git_head: str | None = None,
143
+ body_text: str | None = None,
144
+ ) -> str:
145
+ parts = [
146
+ _labeled("message", message_id),
147
+ _labeled("session", session_id),
148
+ _labeled("event", event_type),
149
+ _labeled("subject", subject),
150
+ _labeled("tool", tool),
151
+ _labeled("workspace", workspace),
152
+ _labeled("git", git_head),
153
+ body_text or "",
154
+ ]
155
+ return "\n".join(part for part in parts if part.strip())
156
+
157
+
158
+ def index_memory_document(
159
+ conn: sqlite3.Connection,
160
+ *,
161
+ message_rowid: int,
162
+ message_id: str | None,
163
+ session_id: str | None,
164
+ event_type: str | None,
165
+ subject: str | None,
166
+ from_actor: str | None,
167
+ to_actor: str | None,
168
+ task_id: str | None,
169
+ tool: str | None,
170
+ tool_exit_code: int | None,
171
+ workspace: str | None,
172
+ git_head: str | None,
173
+ date_header: str | None,
174
+ date_utc: str | None,
175
+ file_path: str,
176
+ body_text: str | None,
177
+ indexed_at: str | None = None,
178
+ ) -> None:
179
+ text = build_memory_text(
180
+ message_id=message_id,
181
+ session_id=session_id,
182
+ event_type=event_type,
183
+ subject=subject,
184
+ tool=tool,
185
+ workspace=workspace,
186
+ git_head=git_head,
187
+ body_text=body_text,
188
+ )
189
+ _insert_memory_document(
190
+ conn,
191
+ source_kind=SOURCE_MESSAGE,
192
+ source_id=f"message:{file_path}",
193
+ message_rowid=message_rowid,
194
+ message_id=message_id,
195
+ session_id=session_id,
196
+ event_type=event_type,
197
+ subject=subject,
198
+ from_actor=from_actor,
199
+ to_actor=to_actor,
200
+ task_id=task_id,
201
+ git_head=git_head,
202
+ workspace=workspace,
203
+ tool=tool,
204
+ tool_exit_code=tool_exit_code,
205
+ date_header=date_header,
206
+ date_utc=date_utc,
207
+ file_path=file_path,
208
+ body_text=body_text or "",
209
+ memory_text=text,
210
+ indexed_at=indexed_at,
211
+ )
212
+
213
+
214
+ def index_session_summaries(conn: sqlite3.Connection) -> None:
215
+ sessions = conn.execute(
216
+ """
217
+ select session_id
218
+ from messages
219
+ where session_id is not null
220
+ group by session_id
221
+ order by max(coalesce(date_utc, indexed_at)) desc, session_id
222
+ """
223
+ ).fetchall()
224
+ for session in sessions:
225
+ session_id = session["session_id"]
226
+ rows = conn.execute(
227
+ """
228
+ select *
229
+ from messages
230
+ where session_id = ?
231
+ order by coalesce(date_utc, indexed_at), coalesce(created_ns, 0), file_path, id
232
+ """,
233
+ (session_id,),
234
+ ).fetchall()
235
+ if not rows:
236
+ continue
237
+ summary = session_memory_summary(session_id, [dict(row) for row in rows])
238
+ last = rows[-1]
239
+ indexed_at = now_iso()
240
+ _insert_memory_document(
241
+ conn,
242
+ source_kind=SOURCE_SESSION_SUMMARY,
243
+ source_id=f"session:{session_id}:summary",
244
+ message_rowid=None,
245
+ message_id=None,
246
+ session_id=session_id,
247
+ event_type="summary.compacted",
248
+ subject=f"session summary: {session_id}",
249
+ from_actor="agentdir@agentdir.local",
250
+ to_actor=f"{session_id}@agentdir.local",
251
+ task_id=None,
252
+ git_head=last["git_head"],
253
+ workspace=last["workspace"],
254
+ tool=None,
255
+ tool_exit_code=None,
256
+ date_header=last["date_header"],
257
+ date_utc=last["date_utc"],
258
+ file_path=f"sessions/{session_id}/derived-summary",
259
+ body_text=summary,
260
+ memory_text=summary,
261
+ indexed_at=indexed_at,
262
+ )
263
+
264
+
265
+ def session_memory_summary(session_id: str, rows: list[dict[str, Any]]) -> str:
266
+ counts: dict[str, int] = {}
267
+ tools: list[str] = []
268
+ failures: list[str] = []
269
+ excerpts: list[str] = []
270
+ for row in rows:
271
+ event_type = row.get("event_type") or "unknown"
272
+ counts[event_type] = counts.get(event_type, 0) + 1
273
+ if event_type == "tool.result":
274
+ tool = row.get("tool") or "tool"
275
+ exit_code = row.get("tool_exit_code")
276
+ tools.append(f"{tool} exit={exit_code}")
277
+ if exit_code not in (None, 0):
278
+ failures.append(f"{tool} exit={exit_code}")
279
+ body = _excerpt(row.get("body_text") or "", 180)
280
+ if body and len(excerpts) < 8:
281
+ excerpts.append(f"- {event_type}: {body}")
282
+
283
+ lines = [
284
+ f"Session summary: {session_id}",
285
+ f"Events: {len(rows)}",
286
+ "Event counts: " + ", ".join(f"{key}={counts[key]}" for key in sorted(counts)),
287
+ ]
288
+ if tools:
289
+ lines.append("Tool results: " + "; ".join(tools[:12]))
290
+ if failures:
291
+ lines.append("Failures: " + "; ".join(failures[:12]))
292
+ if excerpts:
293
+ lines.append("Key records:")
294
+ lines.extend(excerpts)
295
+ return "\n".join(lines)
296
+
297
+
298
+ def _insert_memory_document(
299
+ conn: sqlite3.Connection,
300
+ *,
301
+ source_kind: str,
302
+ source_id: str,
303
+ message_rowid: int | None,
304
+ message_id: str | None,
305
+ session_id: str | None,
306
+ event_type: str | None,
307
+ subject: str | None,
308
+ from_actor: str | None,
309
+ to_actor: str | None,
310
+ task_id: str | None,
311
+ git_head: str | None,
312
+ workspace: str | None,
313
+ tool: str | None,
314
+ tool_exit_code: int | None,
315
+ date_header: str | None,
316
+ date_utc: str | None,
317
+ file_path: str,
318
+ body_text: str,
319
+ memory_text: str,
320
+ indexed_at: str | None = None,
321
+ ) -> None:
322
+ text = memory_text.strip()
323
+ vector, token_count = vectorize(text)
324
+ if not vector:
325
+ return
326
+ cursor = conn.execute(
327
+ """
328
+ insert or replace into memory_documents(
329
+ source_kind, source_id, message_rowid, message_id, session_id,
330
+ event_type, subject, from_actor, to_actor, task_id, git_head,
331
+ workspace, tool, tool_exit_code, date_header, date_utc, file_path, body_text,
332
+ vector_dim, vector_json, text_sha256, token_count, indexed_at
333
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
334
+ """,
335
+ (
336
+ source_kind,
337
+ source_id,
338
+ message_rowid,
339
+ message_id,
340
+ session_id,
341
+ event_type,
342
+ subject,
343
+ from_actor,
344
+ to_actor,
345
+ task_id,
346
+ git_head,
347
+ workspace,
348
+ tool,
349
+ tool_exit_code,
350
+ date_header,
351
+ date_utc,
352
+ file_path,
353
+ body_text,
354
+ DEFAULT_VECTOR_DIM,
355
+ serialize_vector(vector),
356
+ hashlib.sha256(text.encode("utf-8")).hexdigest(),
357
+ token_count,
358
+ indexed_at or now_iso(),
359
+ ),
360
+ )
361
+ document_id = int(cursor.lastrowid)
362
+ _index_memory_passages(
363
+ conn,
364
+ memory_document_id=document_id,
365
+ source_kind=source_kind,
366
+ source_id=source_id,
367
+ message_rowid=message_rowid,
368
+ session_id=session_id,
369
+ event_type=event_type,
370
+ tool=tool,
371
+ git_head=git_head,
372
+ workspace=workspace,
373
+ date_utc=date_utc,
374
+ memory_text=text,
375
+ )
376
+
377
+
378
+ def vectorize(text: str, *, dimensions: int = DEFAULT_VECTOR_DIM) -> tuple[dict[int, float], int]:
379
+ tokens = _tokens(text)
380
+ weights: dict[int, float] = {}
381
+ for token in tokens:
382
+ _add_feature(weights, token, 1.0, dimensions)
383
+ if len(token) >= 5:
384
+ for index in range(len(token) - 2):
385
+ _add_feature(weights, f"tri:{token[index:index + 3]}", 0.25, dimensions)
386
+ for first, second in zip(tokens, tokens[1:]):
387
+ _add_feature(weights, f"bi:{first} {second}", 1.35, dimensions)
388
+
389
+ norm = math.sqrt(sum(value * value for value in weights.values()))
390
+ if norm == 0:
391
+ return {}, 0
392
+ return {index: value / norm for index, value in weights.items()}, len(tokens)
393
+
394
+
395
+ def serialize_vector(vector: dict[int, float]) -> str:
396
+ return json.dumps({str(index): value for index, value in sorted(vector.items())}, sort_keys=True)
397
+
398
+
399
+ def deserialize_vector(value: str) -> dict[int, float]:
400
+ raw = json.loads(value)
401
+ return {int(index): float(weight) for index, weight in raw.items()}
402
+
403
+
404
+ def cosine_similarity(left: dict[int, float], right: dict[int, float]) -> float:
405
+ if len(left) > len(right):
406
+ left, right = right, left
407
+ return sum(weight * right.get(index, 0.0) for index, weight in left.items())
408
+
409
+
410
+ def search_memory(
411
+ root: str | Path,
412
+ query: str,
413
+ *,
414
+ session_id: str | None = None,
415
+ event_type: str | None = None,
416
+ actor: str | None = None,
417
+ task_id: str | None = None,
418
+ tool: str | None = None,
419
+ git_head: str | None = None,
420
+ workspace: str | None = None,
421
+ since: str | None = None,
422
+ until: str | None = None,
423
+ limit: int = 10,
424
+ min_score: float = DEFAULT_MIN_SCORE,
425
+ retrieval_mode: str = RETRIEVAL_HYBRID,
426
+ ) -> list[dict[str, Any]]:
427
+ if retrieval_mode not in RETRIEVAL_MODES:
428
+ raise AgentDirError(
429
+ f"Unknown retrieval mode {retrieval_mode!r}; expected one of {', '.join(RETRIEVAL_MODES)}"
430
+ )
431
+ query_vector, token_count = vectorize(query)
432
+ if token_count == 0 or not query_vector:
433
+ raise AgentDirError("Memory search query must contain searchable text")
434
+
435
+ if retrieval_mode == RETRIEVAL_HYBRID:
436
+ hits = _search_memory_hybrid(
437
+ root,
438
+ query,
439
+ query_vector=query_vector,
440
+ session_id=session_id,
441
+ event_type=event_type,
442
+ actor=actor,
443
+ task_id=task_id,
444
+ tool=tool,
445
+ git_head=git_head,
446
+ workspace=workspace,
447
+ since=since,
448
+ until=until,
449
+ limit=limit,
450
+ min_score=min_score,
451
+ )
452
+ if hits:
453
+ return hits
454
+
455
+ if retrieval_mode == RETRIEVAL_SEMANTIC:
456
+ return _search_memory_semantic(
457
+ root,
458
+ query,
459
+ session_id=session_id,
460
+ event_type=event_type,
461
+ actor=actor,
462
+ task_id=task_id,
463
+ tool=tool,
464
+ git_head=git_head,
465
+ workspace=workspace,
466
+ since=since,
467
+ until=until,
468
+ limit=limit,
469
+ min_score=min_score,
470
+ )
471
+
472
+ return _search_memory_documents(
473
+ root,
474
+ query_vector=query_vector,
475
+ session_id=session_id,
476
+ event_type=event_type,
477
+ actor=actor,
478
+ task_id=task_id,
479
+ tool=tool,
480
+ git_head=git_head,
481
+ workspace=workspace,
482
+ since=since,
483
+ until=until,
484
+ limit=limit,
485
+ min_score=min_score,
486
+ )
487
+
488
+
489
+ def _search_memory_documents(
490
+ root: str | Path,
491
+ *,
492
+ query_vector: dict[int, float],
493
+ session_id: str | None = None,
494
+ event_type: str | None = None,
495
+ actor: str | None = None,
496
+ task_id: str | None = None,
497
+ tool: str | None = None,
498
+ git_head: str | None = None,
499
+ workspace: str | None = None,
500
+ since: str | None = None,
501
+ until: str | None = None,
502
+ limit: int = 10,
503
+ min_score: float = DEFAULT_MIN_SCORE,
504
+ ) -> list[dict[str, Any]]:
505
+ clauses: list[str] = []
506
+ params: list[Any] = []
507
+ _append_memory_filters(
508
+ clauses,
509
+ params,
510
+ session_id=session_id,
511
+ event_type=event_type,
512
+ actor=actor,
513
+ task_id=task_id,
514
+ tool=tool,
515
+ git_head=git_head,
516
+ workspace=workspace,
517
+ since=since,
518
+ until=until,
519
+ )
520
+
521
+ sql = """
522
+ select md.*
523
+ from memory_documents md
524
+ """
525
+ if clauses:
526
+ sql += " where " + " and ".join(clauses)
527
+ sql += " order by coalesce(md.date_utc, md.indexed_at), md.source_id"
528
+
529
+ paths = require_root(root)
530
+ hits: list[dict[str, Any]] = []
531
+ with sqlite3.connect(paths.index_path) as conn:
532
+ conn.row_factory = sqlite3.Row
533
+ for row in conn.execute(sql, params).fetchall():
534
+ score = cosine_similarity(query_vector, deserialize_vector(row["vector_json"]))
535
+ if row["source_kind"] == SOURCE_SESSION_SUMMARY:
536
+ score *= SESSION_SUMMARY_PENALTY
537
+ if score < min_score:
538
+ continue
539
+ payload = _public_memory_row(dict(row))
540
+ payload.pop("vector_json", None)
541
+ payload["memory_score"] = round(score, 6)
542
+ hits.append(payload)
543
+ hits.sort(key=_hit_sort_key)
544
+ return hits[:limit]
545
+
546
+
547
+ def _search_memory_semantic(
548
+ root: str | Path,
549
+ query: str,
550
+ *,
551
+ session_id: str | None = None,
552
+ event_type: str | None = None,
553
+ actor: str | None = None,
554
+ task_id: str | None = None,
555
+ tool: str | None = None,
556
+ git_head: str | None = None,
557
+ workspace: str | None = None,
558
+ since: str | None = None,
559
+ until: str | None = None,
560
+ limit: int = 10,
561
+ min_score: float = DEFAULT_MIN_SCORE,
562
+ ) -> list[dict[str, Any]]:
563
+ config = read_memory_config(root)
564
+ embeddings = config.get("embeddings") or {}
565
+ if embeddings.get("provider") != "fastembed":
566
+ raise AgentDirError("Semantic retrieval requires: agentdir memory embeddings configure fastembed")
567
+ if not _module_available("fastembed"):
568
+ raise AgentDirError("Semantic retrieval requires the optional semantic extra: fastembed")
569
+
570
+ clauses: list[str] = []
571
+ params: list[Any] = []
572
+ _append_memory_filters(
573
+ clauses,
574
+ params,
575
+ session_id=session_id,
576
+ event_type=event_type,
577
+ actor=actor,
578
+ task_id=task_id,
579
+ tool=tool,
580
+ git_head=git_head,
581
+ workspace=workspace,
582
+ since=since,
583
+ until=until,
584
+ )
585
+ sql = "select md.* from memory_documents md"
586
+ if clauses:
587
+ sql += " where " + " and ".join(clauses)
588
+ sql += " order by coalesce(md.date_utc, md.indexed_at), md.source_id"
589
+ model_name = embeddings.get("model") or DEFAULT_FASTEMBED_MODEL
590
+ paths = require_root(root)
591
+ with sqlite3.connect(paths.index_path) as conn:
592
+ conn.row_factory = sqlite3.Row
593
+ rows = [dict(row) for row in conn.execute(sql, params).fetchall()]
594
+ if not rows:
595
+ return []
596
+ query_vector = _fastembed_vectors(model_name, [query])[0]
597
+ document_vectors = _semantic_vectors_for_rows(conn, model_name, rows)
598
+
599
+ hits: list[dict[str, Any]] = []
600
+ for row, vector in zip(rows, document_vectors, strict=True):
601
+ score = dense_cosine_similarity(query_vector, vector)
602
+ if row.get("source_kind") == SOURCE_SESSION_SUMMARY:
603
+ score *= SESSION_SUMMARY_PENALTY
604
+ if score < min_score:
605
+ continue
606
+ payload = _public_memory_row(row)
607
+ payload["memory_score"] = round(score, 6)
608
+ payload["retrieval_mode"] = RETRIEVAL_SEMANTIC
609
+ hits.append(payload)
610
+ hits.sort(key=_hit_sort_key)
611
+ return hits[:limit]
612
+
613
+
614
+ def _search_memory_hybrid(
615
+ root: str | Path,
616
+ query: str,
617
+ *,
618
+ query_vector: dict[int, float],
619
+ session_id: str | None = None,
620
+ event_type: str | None = None,
621
+ actor: str | None = None,
622
+ task_id: str | None = None,
623
+ tool: str | None = None,
624
+ git_head: str | None = None,
625
+ workspace: str | None = None,
626
+ since: str | None = None,
627
+ until: str | None = None,
628
+ limit: int = 10,
629
+ min_score: float = DEFAULT_MIN_SCORE,
630
+ ) -> list[dict[str, Any]]:
631
+ query_terms = sorted(set(_tokens(query)))
632
+ if not query_terms:
633
+ return []
634
+
635
+ clauses = ["mt.term in (" + ", ".join("?" for _ in query_terms) + ")"]
636
+ params: list[Any] = [*query_terms]
637
+ _append_memory_filters(
638
+ clauses,
639
+ params,
640
+ session_id=session_id,
641
+ event_type=event_type,
642
+ actor=actor,
643
+ task_id=task_id,
644
+ tool=tool,
645
+ git_head=git_head,
646
+ workspace=workspace,
647
+ since=since,
648
+ until=until,
649
+ )
650
+ candidate_limit = max(limit * 24, 64)
651
+ sql = f"""
652
+ select
653
+ md.*,
654
+ mp.id as passage_id,
655
+ mp.ordinal as passage_ordinal,
656
+ mp.body_text as passage_body_text,
657
+ mp.vector_json as passage_vector_json,
658
+ mp.text_sha256 as passage_text_sha256,
659
+ mp.token_count as passage_token_count,
660
+ sum(mt.tf) as lexical_score
661
+ from memory_terms mt
662
+ join memory_passages mp on mp.id = mt.passage_id
663
+ join memory_documents md on md.id = mp.memory_document_id
664
+ where {' and '.join(clauses)}
665
+ group by mp.id
666
+ order by sum(mt.tf) desc, coalesce(md.date_utc, md.indexed_at) desc, md.source_id
667
+ limit ?
668
+ """
669
+ params.append(candidate_limit)
670
+
671
+ paths = require_root(root)
672
+ best_by_source: dict[str, dict[str, Any]] = {}
673
+ with sqlite3.connect(paths.index_path) as conn:
674
+ conn.row_factory = sqlite3.Row
675
+ rows = conn.execute(sql, params).fetchall()
676
+ for row in rows:
677
+ vector_score = cosine_similarity(query_vector, deserialize_vector(row["passage_vector_json"]))
678
+ lexical_score = float(row["lexical_score"] or 0)
679
+ lexical_ratio = min(1.0, lexical_score / max(len(query_terms), 1))
680
+ score = (0.82 * max(vector_score, 0.0)) + (0.18 * lexical_ratio)
681
+ if row["source_kind"] == SOURCE_SESSION_SUMMARY:
682
+ score *= SESSION_SUMMARY_PENALTY
683
+ if score < min_score:
684
+ continue
685
+ payload = _public_memory_row(dict(row))
686
+ payload.pop("passage_vector_json", None)
687
+ payload["memory_score"] = round(score, 6)
688
+ payload["passage_score"] = round(vector_score, 6)
689
+ payload["lexical_score"] = round(lexical_score, 6)
690
+ payload["retrieval_mode"] = RETRIEVAL_HYBRID
691
+ source_id = str(payload["source_id"])
692
+ previous = best_by_source.get(source_id)
693
+ if previous is None or _hit_sort_key(payload) < _hit_sort_key(previous):
694
+ best_by_source[source_id] = payload
695
+
696
+ hits = sorted(best_by_source.values(), key=_hit_sort_key)
697
+ return hits[:limit]
698
+
699
+
700
+ def memory_stats(root: str | Path) -> dict[str, Any]:
701
+ paths = require_root(root)
702
+ with sqlite3.connect(paths.index_path) as conn:
703
+ conn.row_factory = sqlite3.Row
704
+ totals = conn.execute(
705
+ """
706
+ select
707
+ (select count(*) from messages) as messages,
708
+ count(*) as memory_documents,
709
+ min(vector_dim) as min_vector_dim,
710
+ max(vector_dim) as max_vector_dim,
711
+ min(indexed_at) as first_indexed_at,
712
+ max(indexed_at) as last_indexed_at
713
+ from memory_documents
714
+ """
715
+ ).fetchone()
716
+ kinds = {
717
+ row["source_kind"]: int(row["count"])
718
+ for row in conn.execute(
719
+ "select source_kind, count(*) as count from memory_documents group by source_kind"
720
+ ).fetchall()
721
+ }
722
+ passage_totals = conn.execute(
723
+ """
724
+ select
725
+ count(*) as passages,
726
+ coalesce(sum(token_count), 0) as passage_tokens,
727
+ (select count(*) from memory_terms) as terms
728
+ from memory_passages
729
+ """
730
+ ).fetchone()
731
+ messages = int(totals["messages"] or 0)
732
+ documents = int(totals["memory_documents"] or 0)
733
+ message_documents = kinds.get(SOURCE_MESSAGE, 0)
734
+ return {
735
+ "messages": messages,
736
+ "memory_documents": documents,
737
+ "message_documents": message_documents,
738
+ "session_summary_documents": kinds.get(SOURCE_SESSION_SUMMARY, 0),
739
+ "coverage": message_documents / messages if messages else 1.0,
740
+ "vector_dim": totals["max_vector_dim"] or DEFAULT_VECTOR_DIM,
741
+ "min_vector_dim": totals["min_vector_dim"],
742
+ "max_vector_dim": totals["max_vector_dim"],
743
+ "first_indexed_at": totals["first_indexed_at"],
744
+ "last_indexed_at": totals["last_indexed_at"],
745
+ "passages": int(passage_totals["passages"] or 0),
746
+ "passage_tokens": int(passage_totals["passage_tokens"] or 0),
747
+ "terms": int(passage_totals["terms"] or 0),
748
+ "retrieval_backend": "local-hybrid",
749
+ }
750
+
751
+
752
+ def memory_backend_status(root: str | Path) -> dict[str, Any]:
753
+ stats = memory_stats(root)
754
+ config = read_memory_config(root)
755
+ fastembed_available = _module_available("fastembed")
756
+ sqlite_vec_available = _module_available("sqlite_vec")
757
+ embeddings = config.get("embeddings", {})
758
+ configured_embedding_provider = embeddings.get("provider")
759
+ semantic_enabled = configured_embedding_provider == "fastembed" and fastembed_available
760
+ active = "semantic-local" if semantic_enabled else "local-hybrid"
761
+ return {
762
+ "active": active,
763
+ "source_of_truth": "immutable envelopes",
764
+ "config": config,
765
+ "backends": [
766
+ {
767
+ "name": "local-hybrid",
768
+ "kind": "built-in",
769
+ "enabled": True,
770
+ "dependencies": [],
771
+ "tables": ["memory_documents", "memory_passages", "memory_terms"],
772
+ "documents": stats["memory_documents"],
773
+ "passages": stats["passages"],
774
+ "terms": stats["terms"],
775
+ },
776
+ {
777
+ "name": "sqlite-vec",
778
+ "kind": "optional-extra",
779
+ "enabled": bool(config.get("vector_backend") == "sqlite-vec" and sqlite_vec_available),
780
+ "dependencies": ["sqlite-vec"],
781
+ "available": sqlite_vec_available,
782
+ "configured": config.get("vector_backend") == "sqlite-vec",
783
+ "status": _optional_status(
784
+ configured=config.get("vector_backend") == "sqlite-vec",
785
+ available=sqlite_vec_available,
786
+ ),
787
+ },
788
+ {
789
+ "name": "local-embeddings",
790
+ "kind": "optional-extra",
791
+ "enabled": semantic_enabled,
792
+ "dependencies": ["fastembed"],
793
+ "available": fastembed_available,
794
+ "configured": configured_embedding_provider == "fastembed",
795
+ "provider": configured_embedding_provider,
796
+ "model": embeddings.get("model"),
797
+ "status": _optional_status(
798
+ configured=configured_embedding_provider == "fastembed",
799
+ available=fastembed_available,
800
+ ),
801
+ },
802
+ {
803
+ "name": "qdrant",
804
+ "kind": "optional-extra",
805
+ "enabled": bool(config.get("team_backend") == "qdrant" and _module_available("qdrant_client")),
806
+ "dependencies": ["qdrant client or service"],
807
+ "available": _module_available("qdrant_client"),
808
+ "configured": config.get("team_backend") == "qdrant",
809
+ "status": _optional_status(
810
+ configured=config.get("team_backend") == "qdrant",
811
+ available=_module_available("qdrant_client"),
812
+ ),
813
+ },
814
+ {
815
+ "name": "lancedb",
816
+ "kind": "optional-extra",
817
+ "enabled": bool(config.get("team_backend") == "lancedb" and _module_available("lancedb")),
818
+ "dependencies": ["lancedb"],
819
+ "available": _module_available("lancedb"),
820
+ "configured": config.get("team_backend") == "lancedb",
821
+ "status": _optional_status(
822
+ configured=config.get("team_backend") == "lancedb",
823
+ available=_module_available("lancedb"),
824
+ ),
825
+ },
826
+ ],
827
+ }
828
+
829
+
830
+ def read_memory_config(root: str | Path) -> dict[str, Any]:
831
+ path = _memory_config_path(root)
832
+ if not path.is_file():
833
+ return {"version": 1, "vector_backend": None, "embeddings": {}, "team_backend": None}
834
+ data = json.loads(path.read_text(encoding="utf-8"))
835
+ if data.get("version") != 1:
836
+ raise AgentDirError(f"Unsupported memory config version: {data.get('version')}")
837
+ data.setdefault("vector_backend", None)
838
+ data.setdefault("embeddings", {})
839
+ data.setdefault("team_backend", None)
840
+ return data
841
+
842
+
843
+ def configure_vector_backend(root: str | Path, backend: str) -> dict[str, Any]:
844
+ if backend not in {"sqlite-vec", "none"}:
845
+ raise AgentDirError("Unknown vector backend; expected sqlite-vec or none")
846
+ config = read_memory_config(root)
847
+ config["vector_backend"] = None if backend == "none" else backend
848
+ _write_memory_config(root, config)
849
+ return memory_backend_status(root)
850
+
851
+
852
+ def configure_embeddings(root: str | Path, provider: str, *, model: str | None = None) -> dict[str, Any]:
853
+ if provider not in {"fastembed", "none"}:
854
+ raise AgentDirError("Unknown embedding provider; expected fastembed or none")
855
+ config = read_memory_config(root)
856
+ config["embeddings"] = {} if provider == "none" else {
857
+ "provider": provider,
858
+ "model": model or DEFAULT_FASTEMBED_MODEL,
859
+ }
860
+ _write_memory_config(root, config)
861
+ return memory_backend_status(root)
862
+
863
+
864
+ def configure_team_backend(root: str | Path, backend: str) -> dict[str, Any]:
865
+ if backend not in {"qdrant", "lancedb", "none"}:
866
+ raise AgentDirError("Unknown team backend; expected qdrant, lancedb, or none")
867
+ config = read_memory_config(root)
868
+ config["team_backend"] = None if backend == "none" else backend
869
+ _write_memory_config(root, config)
870
+ return memory_backend_status(root)
871
+
872
+
873
+ def explain_memory_match(
874
+ root: str | Path,
875
+ query: str,
876
+ *,
877
+ source_id: str | None = None,
878
+ min_score: float = 0.0,
879
+ ) -> dict[str, Any]:
880
+ query_vector, token_count = vectorize(query)
881
+ if token_count == 0 or not query_vector:
882
+ raise AgentDirError("Memory explain query must contain searchable text")
883
+
884
+ if source_id:
885
+ row = _memory_document(root, source_id)
886
+ score = cosine_similarity(query_vector, deserialize_vector(row["vector_json"]))
887
+ if score < min_score:
888
+ raise AgentDirError(f"Memory source did not meet score threshold: {source_id}")
889
+ hit = _public_memory_row(row)
890
+ hit["memory_score"] = round(score, 6)
891
+ hit.update(_best_passage_for_document(root, int(row["id"]), query_vector))
892
+ else:
893
+ hits = search_memory(root, query, limit=1, min_score=min_score)
894
+ if not hits:
895
+ raise AgentDirError("No memory hits to explain")
896
+ hit = hits[0]
897
+
898
+ body = hit.get("body_text") or ""
899
+ query_terms = sorted(set(_tokens(query)))
900
+ document_terms = sorted(set(_tokens(body)))
901
+ overlap = sorted(set(query_terms).intersection(document_terms))
902
+ return {
903
+ "query": query,
904
+ "source_id": hit.get("source_id"),
905
+ "source_kind": hit.get("source_kind"),
906
+ "session_id": hit.get("session_id"),
907
+ "event_type": hit.get("event_type"),
908
+ "subject": hit.get("subject"),
909
+ "file_path": hit.get("file_path"),
910
+ "memory_score": hit.get("memory_score"),
911
+ "overlap_terms": overlap,
912
+ "query_terms": query_terms,
913
+ "document_terms_sample": document_terms[:30],
914
+ "passage_id": hit.get("passage_id"),
915
+ "passage_ordinal": hit.get("passage_ordinal"),
916
+ "passage_score": hit.get("passage_score"),
917
+ "lexical_score": hit.get("lexical_score"),
918
+ "passage_excerpt": _excerpt(hit.get("passage_body_text") or body, 500),
919
+ "excerpt": _excerpt(body, 500),
920
+ }
921
+
922
+
923
+ def format_memory_explanation(explanation: dict[str, Any]) -> str:
924
+ lines = [
925
+ f"query={explanation['query']}",
926
+ f"source_id={explanation['source_id']}",
927
+ f"source_kind={explanation['source_kind']}",
928
+ f"score={explanation['memory_score']}",
929
+ f"session={explanation.get('session_id') or ''}",
930
+ "overlap=" + ", ".join(explanation["overlap_terms"]),
931
+ f"passage_id={explanation.get('passage_id') or ''}",
932
+ f"passage_score={explanation.get('passage_score') or ''}",
933
+ f"lexical_score={explanation.get('lexical_score') or ''}",
934
+ "passage_excerpt:",
935
+ explanation["passage_excerpt"],
936
+ "excerpt:",
937
+ explanation["excerpt"],
938
+ ]
939
+ return "\n".join(lines)
940
+
941
+
942
+ def recent_session_summaries(root: str | Path, *, limit: int = 5) -> list[dict[str, Any]]:
943
+ paths = require_root(root)
944
+ with sqlite3.connect(paths.index_path) as conn:
945
+ conn.row_factory = sqlite3.Row
946
+ rows = conn.execute(
947
+ """
948
+ select *
949
+ from memory_documents
950
+ where source_kind = ?
951
+ order by coalesce(date_utc, indexed_at) desc, source_id
952
+ limit ?
953
+ """,
954
+ (SOURCE_SESSION_SUMMARY, limit),
955
+ ).fetchall()
956
+ return [_public_memory_row(dict(row)) for row in rows]
957
+
958
+
959
+ def format_memory_hits(rows: list[dict[str, Any]]) -> str:
960
+ lines: list[str] = []
961
+ for row in rows:
962
+ body = (row.get("body_text") or "").strip().replace("\n", "\\n")
963
+ if len(body) > 240:
964
+ body = body[:237] + "..."
965
+ lines.append(
966
+ f"{row.get('memory_score'):.3f} "
967
+ f"{row.get('source_kind') or 'memory'} "
968
+ f"{row.get('event_type') or 'unknown'} "
969
+ f"{row.get('subject') or ''} "
970
+ f"session={row.get('session_id') or ''} "
971
+ f"passage={row.get('passage_ordinal') if row.get('passage_ordinal') is not None else ''} "
972
+ f"{body} "
973
+ f"{row.get('file_path')}"
974
+ )
975
+ return "\n".join(lines)
976
+
977
+
978
+ def _memory_document(root: str | Path, source_id: str) -> dict[str, Any]:
979
+ paths = require_root(root)
980
+ with sqlite3.connect(paths.index_path) as conn:
981
+ conn.row_factory = sqlite3.Row
982
+ row = conn.execute("select * from memory_documents where source_id = ?", (source_id,)).fetchone()
983
+ if row is None:
984
+ raise AgentDirError(f"Unknown memory source: {source_id}")
985
+ return dict(row)
986
+
987
+
988
+ def _public_memory_row(row: dict[str, Any]) -> dict[str, Any]:
989
+ row.pop("vector_json", None)
990
+ return row
991
+
992
+
993
+ def _semantic_vectors_for_rows(
994
+ conn: sqlite3.Connection,
995
+ model_name: str,
996
+ rows: list[dict[str, Any]],
997
+ ) -> list[list[float]]:
998
+ vectors: list[list[float] | None] = []
999
+ missing: list[dict[str, Any]] = []
1000
+ for row in rows:
1001
+ cached = conn.execute(
1002
+ """
1003
+ select vector_json
1004
+ from semantic_embeddings
1005
+ where source_id = ? and model = ? and text_sha256 = ?
1006
+ """,
1007
+ (row["source_id"], model_name, row["text_sha256"]),
1008
+ ).fetchone()
1009
+ if cached:
1010
+ vectors.append(json.loads(cached["vector_json"]))
1011
+ else:
1012
+ vectors.append(None)
1013
+ missing.append(row)
1014
+ if missing:
1015
+ embedded = _fastembed_vectors(model_name, [row["body_text"] for row in missing])
1016
+ now = now_iso()
1017
+ for row, vector in zip(missing, embedded, strict=True):
1018
+ conn.execute(
1019
+ """
1020
+ insert or replace into semantic_embeddings(
1021
+ source_id, model, text_sha256, dimensions, vector_json, indexed_at
1022
+ ) values (?, ?, ?, ?, ?, ?)
1023
+ """,
1024
+ (
1025
+ row["source_id"],
1026
+ model_name,
1027
+ row["text_sha256"],
1028
+ len(vector),
1029
+ json.dumps(vector),
1030
+ now,
1031
+ ),
1032
+ )
1033
+ conn.commit()
1034
+ iterator = iter(embedded)
1035
+ vectors = [next(iterator) if vector is None else vector for vector in vectors]
1036
+ return [vector for vector in vectors if vector is not None]
1037
+
1038
+
1039
+ _FASTEMBED_MODELS: dict[str, Any] = {}
1040
+
1041
+
1042
+ def _fastembed_vectors(model_name: str, texts: list[str]) -> list[list[float]]:
1043
+ model = _FASTEMBED_MODELS.get(model_name)
1044
+ if model is None:
1045
+ from fastembed import TextEmbedding
1046
+
1047
+ model = TextEmbedding(model_name=model_name)
1048
+ _FASTEMBED_MODELS[model_name] = model
1049
+ return [[float(value) for value in vector] for vector in model.embed(texts)]
1050
+
1051
+
1052
+ def dense_cosine_similarity(left: list[float], right: list[float]) -> float:
1053
+ if not left or not right or len(left) != len(right):
1054
+ return 0.0
1055
+ dot = sum(a * b for a, b in zip(left, right, strict=True))
1056
+ left_norm = math.sqrt(sum(value * value for value in left))
1057
+ right_norm = math.sqrt(sum(value * value for value in right))
1058
+ if left_norm == 0 or right_norm == 0:
1059
+ return 0.0
1060
+ return dot / (left_norm * right_norm)
1061
+
1062
+
1063
+ def _index_memory_passages(
1064
+ conn: sqlite3.Connection,
1065
+ *,
1066
+ memory_document_id: int,
1067
+ source_kind: str,
1068
+ source_id: str,
1069
+ message_rowid: int | None,
1070
+ session_id: str | None,
1071
+ event_type: str | None,
1072
+ tool: str | None,
1073
+ git_head: str | None,
1074
+ workspace: str | None,
1075
+ date_utc: str | None,
1076
+ memory_text: str,
1077
+ ) -> None:
1078
+ for ordinal, passage_text in enumerate(_passage_texts(memory_text)):
1079
+ vector, token_count = vectorize(passage_text)
1080
+ if not vector:
1081
+ continue
1082
+ cursor = conn.execute(
1083
+ """
1084
+ insert into memory_passages(
1085
+ memory_document_id, source_kind, source_id, message_rowid, session_id,
1086
+ event_type, tool, git_head, workspace, date_utc, ordinal, body_text,
1087
+ token_count, vector_dim, vector_json, text_sha256
1088
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1089
+ """,
1090
+ (
1091
+ memory_document_id,
1092
+ source_kind,
1093
+ source_id,
1094
+ message_rowid,
1095
+ session_id,
1096
+ event_type,
1097
+ tool,
1098
+ git_head,
1099
+ workspace,
1100
+ date_utc,
1101
+ ordinal,
1102
+ passage_text,
1103
+ token_count,
1104
+ DEFAULT_VECTOR_DIM,
1105
+ serialize_vector(vector),
1106
+ hashlib.sha256(passage_text.encode("utf-8")).hexdigest(),
1107
+ ),
1108
+ )
1109
+ passage_id = int(cursor.lastrowid)
1110
+ for term, tf in _term_counts(passage_text).items():
1111
+ conn.execute(
1112
+ "insert into memory_terms(term, passage_id, tf, field_mask) values (?, ?, ?, ?)",
1113
+ (term, passage_id, tf, 1),
1114
+ )
1115
+
1116
+
1117
+ def _passage_texts(text: str) -> list[str]:
1118
+ tokens = _tokens(text)
1119
+ if len(tokens) <= DEFAULT_PASSAGE_TOKEN_LIMIT:
1120
+ return [text.strip()] if text.strip() else []
1121
+
1122
+ passages: list[str] = []
1123
+ step = DEFAULT_PASSAGE_TOKEN_LIMIT - DEFAULT_PASSAGE_TOKEN_OVERLAP
1124
+ for start in range(0, len(tokens), step):
1125
+ window = tokens[start : start + DEFAULT_PASSAGE_TOKEN_LIMIT]
1126
+ if not window:
1127
+ break
1128
+ passages.append(" ".join(window))
1129
+ if start + DEFAULT_PASSAGE_TOKEN_LIMIT >= len(tokens):
1130
+ break
1131
+ return passages
1132
+
1133
+
1134
+ def _term_counts(text: str) -> dict[str, int]:
1135
+ counts: dict[str, int] = {}
1136
+ for token in _tokens(text):
1137
+ counts[token] = counts.get(token, 0) + 1
1138
+ return counts
1139
+
1140
+
1141
+ def _append_memory_filters(
1142
+ clauses: list[str],
1143
+ params: list[Any],
1144
+ *,
1145
+ session_id: str | None = None,
1146
+ event_type: str | None = None,
1147
+ actor: str | None = None,
1148
+ task_id: str | None = None,
1149
+ tool: str | None = None,
1150
+ git_head: str | None = None,
1151
+ workspace: str | None = None,
1152
+ since: str | None = None,
1153
+ until: str | None = None,
1154
+ ) -> None:
1155
+ if session_id:
1156
+ clauses.append("md.session_id = ?")
1157
+ params.append(session_id)
1158
+ if event_type:
1159
+ clauses.append("md.event_type = ?")
1160
+ params.append(event_type)
1161
+ if actor:
1162
+ clauses.append("(md.from_actor like ? or md.to_actor like ?)")
1163
+ params.extend([f"{actor}%", f"{actor}%"])
1164
+ if task_id:
1165
+ clauses.append("md.task_id = ?")
1166
+ params.append(task_id)
1167
+ if tool:
1168
+ clauses.append("md.tool = ?")
1169
+ params.append(tool)
1170
+ if git_head:
1171
+ clauses.append("md.git_head = ?")
1172
+ params.append(git_head)
1173
+ if workspace:
1174
+ clauses.append("md.workspace = ?")
1175
+ params.append(workspace)
1176
+ if since:
1177
+ clauses.append("coalesce(md.date_utc, md.indexed_at) >= ?")
1178
+ params.append(since)
1179
+ if until:
1180
+ clauses.append("coalesce(md.date_utc, md.indexed_at) <= ?")
1181
+ params.append(until)
1182
+
1183
+
1184
+ def _best_passage_for_document(
1185
+ root: str | Path,
1186
+ document_id: int,
1187
+ query_vector: dict[int, float],
1188
+ ) -> dict[str, Any]:
1189
+ paths = require_root(root)
1190
+ with sqlite3.connect(paths.index_path) as conn:
1191
+ conn.row_factory = sqlite3.Row
1192
+ rows = conn.execute(
1193
+ """
1194
+ select id, ordinal, body_text, vector_json, text_sha256, token_count
1195
+ from memory_passages
1196
+ where memory_document_id = ?
1197
+ order by ordinal
1198
+ """,
1199
+ (document_id,),
1200
+ ).fetchall()
1201
+ best: dict[str, Any] = {}
1202
+ best_score = -1.0
1203
+ for row in rows:
1204
+ score = cosine_similarity(query_vector, deserialize_vector(row["vector_json"]))
1205
+ if score > best_score:
1206
+ best_score = score
1207
+ best = {
1208
+ "passage_id": row["id"],
1209
+ "passage_ordinal": row["ordinal"],
1210
+ "passage_body_text": row["body_text"],
1211
+ "passage_text_sha256": row["text_sha256"],
1212
+ "passage_token_count": row["token_count"],
1213
+ "passage_score": round(score, 6),
1214
+ }
1215
+ return best
1216
+
1217
+
1218
+ def _hit_sort_key(row: dict[str, Any]) -> tuple[float, str, str]:
1219
+ # memory_score already includes SESSION_SUMMARY_PENALTY; sort on it as-is.
1220
+ return (
1221
+ -float(row["memory_score"]),
1222
+ row.get("date_utc") or row.get("indexed_at") or "",
1223
+ row.get("source_id") or "",
1224
+ )
1225
+
1226
+
1227
+ def _tokens(text: str) -> list[str]:
1228
+ return [
1229
+ token
1230
+ for token in TOKEN_RE.findall(text.lower())
1231
+ if len(token) > 1 and token not in STOPWORDS
1232
+ ]
1233
+
1234
+
1235
+ def _add_feature(weights: dict[int, float], feature: str, weight: float, dimensions: int) -> None:
1236
+ digest = hashlib.blake2b(feature.encode("utf-8"), digest_size=8).digest()
1237
+ index = int.from_bytes(digest[:4], "big") % dimensions
1238
+ sign = 1.0 if digest[4] & 1 else -1.0
1239
+ weights[index] = weights.get(index, 0.0) + sign * weight
1240
+
1241
+
1242
+ def _labeled(label: str, value: str | None) -> str:
1243
+ return f"{label}: {value}" if value else ""
1244
+
1245
+
1246
+ def _excerpt(text: str, limit: int) -> str:
1247
+ collapsed = " ".join(text.strip().split())
1248
+ if len(collapsed) <= limit:
1249
+ return collapsed
1250
+ return collapsed[: limit - 3] + "..."
1251
+
1252
+
1253
+ def _memory_config_path(root: str | Path) -> Path:
1254
+ return require_root(root).state / MEMORY_CONFIG_FILE
1255
+
1256
+
1257
+ def _write_memory_config(root: str | Path, config: dict[str, Any]) -> None:
1258
+ path = _memory_config_path(root)
1259
+ path.parent.mkdir(parents=True, exist_ok=True)
1260
+ path.write_text(json.dumps(config, indent=2, sort_keys=True) + "\n", encoding="utf-8")
1261
+
1262
+
1263
+ def _module_available(module: str) -> bool:
1264
+ return importlib.util.find_spec(module) is not None
1265
+
1266
+
1267
+ def _optional_status(*, configured: bool, available: bool) -> str:
1268
+ if configured and available:
1269
+ return "ready"
1270
+ if configured:
1271
+ return "configured but dependency missing"
1272
+ if available:
1273
+ return "available but not configured"
1274
+ return "not configured"