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/__init__.py +3 -0
- agentdir/__main__.py +6 -0
- agentdir/actors.py +50 -0
- agentdir/artifacts.py +56 -0
- agentdir/audit.py +339 -0
- agentdir/capture.py +187 -0
- agentdir/cli.py +1900 -0
- agentdir/context.py +654 -0
- agentdir/control.py +729 -0
- agentdir/daemon.py +253 -0
- agentdir/doctor.py +115 -0
- agentdir/envelope.py +147 -0
- agentdir/events.py +72 -0
- agentdir/federation.py +530 -0
- agentdir/git.py +45 -0
- agentdir/gitignore.py +106 -0
- agentdir/hooks.py +215 -0
- agentdir/index.py +362 -0
- agentdir/mailbox.py +84 -0
- agentdir/memory.py +1274 -0
- agentdir/query.py +65 -0
- agentdir/redaction.py +42 -0
- agentdir/rendering.py +89 -0
- agentdir/replay.py +31 -0
- agentdir/retention.py +319 -0
- agentdir/review.py +288 -0
- agentdir/secrets.py +158 -0
- agentdir/sessions.py +171 -0
- agentdir/skills.py +685 -0
- agentdir/store.py +270 -0
- agentdir/upgrade.py +252 -0
- agentdir_cli-0.7.5.dist-info/METADATA +393 -0
- agentdir_cli-0.7.5.dist-info/RECORD +37 -0
- agentdir_cli-0.7.5.dist-info/WHEEL +5 -0
- agentdir_cli-0.7.5.dist-info/entry_points.txt +2 -0
- agentdir_cli-0.7.5.dist-info/licenses/LICENSE +21 -0
- agentdir_cli-0.7.5.dist-info/top_level.txt +1 -0
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"
|