local-coding-agent 1.1.0__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.
- local_coding_agent/__init__.py +314 -0
- local_coding_agent/__main__.py +5 -0
- local_coding_agent/acp_server/__init__.py +38 -0
- local_coding_agent/acp_server/_codec.py +95 -0
- local_coding_agent/acp_server/_protocol.py +13 -0
- local_coding_agent/acp_server/_server.py +663 -0
- local_coding_agent/acp_server/_session.py +24 -0
- local_coding_agent/acp_server/_tools.py +126 -0
- local_coding_agent/ast_compactor.py +130 -0
- local_coding_agent/atomizer.py +77 -0
- local_coding_agent/benchmark/__init__.py +57 -0
- local_coding_agent/benchmark/_cases.py +307 -0
- local_coding_agent/benchmark/_judge.py +175 -0
- local_coding_agent/benchmark/_oracles.py +157 -0
- local_coding_agent/benchmark/_runner.py +259 -0
- local_coding_agent/benchmark/_stats.py +22 -0
- local_coding_agent/benchmark/_summary.py +143 -0
- local_coding_agent/benchmark_oracle_worker.py +169 -0
- local_coding_agent/calibration.py +154 -0
- local_coding_agent/capability.py +450 -0
- local_coding_agent/chat_compaction.py +276 -0
- local_coding_agent/cli/__init__.py +12 -0
- local_coding_agent/cli/_dispatch.py +377 -0
- local_coding_agent/cli/_handlers.py +320 -0
- local_coding_agent/cli/_handlers2.py +725 -0
- local_coding_agent/cli/_input.py +61 -0
- local_coding_agent/cli/_main.py +159 -0
- local_coding_agent/cli/_parser.py +327 -0
- local_coding_agent/context_manager.py +155 -0
- local_coding_agent/controller/__init__.py +18 -0
- local_coding_agent/controller/_constants.py +108 -0
- local_coding_agent/controller/_controller.py +1008 -0
- local_coding_agent/controller/_post_apply.py +216 -0
- local_coding_agent/delegator.py +180 -0
- local_coding_agent/desktop/__init__.py +8 -0
- local_coding_agent/desktop/app.py +100 -0
- local_coding_agent/desktop/assets/lucide.min.js +12 -0
- local_coding_agent/desktop/assets/tailwind.css +1 -0
- local_coding_agent/desktop/client_js.py +1271 -0
- local_coding_agent/desktop/components.py +555 -0
- local_coding_agent/desktop/server/__init__.py +37 -0
- local_coding_agent/desktop/server/_handlers.py +2178 -0
- local_coding_agent/desktop/server/_models.py +204 -0
- local_coding_agent/desktop/server/_server.py +347 -0
- local_coding_agent/desktop/server/_telemetry.py +87 -0
- local_coding_agent/desktop/styles.py +76 -0
- local_coding_agent/desktop/ui.py +53 -0
- local_coding_agent/doctor.py +413 -0
- local_coding_agent/hooks/__init__.py +19 -0
- local_coding_agent/hooks/_adapters.py +458 -0
- local_coding_agent/hooks/_bridge.py +346 -0
- local_coding_agent/hooks/_decision.py +26 -0
- local_coding_agent/lsp/__init__.py +60 -0
- local_coding_agent/lsp/_client.py +322 -0
- local_coding_agent/lsp/_fallback.py +297 -0
- local_coding_agent/lsp/_manager.py +173 -0
- local_coding_agent/lsp/_mock.py +200 -0
- local_coding_agent/lsp/_types.py +398 -0
- local_coding_agent/mcp_config.py +398 -0
- local_coding_agent/mcp_server.py +377 -0
- local_coding_agent/memory.py +140 -0
- local_coding_agent/mode_router.py +200 -0
- local_coding_agent/model_scanner.py +525 -0
- local_coding_agent/monitor.py +635 -0
- local_coding_agent/observation_policy.py +184 -0
- local_coding_agent/ollama_adapter.py +736 -0
- local_coding_agent/plan_mode.py +653 -0
- local_coding_agent/prescriptions.py +278 -0
- local_coding_agent/profiles.py +239 -0
- local_coding_agent/repository_tools.py +782 -0
- local_coding_agent/ripgrep.py +326 -0
- local_coding_agent/semantic_linter.py +122 -0
- local_coding_agent/service.py +405 -0
- local_coding_agent/session_events.py +684 -0
- local_coding_agent/session_query.py +578 -0
- local_coding_agent/skill_config.py +426 -0
- local_coding_agent/smoke.py +268 -0
- local_coding_agent/speculative_racing.py +85 -0
- local_coding_agent/spill.py +258 -0
- local_coding_agent/stats.py +255 -0
- local_coding_agent/stdio.py +132 -0
- local_coding_agent/subagent.py +516 -0
- local_coding_agent/task.py +90 -0
- local_coding_agent/task_store.py +142 -0
- local_coding_agent/tasks.py +240 -0
- local_coding_agent/terminal/__init__.py +35 -0
- local_coding_agent/terminal/_manager.py +156 -0
- local_coding_agent/terminal/_process.py +148 -0
- local_coding_agent/terminal/_session.py +452 -0
- local_coding_agent/terminal/_tools.py +233 -0
- local_coding_agent/terminal/_types.py +60 -0
- local_coding_agent/tool_call_parser.py +178 -0
- local_coding_agent/validators.py +563 -0
- local_coding_agent/vram_fit.py +240 -0
- local_coding_agent/worker_pool.py +516 -0
- local_coding_agent-1.1.0.dist-info/METADATA +202 -0
- local_coding_agent-1.1.0.dist-info/RECORD +101 -0
- local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
- local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
- local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
- local_coding_agent-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
"""SQLite FTS5 Full-Text Search Index for Session Events.
|
|
2
|
+
|
|
3
|
+
Provides persistent session event indexing, cross-session search, event search with
|
|
4
|
+
snippets, and full chronological trace retrieval.
|
|
5
|
+
|
|
6
|
+
Adapted from DeepSeek Harness @deepseek-ai/dsh-session-query and
|
|
7
|
+
@deepseek-ai/dsh-session-persistence-sqlite.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import re
|
|
16
|
+
import sqlite3
|
|
17
|
+
import threading
|
|
18
|
+
from typing import Any, Mapping, Optional, Sequence
|
|
19
|
+
|
|
20
|
+
from local_coding_agent.session_events import (
|
|
21
|
+
ModelTurnEvent,
|
|
22
|
+
PrescriptionEvent,
|
|
23
|
+
SessionCompletedEvent,
|
|
24
|
+
SessionCreatedEvent,
|
|
25
|
+
SessionEvent,
|
|
26
|
+
SessionLog,
|
|
27
|
+
ToolCallEvent,
|
|
28
|
+
ToolResultEvent,
|
|
29
|
+
UserPromptEvent,
|
|
30
|
+
event_from_dict,
|
|
31
|
+
event_to_dict,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def sanitize_fts5_query(query: str) -> str:
|
|
36
|
+
"""Sanitize raw user search queries into safe FTS5 match expressions.
|
|
37
|
+
|
|
38
|
+
Prevents syntax errors caused by punctuation, brackets, colons, or quotes in code search.
|
|
39
|
+
"""
|
|
40
|
+
raw = str(query or "").strip()
|
|
41
|
+
if not raw:
|
|
42
|
+
return '""'
|
|
43
|
+
|
|
44
|
+
# Extract words or quoted fragments
|
|
45
|
+
tokens = re.findall(r'[a-zA-Z0-9_\u0400-\u04FF]+|"[^"]+"', raw)
|
|
46
|
+
if not tokens:
|
|
47
|
+
# Fallback: strip punctuation
|
|
48
|
+
cleaned = re.sub(r'[^\w\s]', " ", raw).strip()
|
|
49
|
+
tokens = cleaned.split()
|
|
50
|
+
|
|
51
|
+
if not tokens:
|
|
52
|
+
# If still empty (e.g. only symbols), escape whole string as a quoted token
|
|
53
|
+
safe_str = raw.replace('"', '""')
|
|
54
|
+
return f'"{safe_str}"'
|
|
55
|
+
|
|
56
|
+
escaped = []
|
|
57
|
+
for token in tokens:
|
|
58
|
+
clean = token.strip('"').replace('"', '""')
|
|
59
|
+
if clean:
|
|
60
|
+
escaped.append(f'"{clean}"*')
|
|
61
|
+
|
|
62
|
+
return " AND ".join(escaped) if escaped else '""'
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SessionQueryEngine:
|
|
66
|
+
"""SQLite FTS5 Search Engine for Session Events and Traces."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, db_path: str | Path = ":memory:") -> None:
|
|
69
|
+
self.db_path = str(db_path) if db_path == ":memory:" else str(Path(db_path).resolve())
|
|
70
|
+
self._lock = threading.RLock()
|
|
71
|
+
|
|
72
|
+
if self.db_path != ":memory:":
|
|
73
|
+
Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
|
|
75
|
+
self._conn = sqlite3.connect(self.db_path, check_same_thread=False)
|
|
76
|
+
self._conn.row_factory = sqlite3.Row
|
|
77
|
+
self._init_db()
|
|
78
|
+
|
|
79
|
+
def _init_db(self) -> None:
|
|
80
|
+
"""Initialize database schema with WAL mode, session records, and FTS5 virtual table."""
|
|
81
|
+
with self._lock:
|
|
82
|
+
cur = self._conn.cursor()
|
|
83
|
+
if self.db_path != ":memory:":
|
|
84
|
+
cur.execute("PRAGMA journal_mode = WAL")
|
|
85
|
+
cur.execute("PRAGMA synchronous = NORMAL")
|
|
86
|
+
|
|
87
|
+
# 1. Session Records Table
|
|
88
|
+
cur.execute(
|
|
89
|
+
"""
|
|
90
|
+
CREATE TABLE IF NOT EXISTS session_records (
|
|
91
|
+
session_id TEXT PRIMARY KEY,
|
|
92
|
+
created_at TEXT NOT NULL,
|
|
93
|
+
updated_at TEXT NOT NULL,
|
|
94
|
+
parent_session_id TEXT,
|
|
95
|
+
fork_seq INTEGER,
|
|
96
|
+
status TEXT DEFAULT 'active',
|
|
97
|
+
summary TEXT DEFAULT '',
|
|
98
|
+
event_count INTEGER DEFAULT 0,
|
|
99
|
+
metadata TEXT DEFAULT '{}'
|
|
100
|
+
)
|
|
101
|
+
"""
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# 2. Raw Events Table (for precise sequential trace reconstruction)
|
|
105
|
+
cur.execute(
|
|
106
|
+
"""
|
|
107
|
+
CREATE TABLE IF NOT EXISTS events_raw (
|
|
108
|
+
session_id TEXT NOT NULL,
|
|
109
|
+
seq INTEGER NOT NULL,
|
|
110
|
+
event_type TEXT NOT NULL,
|
|
111
|
+
timestamp TEXT NOT NULL,
|
|
112
|
+
payload TEXT NOT NULL,
|
|
113
|
+
PRIMARY KEY (session_id, seq)
|
|
114
|
+
)
|
|
115
|
+
"""
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# 3. FTS5 Virtual Table for semantic search
|
|
119
|
+
cur.execute(
|
|
120
|
+
"""
|
|
121
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS events_fts USING fts5(
|
|
122
|
+
session_id UNINDEXED,
|
|
123
|
+
seq UNINDEXED,
|
|
124
|
+
event_type UNINDEXED,
|
|
125
|
+
timestamp UNINDEXED,
|
|
126
|
+
content,
|
|
127
|
+
tool_name,
|
|
128
|
+
tool_args,
|
|
129
|
+
tool_result,
|
|
130
|
+
tokenize = 'unicode61'
|
|
131
|
+
)
|
|
132
|
+
"""
|
|
133
|
+
)
|
|
134
|
+
self._conn.commit()
|
|
135
|
+
|
|
136
|
+
def close(self) -> None:
|
|
137
|
+
"""Close SQLite database connection."""
|
|
138
|
+
with self._lock:
|
|
139
|
+
self._conn.close()
|
|
140
|
+
|
|
141
|
+
def index_event(self, event: SessionEvent, commit: bool = True) -> None:
|
|
142
|
+
"""Index a single session event into FTS5 and raw tables, updating session metadata."""
|
|
143
|
+
with self._lock:
|
|
144
|
+
cur = self._conn.cursor()
|
|
145
|
+
|
|
146
|
+
content = ""
|
|
147
|
+
tool_name = ""
|
|
148
|
+
tool_args = ""
|
|
149
|
+
tool_result = ""
|
|
150
|
+
|
|
151
|
+
# Extract searchable text fields
|
|
152
|
+
if isinstance(event, UserPromptEvent):
|
|
153
|
+
content = event.content
|
|
154
|
+
elif isinstance(event, ModelTurnEvent):
|
|
155
|
+
content = event.content
|
|
156
|
+
if event.tool_calls:
|
|
157
|
+
tool_name = " ".join(tc.get("name", tc.get("function", {}).get("name", "")) for tc in event.tool_calls)
|
|
158
|
+
tool_args = " ".join(json.dumps(tc.get("arguments", tc.get("function", {}).get("arguments", {}))) for tc in event.tool_calls)
|
|
159
|
+
elif isinstance(event, ToolCallEvent):
|
|
160
|
+
tool_name = event.tool_name
|
|
161
|
+
tool_args = json.dumps(event.arguments, ensure_ascii=False)
|
|
162
|
+
elif isinstance(event, ToolResultEvent):
|
|
163
|
+
tool_name = event.tool_name
|
|
164
|
+
tool_result = json.dumps(event.result, ensure_ascii=False) if not isinstance(event.result, str) else event.result
|
|
165
|
+
if event.error:
|
|
166
|
+
tool_result = f"{tool_result} error: {event.error}"
|
|
167
|
+
elif isinstance(event, PrescriptionEvent):
|
|
168
|
+
content = f"[{event.kind}] {event.instruction}"
|
|
169
|
+
if event.details:
|
|
170
|
+
tool_args = json.dumps(event.details, ensure_ascii=False)
|
|
171
|
+
elif isinstance(event, SessionCompletedEvent):
|
|
172
|
+
content = event.summary
|
|
173
|
+
if event.result:
|
|
174
|
+
tool_result = json.dumps(event.result, ensure_ascii=False)
|
|
175
|
+
|
|
176
|
+
# Insert raw event
|
|
177
|
+
raw_payload = json.dumps(event_to_dict(event), ensure_ascii=False)
|
|
178
|
+
cur.execute(
|
|
179
|
+
"""
|
|
180
|
+
INSERT OR REPLACE INTO events_raw (session_id, seq, event_type, timestamp, payload)
|
|
181
|
+
VALUES (?, ?, ?, ?, ?)
|
|
182
|
+
""",
|
|
183
|
+
(event.session_id, event.seq, event.event_type, event.timestamp, raw_payload),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# Insert FTS doc
|
|
187
|
+
cur.execute(
|
|
188
|
+
"""
|
|
189
|
+
INSERT INTO events_fts (session_id, seq, event_type, timestamp, content, tool_name, tool_args, tool_result)
|
|
190
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
191
|
+
""",
|
|
192
|
+
(
|
|
193
|
+
event.session_id,
|
|
194
|
+
event.seq,
|
|
195
|
+
event.event_type,
|
|
196
|
+
event.timestamp,
|
|
197
|
+
content,
|
|
198
|
+
tool_name,
|
|
199
|
+
tool_args,
|
|
200
|
+
tool_result,
|
|
201
|
+
),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# Update or insert session record
|
|
205
|
+
parent_session_id = getattr(event, "parent_session_id", None)
|
|
206
|
+
fork_seq = getattr(event, "fork_seq", None)
|
|
207
|
+
status = getattr(event, "status", None)
|
|
208
|
+
summary = getattr(event, "summary", None)
|
|
209
|
+
|
|
210
|
+
cur.execute("SELECT * FROM session_records WHERE session_id = ?", (event.session_id,))
|
|
211
|
+
row = cur.fetchone()
|
|
212
|
+
|
|
213
|
+
if row is None:
|
|
214
|
+
cur.execute(
|
|
215
|
+
"""
|
|
216
|
+
INSERT INTO session_records (
|
|
217
|
+
session_id, created_at, updated_at, parent_session_id, fork_seq,
|
|
218
|
+
status, summary, event_count, metadata
|
|
219
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)
|
|
220
|
+
""",
|
|
221
|
+
(
|
|
222
|
+
event.session_id,
|
|
223
|
+
event.timestamp,
|
|
224
|
+
event.timestamp,
|
|
225
|
+
parent_session_id,
|
|
226
|
+
fork_seq,
|
|
227
|
+
status or "active",
|
|
228
|
+
summary or "",
|
|
229
|
+
json.dumps(event.metadata, ensure_ascii=False),
|
|
230
|
+
),
|
|
231
|
+
)
|
|
232
|
+
else:
|
|
233
|
+
new_status = status if status is not None else row["status"]
|
|
234
|
+
new_summary = summary if summary else row["summary"]
|
|
235
|
+
cur.execute(
|
|
236
|
+
"""
|
|
237
|
+
UPDATE session_records
|
|
238
|
+
SET updated_at = ?,
|
|
239
|
+
event_count = event_count + 1,
|
|
240
|
+
status = ?,
|
|
241
|
+
summary = ?
|
|
242
|
+
WHERE session_id = ?
|
|
243
|
+
""",
|
|
244
|
+
(event.timestamp, new_status, new_summary, event.session_id),
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
if commit:
|
|
248
|
+
self._conn.commit()
|
|
249
|
+
|
|
250
|
+
def index_session_log(self, session_log: SessionLog) -> None:
|
|
251
|
+
"""Index an entire SessionLog into the search engine."""
|
|
252
|
+
with self._lock:
|
|
253
|
+
for event in session_log.events:
|
|
254
|
+
self.index_event(event, commit=False)
|
|
255
|
+
self._conn.commit()
|
|
256
|
+
|
|
257
|
+
def search_events(
|
|
258
|
+
self,
|
|
259
|
+
query: str,
|
|
260
|
+
session_id: str | None = None,
|
|
261
|
+
event_type: str | None = None,
|
|
262
|
+
limit: int = 50,
|
|
263
|
+
) -> list[dict[str, Any]]:
|
|
264
|
+
"""Search indexed events using SQLite FTS5 full-text matching."""
|
|
265
|
+
fts_query = sanitize_fts5_query(query)
|
|
266
|
+
with self._lock:
|
|
267
|
+
cur = self._conn.cursor()
|
|
268
|
+
conditions = ["events_fts MATCH ?"]
|
|
269
|
+
params: list[Any] = [fts_query]
|
|
270
|
+
|
|
271
|
+
if session_id is not None:
|
|
272
|
+
conditions.append("session_id = ?")
|
|
273
|
+
params.append(session_id)
|
|
274
|
+
if event_type is not None:
|
|
275
|
+
conditions.append("event_type = ?")
|
|
276
|
+
params.append(event_type)
|
|
277
|
+
|
|
278
|
+
where_clause = " AND ".join(conditions)
|
|
279
|
+
sql = f"""
|
|
280
|
+
SELECT
|
|
281
|
+
session_id,
|
|
282
|
+
seq,
|
|
283
|
+
event_type,
|
|
284
|
+
timestamp,
|
|
285
|
+
content,
|
|
286
|
+
tool_name,
|
|
287
|
+
tool_args,
|
|
288
|
+
tool_result,
|
|
289
|
+
snippet(events_fts, 4, '<b>', '</b>', '...', 15) AS content_snippet,
|
|
290
|
+
snippet(events_fts, 6, '<b>', '</b>', '...', 15) AS args_snippet,
|
|
291
|
+
snippet(events_fts, 7, '<b>', '</b>', '...', 15) AS result_snippet,
|
|
292
|
+
rank
|
|
293
|
+
FROM events_fts
|
|
294
|
+
WHERE {where_clause}
|
|
295
|
+
ORDER BY rank
|
|
296
|
+
LIMIT ?
|
|
297
|
+
"""
|
|
298
|
+
params.append(limit)
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
cur.execute(sql, params)
|
|
302
|
+
rows = [dict(r) for r in cur.fetchall()]
|
|
303
|
+
except sqlite3.OperationalError:
|
|
304
|
+
# Fallback to plain phrase search if complex FTS syntax failed
|
|
305
|
+
safe_fallback = f'"{query.replace(chr(34), chr(34)+chr(34))}"'
|
|
306
|
+
params[0] = safe_fallback
|
|
307
|
+
cur.execute(sql, params)
|
|
308
|
+
rows = [dict(r) for r in cur.fetchall()]
|
|
309
|
+
|
|
310
|
+
results: list[dict[str, Any]] = []
|
|
311
|
+
for row in rows:
|
|
312
|
+
snippet = (
|
|
313
|
+
row["content_snippet"]
|
|
314
|
+
or row["result_snippet"]
|
|
315
|
+
or row["args_snippet"]
|
|
316
|
+
or row["content"]
|
|
317
|
+
or ""
|
|
318
|
+
)
|
|
319
|
+
results.append(
|
|
320
|
+
{
|
|
321
|
+
"session_id": row["session_id"],
|
|
322
|
+
"seq": int(row["seq"]),
|
|
323
|
+
"event_type": row["event_type"],
|
|
324
|
+
"timestamp": row["timestamp"],
|
|
325
|
+
"content": row["content"],
|
|
326
|
+
"tool_name": row["tool_name"],
|
|
327
|
+
"tool_args": row["tool_args"],
|
|
328
|
+
"tool_result": row["tool_result"],
|
|
329
|
+
"snippet": snippet,
|
|
330
|
+
"rank": float(row["rank"]) if row["rank"] is not None else 0.0,
|
|
331
|
+
}
|
|
332
|
+
)
|
|
333
|
+
return results
|
|
334
|
+
|
|
335
|
+
def search_sessions(self, query: str, limit: int = 50) -> list[dict[str, Any]]:
|
|
336
|
+
"""Search cross-session corpus and return ranked sessions with matching snippets."""
|
|
337
|
+
fts_query = sanitize_fts5_query(query)
|
|
338
|
+
with self._lock:
|
|
339
|
+
cur = self._conn.cursor()
|
|
340
|
+
sql = """
|
|
341
|
+
SELECT
|
|
342
|
+
session_id,
|
|
343
|
+
seq,
|
|
344
|
+
event_type,
|
|
345
|
+
timestamp,
|
|
346
|
+
content,
|
|
347
|
+
snippet(events_fts, 4, '<b>', '</b>', '...', 15) AS content_snippet,
|
|
348
|
+
snippet(events_fts, 6, '<b>', '</b>', '...', 15) AS args_snippet,
|
|
349
|
+
snippet(events_fts, 7, '<b>', '</b>', '...', 15) AS result_snippet,
|
|
350
|
+
rank
|
|
351
|
+
FROM events_fts
|
|
352
|
+
WHERE events_fts MATCH ?
|
|
353
|
+
ORDER BY rank ASC
|
|
354
|
+
"""
|
|
355
|
+
try:
|
|
356
|
+
cur.execute(sql, (fts_query,))
|
|
357
|
+
event_rows = [dict(r) for r in cur.fetchall()]
|
|
358
|
+
except sqlite3.OperationalError:
|
|
359
|
+
safe_fallback = f'"{query.replace(chr(34), chr(34)+chr(34))}"'
|
|
360
|
+
cur.execute(sql, (safe_fallback,))
|
|
361
|
+
event_rows = [dict(r) for r in cur.fetchall()]
|
|
362
|
+
|
|
363
|
+
if not event_rows:
|
|
364
|
+
return []
|
|
365
|
+
|
|
366
|
+
# Group matches by session_id
|
|
367
|
+
session_matches: dict[str, list[dict[str, Any]]] = {}
|
|
368
|
+
for row in event_rows:
|
|
369
|
+
sess_id = str(row["session_id"])
|
|
370
|
+
if sess_id not in session_matches:
|
|
371
|
+
session_matches[sess_id] = []
|
|
372
|
+
session_matches[sess_id].append(row)
|
|
373
|
+
|
|
374
|
+
# Sort sessions by their best (lowest) match rank
|
|
375
|
+
sorted_sessions = sorted(
|
|
376
|
+
session_matches.items(),
|
|
377
|
+
key=lambda item: float(item[1][0]["rank"]) if item[1][0]["rank"] is not None else 0.0,
|
|
378
|
+
)[:limit]
|
|
379
|
+
|
|
380
|
+
results: list[dict[str, Any]] = []
|
|
381
|
+
for sess_id, matches in sorted_sessions:
|
|
382
|
+
best = matches[0]
|
|
383
|
+
rec = self.get_session_record(sess_id)
|
|
384
|
+
if rec is None:
|
|
385
|
+
continue
|
|
386
|
+
|
|
387
|
+
snippet = (
|
|
388
|
+
best["content_snippet"]
|
|
389
|
+
or best["result_snippet"]
|
|
390
|
+
or best["args_snippet"]
|
|
391
|
+
or best["content"]
|
|
392
|
+
or ""
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
results.append(
|
|
396
|
+
{
|
|
397
|
+
"session_id": sess_id,
|
|
398
|
+
"created_at": rec["created_at"],
|
|
399
|
+
"updated_at": rec["updated_at"],
|
|
400
|
+
"parent_session_id": rec["parent_session_id"],
|
|
401
|
+
"fork_seq": rec["fork_seq"],
|
|
402
|
+
"status": rec["status"],
|
|
403
|
+
"summary": rec["summary"],
|
|
404
|
+
"event_count": rec["event_count"],
|
|
405
|
+
"match_count": len(matches),
|
|
406
|
+
"best_match": {
|
|
407
|
+
"seq": int(best["seq"]),
|
|
408
|
+
"event_type": best["event_type"],
|
|
409
|
+
"timestamp": best["timestamp"],
|
|
410
|
+
"snippet": snippet,
|
|
411
|
+
"rank": float(best["rank"]) if best["rank"] is not None else 0.0,
|
|
412
|
+
},
|
|
413
|
+
}
|
|
414
|
+
)
|
|
415
|
+
return results
|
|
416
|
+
|
|
417
|
+
def get_session_trace(self, session_id: str) -> list[dict[str, Any]]:
|
|
418
|
+
"""Retrieve full ordered event trace for a session."""
|
|
419
|
+
with self._lock:
|
|
420
|
+
cur = self._conn.cursor()
|
|
421
|
+
cur.execute(
|
|
422
|
+
"""
|
|
423
|
+
SELECT payload
|
|
424
|
+
FROM events_raw
|
|
425
|
+
WHERE session_id = ?
|
|
426
|
+
ORDER BY seq ASC
|
|
427
|
+
""",
|
|
428
|
+
(session_id,),
|
|
429
|
+
)
|
|
430
|
+
rows = cur.fetchall()
|
|
431
|
+
trace: list[dict[str, Any]] = []
|
|
432
|
+
for row in rows:
|
|
433
|
+
try:
|
|
434
|
+
payload = json.loads(row["payload"])
|
|
435
|
+
trace.append(payload)
|
|
436
|
+
except (json.JSONDecodeError, KeyError):
|
|
437
|
+
continue
|
|
438
|
+
return trace
|
|
439
|
+
|
|
440
|
+
def get_session_record(self, session_id: str) -> dict[str, Any] | None:
|
|
441
|
+
"""Get top-level metadata record for a session."""
|
|
442
|
+
with self._lock:
|
|
443
|
+
cur = self._conn.cursor()
|
|
444
|
+
cur.execute("SELECT * FROM session_records WHERE session_id = ?", (session_id,))
|
|
445
|
+
row = cur.fetchone()
|
|
446
|
+
if row is None:
|
|
447
|
+
return None
|
|
448
|
+
return {
|
|
449
|
+
"session_id": row["session_id"],
|
|
450
|
+
"created_at": row["created_at"],
|
|
451
|
+
"updated_at": row["updated_at"],
|
|
452
|
+
"parent_session_id": row["parent_session_id"],
|
|
453
|
+
"fork_seq": row["fork_seq"],
|
|
454
|
+
"status": row["status"],
|
|
455
|
+
"summary": row["summary"],
|
|
456
|
+
"event_count": int(row["event_count"]),
|
|
457
|
+
"metadata": json.loads(row["metadata"]) if row["metadata"] else {},
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
def list_sessions(
|
|
461
|
+
self,
|
|
462
|
+
parent_session_id: str | None = None,
|
|
463
|
+
status: str | None = None,
|
|
464
|
+
limit: int = 50,
|
|
465
|
+
) -> list[dict[str, Any]]:
|
|
466
|
+
"""List session records with optional filtering."""
|
|
467
|
+
with self._lock:
|
|
468
|
+
cur = self._conn.cursor()
|
|
469
|
+
conditions: list[str] = []
|
|
470
|
+
params: list[Any] = []
|
|
471
|
+
|
|
472
|
+
if parent_session_id is not None:
|
|
473
|
+
conditions.append("parent_session_id = ?")
|
|
474
|
+
params.append(parent_session_id)
|
|
475
|
+
if status is not None:
|
|
476
|
+
conditions.append("status = ?")
|
|
477
|
+
params.append(status)
|
|
478
|
+
|
|
479
|
+
where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
|
480
|
+
sql = f"""
|
|
481
|
+
SELECT * FROM session_records
|
|
482
|
+
{where_clause}
|
|
483
|
+
ORDER BY updated_at DESC
|
|
484
|
+
LIMIT ?
|
|
485
|
+
"""
|
|
486
|
+
params.append(limit)
|
|
487
|
+
cur.execute(sql, params)
|
|
488
|
+
rows = cur.fetchall()
|
|
489
|
+
|
|
490
|
+
return [
|
|
491
|
+
{
|
|
492
|
+
"session_id": row["session_id"],
|
|
493
|
+
"created_at": row["created_at"],
|
|
494
|
+
"updated_at": row["updated_at"],
|
|
495
|
+
"parent_session_id": row["parent_session_id"],
|
|
496
|
+
"fork_seq": row["fork_seq"],
|
|
497
|
+
"status": row["status"],
|
|
498
|
+
"summary": row["summary"],
|
|
499
|
+
"event_count": int(row["event_count"]),
|
|
500
|
+
"metadata": json.loads(row["metadata"]) if row["metadata"] else {},
|
|
501
|
+
}
|
|
502
|
+
for row in rows
|
|
503
|
+
]
|
|
504
|
+
|
|
505
|
+
def get_session_lineage(self, session_id: str) -> dict[str, Any]:
|
|
506
|
+
"""Retrieve ancestry chain and direct descendant sessions."""
|
|
507
|
+
with self._lock:
|
|
508
|
+
cur = self._conn.cursor()
|
|
509
|
+
target = self.get_session_record(session_id)
|
|
510
|
+
if target is None:
|
|
511
|
+
raise ValueError(f"Session {session_id!r} not found in index")
|
|
512
|
+
|
|
513
|
+
ancestors: list[dict[str, Any]] = []
|
|
514
|
+
curr_parent = target["parent_session_id"]
|
|
515
|
+
while curr_parent:
|
|
516
|
+
parent_rec = self.get_session_record(curr_parent)
|
|
517
|
+
if parent_rec:
|
|
518
|
+
ancestors.append(parent_rec)
|
|
519
|
+
curr_parent = parent_rec["parent_session_id"]
|
|
520
|
+
else:
|
|
521
|
+
break
|
|
522
|
+
|
|
523
|
+
descendants = self.list_sessions(parent_session_id=session_id)
|
|
524
|
+
|
|
525
|
+
return {
|
|
526
|
+
"session": target,
|
|
527
|
+
"ancestors": ancestors,
|
|
528
|
+
"descendants": descendants,
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
# -----------------------------------------------------------------------------
|
|
533
|
+
# Module-level Convenience Functions
|
|
534
|
+
# -----------------------------------------------------------------------------
|
|
535
|
+
|
|
536
|
+
_DEFAULT_ENGINE: SessionQueryEngine | None = None
|
|
537
|
+
_DEFAULT_ENGINE_LOCK = threading.RLock()
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def get_default_engine(db_path: str | Path | None = None) -> SessionQueryEngine:
|
|
541
|
+
"""Get or create singleton default SessionQueryEngine."""
|
|
542
|
+
global _DEFAULT_ENGINE
|
|
543
|
+
with _DEFAULT_ENGINE_LOCK:
|
|
544
|
+
if _DEFAULT_ENGINE is None or db_path is not None:
|
|
545
|
+
path = db_path or os.environ.get("LOCAL_AGENT_SESSION_DB", ":memory:")
|
|
546
|
+
_DEFAULT_ENGINE = SessionQueryEngine(path)
|
|
547
|
+
return _DEFAULT_ENGINE
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def search_sessions(
|
|
551
|
+
query: str,
|
|
552
|
+
db_path: str | Path | None = None,
|
|
553
|
+
limit: int = 50,
|
|
554
|
+
) -> list[dict[str, Any]]:
|
|
555
|
+
"""Search cross-session corpus using SQLite FTS5."""
|
|
556
|
+
engine = get_default_engine(db_path) if db_path is None else SessionQueryEngine(db_path)
|
|
557
|
+
return engine.search_sessions(query, limit=limit)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def search_events(
|
|
561
|
+
query: str,
|
|
562
|
+
session_id: str | None = None,
|
|
563
|
+
event_type: str | None = None,
|
|
564
|
+
db_path: str | Path | None = None,
|
|
565
|
+
limit: int = 50,
|
|
566
|
+
) -> list[dict[str, Any]]:
|
|
567
|
+
"""Search event text using SQLite FTS5."""
|
|
568
|
+
engine = get_default_engine(db_path) if db_path is None else SessionQueryEngine(db_path)
|
|
569
|
+
return engine.search_events(query, session_id=session_id, event_type=event_type, limit=limit)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def get_session_trace(
|
|
573
|
+
session_id: str,
|
|
574
|
+
db_path: str | Path | None = None,
|
|
575
|
+
) -> list[dict[str, Any]]:
|
|
576
|
+
"""Retrieve complete event trace for session."""
|
|
577
|
+
engine = get_default_engine(db_path) if db_path is None else SessionQueryEngine(db_path)
|
|
578
|
+
return engine.get_session_trace(session_id)
|