subactor-shell 0.2.2__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.
@@ -0,0 +1,857 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import sqlite3
6
+ import time
7
+ import uuid
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .config import ensure_private_dir, ensure_private_file
12
+ from .models import Artifact, Message, Session, utc_now
13
+ from .token_budget import TokenUsage
14
+
15
+
16
+ class Store:
17
+ def __init__(self, db_path: Path):
18
+ self.db_path = db_path.expanduser()
19
+ ensure_private_dir(self.db_path.parent)
20
+ self._init_schema()
21
+ ensure_private_file(self.db_path)
22
+
23
+ def _connect(self) -> sqlite3.Connection:
24
+ connection = sqlite3.connect(self.db_path)
25
+ connection.row_factory = sqlite3.Row
26
+ connection.execute("PRAGMA foreign_keys = ON")
27
+ connection.execute("PRAGMA journal_mode = WAL")
28
+ connection.execute("PRAGMA synchronous = NORMAL")
29
+ return connection
30
+
31
+ def _init_schema(self) -> None:
32
+ with self._connect() as db:
33
+ db.executescript(
34
+ """
35
+ CREATE TABLE IF NOT EXISTS sessions (
36
+ id TEXT PRIMARY KEY,
37
+ name TEXT NOT NULL,
38
+ provider TEXT NOT NULL,
39
+ model TEXT NOT NULL,
40
+ created_at TEXT NOT NULL,
41
+ updated_at TEXT NOT NULL
42
+ );
43
+
44
+ CREATE TABLE IF NOT EXISTS messages (
45
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
46
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
47
+ role TEXT NOT NULL CHECK(role IN ('system', 'user', 'assistant')),
48
+ display_content TEXT NOT NULL,
49
+ context_content TEXT NOT NULL,
50
+ metadata_json TEXT NOT NULL DEFAULT '{}',
51
+ created_at TEXT NOT NULL
52
+ );
53
+ CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id, id);
54
+
55
+ CREATE TABLE IF NOT EXISTS artifacts (
56
+ id TEXT PRIMARY KEY,
57
+ original_path TEXT NOT NULL,
58
+ stored_path TEXT NOT NULL,
59
+ mime_type TEXT NOT NULL,
60
+ size INTEGER NOT NULL,
61
+ created_at TEXT NOT NULL
62
+ );
63
+ CREATE TABLE IF NOT EXISTS session_artifacts (
64
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
65
+ artifact_id TEXT NOT NULL REFERENCES artifacts(id) ON DELETE CASCADE,
66
+ attached_at TEXT NOT NULL,
67
+ PRIMARY KEY(session_id, artifact_id)
68
+ );
69
+
70
+ CREATE TABLE IF NOT EXISTS secret_bindings (
71
+ alias TEXT PRIMARY KEY,
72
+ secret_ref TEXT NOT NULL,
73
+ created_at TEXT NOT NULL,
74
+ updated_at TEXT NOT NULL
75
+ );
76
+ CREATE TABLE IF NOT EXISTS data_items (
77
+ name TEXT PRIMARY KEY,
78
+ kind TEXT NOT NULL CHECK(kind IN ('text', 'artifact')),
79
+ value TEXT NOT NULL,
80
+ created_at TEXT NOT NULL,
81
+ updated_at TEXT NOT NULL
82
+ );
83
+
84
+ CREATE TABLE IF NOT EXISTS session_state (
85
+ session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
86
+ state_json TEXT NOT NULL DEFAULT '{}',
87
+ updated_at TEXT NOT NULL
88
+ );
89
+
90
+ CREATE TABLE IF NOT EXISTS routing_decisions (
91
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
92
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
93
+ route TEXT NOT NULL,
94
+ reason TEXT NOT NULL,
95
+ intent_id TEXT NOT NULL DEFAULT '',
96
+ confidence REAL NOT NULL DEFAULT 0,
97
+ provider TEXT NOT NULL DEFAULT '',
98
+ model TEXT NOT NULL DEFAULT '',
99
+ candidates_json TEXT NOT NULL DEFAULT '[]',
100
+ metadata_json TEXT NOT NULL DEFAULT '{}',
101
+ created_at TEXT NOT NULL
102
+ );
103
+ CREATE INDEX IF NOT EXISTS idx_routing_session ON routing_decisions(session_id, id);
104
+
105
+ CREATE TABLE IF NOT EXISTS provider_usage (
106
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
107
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
108
+ provider TEXT NOT NULL,
109
+ model TEXT NOT NULL,
110
+ purpose TEXT NOT NULL,
111
+ input_tokens INTEGER NOT NULL DEFAULT 0,
112
+ cached_input_tokens INTEGER NOT NULL DEFAULT 0,
113
+ output_tokens INTEGER NOT NULL DEFAULT 0,
114
+ estimated INTEGER NOT NULL DEFAULT 0,
115
+ cost_usd REAL NOT NULL DEFAULT 0,
116
+ metadata_json TEXT NOT NULL DEFAULT '{}',
117
+ created_at TEXT NOT NULL
118
+ );
119
+ CREATE INDEX IF NOT EXISTS idx_usage_session ON provider_usage(session_id, id);
120
+
121
+ CREATE TABLE IF NOT EXISTS execution_plans (
122
+ id TEXT PRIMARY KEY,
123
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
124
+ intent_id TEXT NOT NULL,
125
+ status TEXT NOT NULL,
126
+ effect TEXT NOT NULL,
127
+ plan_json TEXT NOT NULL,
128
+ created_at TEXT NOT NULL,
129
+ updated_at TEXT NOT NULL
130
+ );
131
+ CREATE INDEX IF NOT EXISTS idx_plans_session ON execution_plans(session_id, updated_at);
132
+
133
+ CREATE TABLE IF NOT EXISTS execution_receipts (
134
+ id TEXT PRIMARY KEY,
135
+ plan_id TEXT NOT NULL,
136
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
137
+ ok INTEGER NOT NULL,
138
+ receipt_json TEXT NOT NULL,
139
+ created_at TEXT NOT NULL
140
+ );
141
+ CREATE INDEX IF NOT EXISTS idx_receipts_session ON execution_receipts(session_id, created_at);
142
+
143
+ CREATE TABLE IF NOT EXISTS router_feedback (
144
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
145
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
146
+ intent_id TEXT NOT NULL,
147
+ route TEXT NOT NULL,
148
+ success INTEGER NOT NULL,
149
+ metadata_json TEXT NOT NULL DEFAULT '{}',
150
+ created_at TEXT NOT NULL
151
+ );
152
+ CREATE INDEX IF NOT EXISTS idx_feedback_intent ON router_feedback(intent_id, route, id);
153
+
154
+ CREATE TABLE IF NOT EXISTS context_cache (
155
+ cache_key TEXT PRIMARY KEY,
156
+ payload_json TEXT NOT NULL,
157
+ expires_at REAL NOT NULL,
158
+ created_at TEXT NOT NULL
159
+ );
160
+ """
161
+ )
162
+
163
+ # Sessions and messages -------------------------------------------------
164
+ def create_session(
165
+ self,
166
+ name: str,
167
+ provider: str,
168
+ model: str,
169
+ session_id: str | None = None,
170
+ ) -> Session:
171
+ session_id = session_id or str(uuid.uuid4())
172
+ now = utc_now()
173
+ with self._connect() as db:
174
+ db.execute(
175
+ "INSERT INTO sessions(id, name, provider, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
176
+ (session_id, name, provider, model, now, now),
177
+ )
178
+ return Session(session_id, name, provider, model, now, now)
179
+
180
+ def get_session(self, session_id: str) -> Session | None:
181
+ with self._connect() as db:
182
+ row = db.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
183
+ return self._row_to_session(row) if row else None
184
+
185
+ def list_sessions(self, limit: int = 100) -> list[Session]:
186
+ with self._connect() as db:
187
+ rows = db.execute(
188
+ "SELECT * FROM sessions ORDER BY updated_at DESC LIMIT ?", (limit,)
189
+ ).fetchall()
190
+ return [self._row_to_session(row) for row in rows]
191
+
192
+ def update_session(
193
+ self,
194
+ session_id: str,
195
+ *,
196
+ name: str | None = None,
197
+ provider: str | None = None,
198
+ model: str | None = None,
199
+ ) -> Session:
200
+ current = self.get_session(session_id)
201
+ if not current:
202
+ raise KeyError(f"Nie ma sesji {session_id}")
203
+ now = utc_now()
204
+ values = (
205
+ name if name is not None else current.name,
206
+ provider if provider is not None else current.provider,
207
+ model if model is not None else current.model,
208
+ now,
209
+ session_id,
210
+ )
211
+ with self._connect() as db:
212
+ db.execute(
213
+ "UPDATE sessions SET name = ?, provider = ?, model = ?, updated_at = ? WHERE id = ?",
214
+ values,
215
+ )
216
+ updated = self.get_session(session_id)
217
+ assert updated is not None
218
+ return updated
219
+
220
+ def add_message(
221
+ self,
222
+ session_id: str,
223
+ role: str,
224
+ display_content: str,
225
+ context_content: str | None = None,
226
+ metadata: dict[str, Any] | None = None,
227
+ ) -> Message:
228
+ if role not in {"system", "user", "assistant"}:
229
+ raise ValueError(f"Nieprawidłowa rola: {role}")
230
+ now = utc_now()
231
+ context_content = display_content if context_content is None else context_content
232
+ metadata_json = json.dumps(metadata or {}, ensure_ascii=False, separators=(",", ":"))
233
+ with self._connect() as db:
234
+ cursor = db.execute(
235
+ """
236
+ INSERT INTO messages(session_id, role, display_content, context_content, metadata_json, created_at)
237
+ VALUES (?, ?, ?, ?, ?, ?)
238
+ """,
239
+ (session_id, role, display_content, context_content, metadata_json, now),
240
+ )
241
+ db.execute("UPDATE sessions SET updated_at = ? WHERE id = ?", (now, session_id))
242
+ message_id = int(cursor.lastrowid)
243
+ return Message(
244
+ message_id,
245
+ session_id,
246
+ role, # type: ignore[arg-type]
247
+ display_content,
248
+ context_content,
249
+ metadata or {},
250
+ now,
251
+ )
252
+
253
+ def list_messages(self, session_id: str) -> list[Message]:
254
+ with self._connect() as db:
255
+ rows = db.execute(
256
+ "SELECT * FROM messages WHERE session_id = ? ORDER BY id", (session_id,)
257
+ ).fetchall()
258
+ return [self._row_to_message(row) for row in rows]
259
+
260
+ def list_messages_recent(self, session_id: str, limit: int = 6) -> list[Message]:
261
+ if limit <= 0:
262
+ return []
263
+ with self._connect() as db:
264
+ rows = db.execute(
265
+ "SELECT * FROM messages WHERE session_id = ? ORDER BY id DESC LIMIT ?",
266
+ (session_id, limit),
267
+ ).fetchall()
268
+ return [self._row_to_message(row) for row in reversed(rows)]
269
+
270
+ # Artifacts, data, secret references -----------------------------------
271
+ def add_artifact(self, artifact: Artifact, session_id: str) -> None:
272
+ with self._connect() as db:
273
+ db.execute(
274
+ """
275
+ INSERT OR IGNORE INTO artifacts(id, original_path, stored_path, mime_type, size, created_at)
276
+ VALUES (?, ?, ?, ?, ?, ?)
277
+ """,
278
+ (
279
+ artifact.id,
280
+ artifact.original_path,
281
+ str(artifact.stored_path),
282
+ artifact.mime_type,
283
+ artifact.size,
284
+ artifact.created_at,
285
+ ),
286
+ )
287
+ db.execute(
288
+ "INSERT OR IGNORE INTO session_artifacts(session_id, artifact_id, attached_at) VALUES (?, ?, ?)",
289
+ (session_id, artifact.id, utc_now()),
290
+ )
291
+
292
+ def list_artifacts(self, session_id: str) -> list[Artifact]:
293
+ with self._connect() as db:
294
+ rows = db.execute(
295
+ """
296
+ SELECT a.* FROM artifacts a
297
+ JOIN session_artifacts sa ON sa.artifact_id = a.id
298
+ WHERE sa.session_id = ? ORDER BY sa.attached_at
299
+ """,
300
+ (session_id,),
301
+ ).fetchall()
302
+ return [self._row_to_artifact(row) for row in rows]
303
+
304
+ def get_artifact(self, artifact_id: str) -> Artifact | None:
305
+ with self._connect() as db:
306
+ row = db.execute("SELECT * FROM artifacts WHERE id = ?", (artifact_id,)).fetchone()
307
+ return self._row_to_artifact(row) if row else None
308
+
309
+ def set_data(self, name: str, kind: str, value: str) -> None:
310
+ if kind not in {"text", "artifact"}:
311
+ raise ValueError("kind danych musi być 'text' albo 'artifact'")
312
+ now = utc_now()
313
+ with self._connect() as db:
314
+ db.execute(
315
+ """
316
+ INSERT INTO data_items(name, kind, value, created_at, updated_at)
317
+ VALUES (?, ?, ?, ?, ?)
318
+ ON CONFLICT(name) DO UPDATE SET
319
+ kind = excluded.kind, value = excluded.value, updated_at = excluded.updated_at
320
+ """,
321
+ (name, kind, value, now, now),
322
+ )
323
+
324
+ def get_data(self, name: str) -> tuple[str, str] | None:
325
+ with self._connect() as db:
326
+ row = db.execute("SELECT kind, value FROM data_items WHERE name = ?", (name,)).fetchone()
327
+ return (str(row["kind"]), str(row["value"])) if row else None
328
+
329
+ def list_data(self) -> list[tuple[str, str, str]]:
330
+ with self._connect() as db:
331
+ rows = db.execute("SELECT name, kind, value FROM data_items ORDER BY name").fetchall()
332
+ return [(str(row["name"]), str(row["kind"]), str(row["value"])) for row in rows]
333
+
334
+ def delete_data(self, name: str) -> bool:
335
+ with self._connect() as db:
336
+ cursor = db.execute("DELETE FROM data_items WHERE name = ?", (name,))
337
+ return cursor.rowcount > 0
338
+
339
+ def bind_secret(self, alias: str, secret_ref: str) -> None:
340
+ now = utc_now()
341
+ with self._connect() as db:
342
+ db.execute(
343
+ """
344
+ INSERT INTO secret_bindings(alias, secret_ref, created_at, updated_at)
345
+ VALUES (?, ?, ?, ?)
346
+ ON CONFLICT(alias) DO UPDATE SET
347
+ secret_ref = excluded.secret_ref, updated_at = excluded.updated_at
348
+ """,
349
+ (alias, secret_ref, now, now),
350
+ )
351
+
352
+ def get_secret_binding(self, alias: str) -> str | None:
353
+ with self._connect() as db:
354
+ row = db.execute(
355
+ "SELECT secret_ref FROM secret_bindings WHERE alias = ?", (alias,)
356
+ ).fetchone()
357
+ return str(row["secret_ref"]) if row else None
358
+
359
+ def list_secret_bindings(self) -> list[tuple[str, str]]:
360
+ with self._connect() as db:
361
+ rows = db.execute(
362
+ "SELECT alias, secret_ref FROM secret_bindings ORDER BY alias"
363
+ ).fetchall()
364
+ return [(str(row["alias"]), str(row["secret_ref"])) for row in rows]
365
+
366
+ def unbind_secret(self, alias: str) -> bool:
367
+ with self._connect() as db:
368
+ cursor = db.execute("DELETE FROM secret_bindings WHERE alias = ?", (alias,))
369
+ return cursor.rowcount > 0
370
+
371
+ # Compact conversation state ------------------------------------------
372
+ def set_session_state(self, session_id: str, state: dict[str, Any]) -> None:
373
+ now = utc_now()
374
+ encoded = json.dumps(state, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
375
+ with self._connect() as db:
376
+ db.execute(
377
+ """
378
+ INSERT INTO session_state(session_id, state_json, updated_at)
379
+ VALUES (?, ?, ?)
380
+ ON CONFLICT(session_id) DO UPDATE SET
381
+ state_json = excluded.state_json, updated_at = excluded.updated_at
382
+ """,
383
+ (session_id, encoded, now),
384
+ )
385
+
386
+ def get_session_state(self, session_id: str) -> dict[str, Any]:
387
+ with self._connect() as db:
388
+ row = db.execute(
389
+ "SELECT state_json FROM session_state WHERE session_id = ?", (session_id,)
390
+ ).fetchone()
391
+ if not row:
392
+ return {}
393
+ try:
394
+ value = json.loads(row["state_json"] or "{}")
395
+ except json.JSONDecodeError:
396
+ return {}
397
+ return value if isinstance(value, dict) else {}
398
+
399
+ # Routing and usage ----------------------------------------------------
400
+ def record_routing_decision(
401
+ self,
402
+ session_id: str,
403
+ *,
404
+ route: str,
405
+ reason: str,
406
+ intent_id: str = "",
407
+ confidence: float = 0.0,
408
+ provider: str = "",
409
+ model: str = "",
410
+ candidates: list[dict[str, Any]] | None = None,
411
+ metadata: dict[str, Any] | None = None,
412
+ ) -> int:
413
+ with self._connect() as db:
414
+ cursor = db.execute(
415
+ """
416
+ INSERT INTO routing_decisions(
417
+ session_id, route, reason, intent_id, confidence, provider, model,
418
+ candidates_json, metadata_json, created_at
419
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
420
+ """,
421
+ (
422
+ session_id,
423
+ route,
424
+ reason,
425
+ intent_id,
426
+ float(confidence),
427
+ provider,
428
+ model,
429
+ json.dumps(candidates or [], ensure_ascii=False, separators=(",", ":")),
430
+ json.dumps(metadata or {}, ensure_ascii=False, separators=(",", ":")),
431
+ utc_now(),
432
+ ),
433
+ )
434
+ return int(cursor.lastrowid)
435
+
436
+ def last_routing_decision(self, session_id: str) -> dict[str, Any] | None:
437
+ with self._connect() as db:
438
+ row = db.execute(
439
+ "SELECT * FROM routing_decisions WHERE session_id = ? ORDER BY id DESC LIMIT 1",
440
+ (session_id,),
441
+ ).fetchone()
442
+ return self._routing_row(row) if row else None
443
+
444
+ def list_routing_decisions(self, session_id: str, limit: int = 100) -> list[dict[str, Any]]:
445
+ with self._connect() as db:
446
+ rows = db.execute(
447
+ "SELECT * FROM routing_decisions WHERE session_id = ? ORDER BY id DESC LIMIT ?",
448
+ (session_id, limit),
449
+ ).fetchall()
450
+ return [self._routing_row(row) for row in rows]
451
+
452
+ def record_provider_usage(
453
+ self,
454
+ session_id: str,
455
+ *,
456
+ provider: str,
457
+ model: str,
458
+ purpose: str,
459
+ usage: TokenUsage,
460
+ input_cost_per_million: float = 0.0,
461
+ cached_input_cost_per_million: float = 0.0,
462
+ output_cost_per_million: float = 0.0,
463
+ metadata: dict[str, Any] | None = None,
464
+ ) -> float:
465
+ input_tokens = max(0, int(usage.input_tokens))
466
+ cached = min(input_tokens, max(0, int(usage.cached_input_tokens)))
467
+ uncached = input_tokens - cached
468
+ cost = (
469
+ uncached * float(input_cost_per_million)
470
+ + cached * float(cached_input_cost_per_million)
471
+ + max(0, int(usage.output_tokens)) * float(output_cost_per_million)
472
+ ) / 1_000_000
473
+ with self._connect() as db:
474
+ db.execute(
475
+ """
476
+ INSERT INTO provider_usage(
477
+ session_id, provider, model, purpose, input_tokens, cached_input_tokens,
478
+ output_tokens, estimated, cost_usd, metadata_json, created_at
479
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
480
+ """,
481
+ (
482
+ session_id,
483
+ provider,
484
+ model,
485
+ purpose,
486
+ input_tokens,
487
+ cached,
488
+ max(0, int(usage.output_tokens)),
489
+ 1 if usage.estimated else 0,
490
+ cost,
491
+ json.dumps(metadata or {}, ensure_ascii=False, separators=(",", ":")),
492
+ utc_now(),
493
+ ),
494
+ )
495
+ return cost
496
+
497
+ def usage_summary(self, session_id: str | None = None) -> dict[str, Any]:
498
+ where = "WHERE session_id = ?" if session_id else ""
499
+ params: tuple[Any, ...] = (session_id,) if session_id else ()
500
+ with self._connect() as db:
501
+ total = db.execute(
502
+ f"""
503
+ SELECT COUNT(*) AS calls,
504
+ COALESCE(SUM(input_tokens), 0) AS input_tokens,
505
+ COALESCE(SUM(cached_input_tokens), 0) AS cached_input_tokens,
506
+ COALESCE(SUM(output_tokens), 0) AS output_tokens,
507
+ COALESCE(SUM(cost_usd), 0) AS cost_usd,
508
+ COALESCE(SUM(estimated), 0) AS estimated_calls
509
+ FROM provider_usage {where}
510
+ """,
511
+ params,
512
+ ).fetchone()
513
+ by_provider = db.execute(
514
+ f"""
515
+ SELECT provider, model, purpose, COUNT(*) AS calls,
516
+ COALESCE(SUM(input_tokens), 0) AS input_tokens,
517
+ COALESCE(SUM(cached_input_tokens), 0) AS cached_input_tokens,
518
+ COALESCE(SUM(output_tokens), 0) AS output_tokens,
519
+ COALESCE(SUM(cost_usd), 0) AS cost_usd
520
+ FROM provider_usage {where}
521
+ GROUP BY provider, model, purpose
522
+ ORDER BY cost_usd DESC, calls DESC
523
+ """,
524
+ params,
525
+ ).fetchall()
526
+ route_where = "WHERE session_id = ?" if session_id else ""
527
+ route_rows = db.execute(
528
+ f"SELECT route, COUNT(*) AS count FROM routing_decisions {route_where} GROUP BY route ORDER BY count DESC",
529
+ params,
530
+ ).fetchall()
531
+ calls = int(total["calls"] or 0)
532
+ routes = {str(row["route"]): int(row["count"]) for row in route_rows}
533
+ llm_free = sum(routes.get(name, 0) for name in ("deterministic", "cache"))
534
+ route_total = sum(routes.values())
535
+ return {
536
+ "scope": session_id or "all",
537
+ "calls": calls,
538
+ "input_tokens": int(total["input_tokens"] or 0),
539
+ "cached_input_tokens": int(total["cached_input_tokens"] or 0),
540
+ "output_tokens": int(total["output_tokens"] or 0),
541
+ "estimated_calls": int(total["estimated_calls"] or 0),
542
+ "cost_usd": round(float(total["cost_usd"] or 0.0), 8),
543
+ "routes": routes,
544
+ "llm_free_route_share": round(llm_free / route_total, 4) if route_total else 0.0,
545
+ "by_provider": [
546
+ {
547
+ "provider": str(row["provider"]),
548
+ "model": str(row["model"]),
549
+ "purpose": str(row["purpose"]),
550
+ "calls": int(row["calls"]),
551
+ "input_tokens": int(row["input_tokens"]),
552
+ "cached_input_tokens": int(row["cached_input_tokens"]),
553
+ "output_tokens": int(row["output_tokens"]),
554
+ "cost_usd": round(float(row["cost_usd"]), 8),
555
+ }
556
+ for row in by_provider
557
+ ],
558
+ }
559
+
560
+ # Plans and receipts ---------------------------------------------------
561
+ def save_execution_plan(self, plan: dict[str, Any]) -> None:
562
+ now = utc_now()
563
+ created_at = str(plan.get("created_at", now))
564
+ encoded = json.dumps(plan, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
565
+ with self._connect() as db:
566
+ db.execute(
567
+ """
568
+ INSERT INTO execution_plans(id, session_id, intent_id, status, effect, plan_json, created_at, updated_at)
569
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
570
+ ON CONFLICT(id) DO UPDATE SET
571
+ status = excluded.status, effect = excluded.effect,
572
+ plan_json = excluded.plan_json, updated_at = excluded.updated_at
573
+ """,
574
+ (
575
+ str(plan["id"]),
576
+ str(plan["session_id"]),
577
+ str(plan.get("intent_id", "")),
578
+ str(plan.get("status", "planned")),
579
+ str(plan.get("effect", "read")),
580
+ encoded,
581
+ created_at,
582
+ now,
583
+ ),
584
+ )
585
+
586
+ def get_execution_plan(self, plan_id: str) -> dict[str, Any] | None:
587
+ with self._connect() as db:
588
+ row = db.execute(
589
+ "SELECT plan_json, status FROM execution_plans WHERE id = ?", (plan_id,)
590
+ ).fetchone()
591
+ if not row:
592
+ return None
593
+ payload = json.loads(row["plan_json"])
594
+ payload["status"] = str(row["status"])
595
+ return payload
596
+
597
+ def list_execution_plans(
598
+ self, session_id: str | None = None, limit: int = 100
599
+ ) -> list[dict[str, Any]]:
600
+ with self._connect() as db:
601
+ if session_id:
602
+ rows = db.execute(
603
+ "SELECT plan_json, status FROM execution_plans WHERE session_id = ? ORDER BY updated_at DESC LIMIT ?",
604
+ (session_id, limit),
605
+ ).fetchall()
606
+ else:
607
+ rows = db.execute(
608
+ "SELECT plan_json, status FROM execution_plans ORDER BY updated_at DESC LIMIT ?",
609
+ (limit,),
610
+ ).fetchall()
611
+ result: list[dict[str, Any]] = []
612
+ for row in rows:
613
+ payload = json.loads(row["plan_json"])
614
+ payload["status"] = str(row["status"])
615
+ result.append(payload)
616
+ return result
617
+
618
+ def update_plan_status(self, plan_id: str, status: str) -> None:
619
+ payload = self.get_execution_plan(plan_id)
620
+ if not payload:
621
+ raise KeyError(f"Nie ma planu {plan_id}")
622
+ payload["status"] = status
623
+ now = utc_now()
624
+ with self._connect() as db:
625
+ db.execute(
626
+ "UPDATE execution_plans SET status = ?, plan_json = ?, updated_at = ? WHERE id = ?",
627
+ (
628
+ status,
629
+ json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")),
630
+ now,
631
+ plan_id,
632
+ ),
633
+ )
634
+
635
+ def save_execution_receipt(self, receipt: dict[str, Any]) -> None:
636
+ with self._connect() as db:
637
+ db.execute(
638
+ """
639
+ INSERT OR REPLACE INTO execution_receipts(id, plan_id, session_id, ok, receipt_json, created_at)
640
+ VALUES (?, ?, ?, ?, ?, ?)
641
+ """,
642
+ (
643
+ str(receipt["id"]),
644
+ str(receipt["plan_id"]),
645
+ str(receipt["session_id"]),
646
+ 1 if receipt.get("ok") else 0,
647
+ json.dumps(receipt, ensure_ascii=False, sort_keys=True, separators=(",", ":")),
648
+ str(receipt.get("created_at", utc_now())),
649
+ ),
650
+ )
651
+
652
+ def get_execution_receipt(self, receipt_id: str) -> dict[str, Any] | None:
653
+ with self._connect() as db:
654
+ row = db.execute(
655
+ "SELECT receipt_json FROM execution_receipts WHERE id = ?", (receipt_id,)
656
+ ).fetchone()
657
+ return json.loads(row["receipt_json"]) if row else None
658
+
659
+ def list_execution_receipts(
660
+ self, session_id: str | None = None, limit: int = 100
661
+ ) -> list[dict[str, Any]]:
662
+ with self._connect() as db:
663
+ if session_id:
664
+ rows = db.execute(
665
+ "SELECT receipt_json FROM execution_receipts WHERE session_id = ? ORDER BY created_at DESC LIMIT ?",
666
+ (session_id, limit),
667
+ ).fetchall()
668
+ else:
669
+ rows = db.execute(
670
+ "SELECT receipt_json FROM execution_receipts ORDER BY created_at DESC LIMIT ?",
671
+ (limit,),
672
+ ).fetchall()
673
+ return [json.loads(row["receipt_json"]) for row in rows]
674
+
675
+ def record_router_feedback(
676
+ self,
677
+ session_id: str,
678
+ *,
679
+ intent_id: str,
680
+ route: str,
681
+ success: bool,
682
+ metadata: dict[str, Any] | None = None,
683
+ ) -> None:
684
+ with self._connect() as db:
685
+ db.execute(
686
+ """
687
+ INSERT INTO router_feedback(session_id, intent_id, route, success, metadata_json, created_at)
688
+ VALUES (?, ?, ?, ?, ?, ?)
689
+ """,
690
+ (
691
+ session_id,
692
+ intent_id,
693
+ route,
694
+ 1 if success else 0,
695
+ json.dumps(metadata or {}, ensure_ascii=False, separators=(",", ":")),
696
+ utc_now(),
697
+ ),
698
+ )
699
+
700
+ def historical_success(self, intent_id: str, route: str = "") -> float | None:
701
+ clause = "WHERE intent_id = ?"
702
+ params: list[Any] = [intent_id]
703
+ if route:
704
+ clause += " AND route = ?"
705
+ params.append(route)
706
+ with self._connect() as db:
707
+ row = db.execute(
708
+ f"SELECT COUNT(*) AS n, AVG(success) AS rate FROM router_feedback {clause}",
709
+ tuple(params),
710
+ ).fetchone()
711
+ if not row or int(row["n"] or 0) < 3:
712
+ return None
713
+ return float(row["rate"] or 0.0)
714
+
715
+ # Safe IntentIR cache --------------------------------------------------
716
+ def cache_get(self, key: str) -> dict[str, Any] | None:
717
+ now = time.time()
718
+ with self._connect() as db:
719
+ row = db.execute(
720
+ "SELECT payload_json, expires_at FROM context_cache WHERE cache_key = ?", (key,)
721
+ ).fetchone()
722
+ if row and float(row["expires_at"]) < now:
723
+ db.execute("DELETE FROM context_cache WHERE cache_key = ?", (key,))
724
+ row = None
725
+ if not row:
726
+ return None
727
+ try:
728
+ payload = json.loads(row["payload_json"])
729
+ except json.JSONDecodeError:
730
+ return None
731
+ return payload if isinstance(payload, dict) else None
732
+
733
+ def cache_set(self, key: str, payload: dict[str, Any], ttl_seconds: int = 86_400) -> None:
734
+ with self._connect() as db:
735
+ db.execute(
736
+ """
737
+ INSERT OR REPLACE INTO context_cache(cache_key, payload_json, expires_at, created_at)
738
+ VALUES (?, ?, ?, ?)
739
+ """,
740
+ (
741
+ key,
742
+ json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")),
743
+ time.time() + max(1, int(ttl_seconds)),
744
+ utc_now(),
745
+ ),
746
+ )
747
+
748
+ # State fingerprint and export ----------------------------------------
749
+ def state_fingerprint(self) -> str:
750
+ with self._connect() as db:
751
+ data = [tuple(row) for row in db.execute(
752
+ "SELECT name, kind, value, updated_at FROM data_items ORDER BY name"
753
+ ).fetchall()]
754
+ bindings = [tuple(row) for row in db.execute(
755
+ "SELECT alias, secret_ref, updated_at FROM secret_bindings ORDER BY alias"
756
+ ).fetchall()]
757
+ artifacts = [tuple(row) for row in db.execute(
758
+ "SELECT id, size FROM artifacts ORDER BY id"
759
+ ).fetchall()]
760
+ encoded = json.dumps(
761
+ {"data": data, "bindings": bindings, "artifacts": artifacts},
762
+ ensure_ascii=False,
763
+ sort_keys=True,
764
+ separators=(",", ":"),
765
+ )
766
+ return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
767
+
768
+ def export_session(self, session_id: str) -> dict[str, Any]:
769
+ session = self.get_session(session_id)
770
+ if not session:
771
+ raise KeyError(f"Nie ma sesji {session_id}")
772
+ return {
773
+ "session": {
774
+ "id": session.id,
775
+ "name": session.name,
776
+ "provider": session.provider,
777
+ "model": session.model,
778
+ "created_at": session.created_at,
779
+ "updated_at": session.updated_at,
780
+ },
781
+ "working_state": self.get_session_state(session_id),
782
+ "messages": [
783
+ {
784
+ "id": msg.id,
785
+ "role": msg.role,
786
+ "content": msg.display_content,
787
+ "metadata": msg.metadata,
788
+ "created_at": msg.created_at,
789
+ }
790
+ for msg in self.list_messages(session_id)
791
+ ],
792
+ "artifacts": [
793
+ {
794
+ "id": item.id,
795
+ "original_path": item.original_path,
796
+ "stored_path": str(item.stored_path),
797
+ "mime_type": item.mime_type,
798
+ "size": item.size,
799
+ "created_at": item.created_at,
800
+ }
801
+ for item in self.list_artifacts(session_id)
802
+ ],
803
+ "routing": self.list_routing_decisions(session_id),
804
+ "plans": self.list_execution_plans(session_id),
805
+ "receipts": self.list_execution_receipts(session_id),
806
+ "usage": self.usage_summary(session_id),
807
+ }
808
+
809
+ @staticmethod
810
+ def _row_to_session(row: sqlite3.Row) -> Session:
811
+ return Session(
812
+ id=str(row["id"]),
813
+ name=str(row["name"]),
814
+ provider=str(row["provider"]),
815
+ model=str(row["model"]),
816
+ created_at=str(row["created_at"]),
817
+ updated_at=str(row["updated_at"]),
818
+ )
819
+
820
+ @staticmethod
821
+ def _row_to_message(row: sqlite3.Row) -> Message:
822
+ return Message(
823
+ id=int(row["id"]),
824
+ session_id=str(row["session_id"]),
825
+ role=str(row["role"]), # type: ignore[arg-type]
826
+ display_content=str(row["display_content"]),
827
+ context_content=str(row["context_content"]),
828
+ metadata=json.loads(row["metadata_json"] or "{}"),
829
+ created_at=str(row["created_at"]),
830
+ )
831
+
832
+ @staticmethod
833
+ def _row_to_artifact(row: sqlite3.Row) -> Artifact:
834
+ return Artifact(
835
+ id=str(row["id"]),
836
+ original_path=str(row["original_path"]),
837
+ stored_path=Path(row["stored_path"]),
838
+ mime_type=str(row["mime_type"]),
839
+ size=int(row["size"]),
840
+ created_at=str(row["created_at"]),
841
+ )
842
+
843
+ @staticmethod
844
+ def _routing_row(row: sqlite3.Row) -> dict[str, Any]:
845
+ return {
846
+ "id": int(row["id"]),
847
+ "session_id": str(row["session_id"]),
848
+ "route": str(row["route"]),
849
+ "reason": str(row["reason"]),
850
+ "intent_id": str(row["intent_id"]),
851
+ "confidence": float(row["confidence"]),
852
+ "provider": str(row["provider"]),
853
+ "model": str(row["model"]),
854
+ "candidates": json.loads(row["candidates_json"] or "[]"),
855
+ "metadata": json.loads(row["metadata_json"] or "{}"),
856
+ "created_at": str(row["created_at"]),
857
+ }