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.
Files changed (101) hide show
  1. local_coding_agent/__init__.py +314 -0
  2. local_coding_agent/__main__.py +5 -0
  3. local_coding_agent/acp_server/__init__.py +38 -0
  4. local_coding_agent/acp_server/_codec.py +95 -0
  5. local_coding_agent/acp_server/_protocol.py +13 -0
  6. local_coding_agent/acp_server/_server.py +663 -0
  7. local_coding_agent/acp_server/_session.py +24 -0
  8. local_coding_agent/acp_server/_tools.py +126 -0
  9. local_coding_agent/ast_compactor.py +130 -0
  10. local_coding_agent/atomizer.py +77 -0
  11. local_coding_agent/benchmark/__init__.py +57 -0
  12. local_coding_agent/benchmark/_cases.py +307 -0
  13. local_coding_agent/benchmark/_judge.py +175 -0
  14. local_coding_agent/benchmark/_oracles.py +157 -0
  15. local_coding_agent/benchmark/_runner.py +259 -0
  16. local_coding_agent/benchmark/_stats.py +22 -0
  17. local_coding_agent/benchmark/_summary.py +143 -0
  18. local_coding_agent/benchmark_oracle_worker.py +169 -0
  19. local_coding_agent/calibration.py +154 -0
  20. local_coding_agent/capability.py +450 -0
  21. local_coding_agent/chat_compaction.py +276 -0
  22. local_coding_agent/cli/__init__.py +12 -0
  23. local_coding_agent/cli/_dispatch.py +377 -0
  24. local_coding_agent/cli/_handlers.py +320 -0
  25. local_coding_agent/cli/_handlers2.py +725 -0
  26. local_coding_agent/cli/_input.py +61 -0
  27. local_coding_agent/cli/_main.py +159 -0
  28. local_coding_agent/cli/_parser.py +327 -0
  29. local_coding_agent/context_manager.py +155 -0
  30. local_coding_agent/controller/__init__.py +18 -0
  31. local_coding_agent/controller/_constants.py +108 -0
  32. local_coding_agent/controller/_controller.py +1008 -0
  33. local_coding_agent/controller/_post_apply.py +216 -0
  34. local_coding_agent/delegator.py +180 -0
  35. local_coding_agent/desktop/__init__.py +8 -0
  36. local_coding_agent/desktop/app.py +100 -0
  37. local_coding_agent/desktop/assets/lucide.min.js +12 -0
  38. local_coding_agent/desktop/assets/tailwind.css +1 -0
  39. local_coding_agent/desktop/client_js.py +1271 -0
  40. local_coding_agent/desktop/components.py +555 -0
  41. local_coding_agent/desktop/server/__init__.py +37 -0
  42. local_coding_agent/desktop/server/_handlers.py +2178 -0
  43. local_coding_agent/desktop/server/_models.py +204 -0
  44. local_coding_agent/desktop/server/_server.py +347 -0
  45. local_coding_agent/desktop/server/_telemetry.py +87 -0
  46. local_coding_agent/desktop/styles.py +76 -0
  47. local_coding_agent/desktop/ui.py +53 -0
  48. local_coding_agent/doctor.py +413 -0
  49. local_coding_agent/hooks/__init__.py +19 -0
  50. local_coding_agent/hooks/_adapters.py +458 -0
  51. local_coding_agent/hooks/_bridge.py +346 -0
  52. local_coding_agent/hooks/_decision.py +26 -0
  53. local_coding_agent/lsp/__init__.py +60 -0
  54. local_coding_agent/lsp/_client.py +322 -0
  55. local_coding_agent/lsp/_fallback.py +297 -0
  56. local_coding_agent/lsp/_manager.py +173 -0
  57. local_coding_agent/lsp/_mock.py +200 -0
  58. local_coding_agent/lsp/_types.py +398 -0
  59. local_coding_agent/mcp_config.py +398 -0
  60. local_coding_agent/mcp_server.py +377 -0
  61. local_coding_agent/memory.py +140 -0
  62. local_coding_agent/mode_router.py +200 -0
  63. local_coding_agent/model_scanner.py +525 -0
  64. local_coding_agent/monitor.py +635 -0
  65. local_coding_agent/observation_policy.py +184 -0
  66. local_coding_agent/ollama_adapter.py +736 -0
  67. local_coding_agent/plan_mode.py +653 -0
  68. local_coding_agent/prescriptions.py +278 -0
  69. local_coding_agent/profiles.py +239 -0
  70. local_coding_agent/repository_tools.py +782 -0
  71. local_coding_agent/ripgrep.py +326 -0
  72. local_coding_agent/semantic_linter.py +122 -0
  73. local_coding_agent/service.py +405 -0
  74. local_coding_agent/session_events.py +684 -0
  75. local_coding_agent/session_query.py +578 -0
  76. local_coding_agent/skill_config.py +426 -0
  77. local_coding_agent/smoke.py +268 -0
  78. local_coding_agent/speculative_racing.py +85 -0
  79. local_coding_agent/spill.py +258 -0
  80. local_coding_agent/stats.py +255 -0
  81. local_coding_agent/stdio.py +132 -0
  82. local_coding_agent/subagent.py +516 -0
  83. local_coding_agent/task.py +90 -0
  84. local_coding_agent/task_store.py +142 -0
  85. local_coding_agent/tasks.py +240 -0
  86. local_coding_agent/terminal/__init__.py +35 -0
  87. local_coding_agent/terminal/_manager.py +156 -0
  88. local_coding_agent/terminal/_process.py +148 -0
  89. local_coding_agent/terminal/_session.py +452 -0
  90. local_coding_agent/terminal/_tools.py +233 -0
  91. local_coding_agent/terminal/_types.py +60 -0
  92. local_coding_agent/tool_call_parser.py +178 -0
  93. local_coding_agent/validators.py +563 -0
  94. local_coding_agent/vram_fit.py +240 -0
  95. local_coding_agent/worker_pool.py +516 -0
  96. local_coding_agent-1.1.0.dist-info/METADATA +202 -0
  97. local_coding_agent-1.1.0.dist-info/RECORD +101 -0
  98. local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
  99. local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
  100. local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
  101. 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)