concierge-graph 3.8.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.
storage/store.py ADDED
@@ -0,0 +1,802 @@
1
+ """
2
+ storage/store.py - Grafo Concierge v3.8.0 (Absolute Solidity)
3
+
4
+ Unified Facade that composes:
5
+ - ConnectionManager (connection.py) -> WAL, busy_timeout, serialized queue
6
+ - SchemaManager (schema.py) -> DDL, CHECK constraints, FTS5 triggers
7
+ - GraphLogic (logic.py) -> decay, centrality, recency, FTS5
8
+
9
+ API consistent with MCP v3.8 Tools:
10
+ concierge_resume -> get_project / get_project_stats
11
+ concierge_commit -> create_commit + touch_node_commit
12
+ concierge_search -> fts_search / hybrid_search_score_batch
13
+ concierge_mine -> create_node / find_node_by_hash
14
+ concierge_register -> create_project
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import logging
21
+ import sqlite3
22
+ from datetime import datetime
23
+ from pathlib import Path
24
+ from typing import Any, Callable, Optional, TYPE_CHECKING
25
+
26
+ from storage.connection import ConnectionManager
27
+
28
+ if TYPE_CHECKING:
29
+ from core.config import ConciergeConfig
30
+ from storage.schema import SchemaManager, VALID_NODE_TYPES, VALID_PRIVACY_LEVELS, VALID_STATUSES
31
+ from storage.logic import GraphLogic, TrajectoryNotFoundError, InvalidTransitionError
32
+
33
+ logger = logging.getLogger("grafo-concierge.store")
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Facade exceptions
38
+ # ---------------------------------------------------------------------------
39
+
40
+ class ProjectNotFoundError(Exception):
41
+ """Project not found by UUID or folder_name."""
42
+
43
+ class NodeNotFoundError(Exception):
44
+ """Node not found by the provided ID."""
45
+
46
+ class CommitValidationError(Exception):
47
+ """Required fields missing in the commit."""
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # SqliteStore — Unified Facade
52
+ # ---------------------------------------------------------------------------
53
+
54
+ class SqliteStore:
55
+ """SQLite persistence facade for Grafo Concierge v3.8.
56
+
57
+ In __init__, the schema is verified and applied automatically.
58
+ The end user only interacts with this class.
59
+
60
+ Args:
61
+ db_path: Path to the .db (default: <project_root>/data/concierge.db).
62
+ """
63
+
64
+ def __init__(self, db_path: str | None = None, config: Optional["ConciergeConfig"] = None) -> None:
65
+ if db_path is None:
66
+ db_path = str(Path(__file__).parent.parent.resolve() / "data" / "concierge.db")
67
+ resolved = str(Path(db_path).expanduser().absolute())
68
+ Path(resolved).parent.mkdir(parents=True, exist_ok=True)
69
+
70
+ # 1. Schema FIRST (applies DDL + FTS5 + triggers before any persistent connection)
71
+ # The temporary connection is opened and closed here, without competing with the queue.
72
+ self._boot_schema(resolved)
73
+
74
+ # 2. Connections (WAL + busy_timeout + serialized queue) - starts AFTER schema is ready
75
+ self._conn_mgr = ConnectionManager(resolved)
76
+ self._conn_mgr.start()
77
+
78
+ # 3. Intelligence (centrality, recency, decay, FTS5, CTE)
79
+ # Now passes ConciergeConfig so that weights obey user config
80
+ self._logic = GraphLogic(self._conn_mgr, config=config)
81
+
82
+ logger.info("SqliteStore initialized: %s", resolved)
83
+
84
+ def _boot_schema(self, db_path: str) -> None:
85
+ """Opens temporary connection to apply the schema idempotently."""
86
+ conn = sqlite3.connect(db_path)
87
+ try:
88
+ conn.execute("PRAGMA journal_mode=WAL;")
89
+ conn.execute("PRAGMA busy_timeout=5000;")
90
+ conn.execute("PRAGMA foreign_keys=ON;")
91
+ mgr = SchemaManager(conn)
92
+ mgr.apply_full_schema()
93
+
94
+ # Verification log
95
+ tables = mgr.verify_tables_exist()
96
+ missing = [t for t, exists in tables.items() if not exists]
97
+ if missing:
98
+ logger.error("Missing tables after boot: %s", missing)
99
+ raise RuntimeError(f"Incomplete schema: {missing}")
100
+
101
+ triggers = mgr.verify_triggers_exist()
102
+ missing_t = [t for t, exists in triggers.items() if not exists]
103
+ if missing_t:
104
+ logger.error("Missing FTS5 triggers: %s", missing_t)
105
+ raise RuntimeError(f"Missing triggers: {missing_t}")
106
+
107
+ logger.info("Schema v%s verified - all tables and triggers OK.", SchemaManager.SCHEMA_VERSION)
108
+ finally:
109
+ conn.close()
110
+
111
+ def close(self) -> None:
112
+ """Shuts down the write queue and read connections."""
113
+ self._conn_mgr.close()
114
+ logger.info("SqliteStore closed.")
115
+
116
+ def write_callback(self, fn: Callable, *args: Any, **kwargs: Any) -> Any:
117
+ """Delegates a write operation to the ConnectionManager's serialized queue.
118
+
119
+ This method protects the encapsulation of the ConnectionManager and its
120
+ internal write queue.
121
+ """
122
+ return self._conn_mgr.write(fn, *args, **kwargs)
123
+
124
+ # ===================================================================
125
+ # PROJECTS
126
+ # ===================================================================
127
+
128
+ def create_project(
129
+ self, uuid: str, folder_name: str, primary_wing: str = "geral",
130
+ privacy_level: str = "PUBLIC", summary: Optional[str] = None,
131
+ ) -> dict:
132
+ """Registers a new project (aligned with concierge_register)."""
133
+ if privacy_level not in VALID_PRIVACY_LEVELS:
134
+ raise ValueError(f"Invalid privacy_level: '{privacy_level}'. Accepted: {sorted(VALID_PRIVACY_LEVELS)}")
135
+
136
+ def _do(conn, u, fn, pw, pl, s):
137
+ conn.execute(
138
+ "INSERT INTO projects (uuid, folder_name, primary_wing, privacy_level, summary) VALUES (?,?,?,?,?)",
139
+ (u, fn, pw, pl, s))
140
+ return {"uuid": u, "folder_name": fn, "primary_wing": pw, "privacy_level": pl}
141
+
142
+ return self._conn_mgr.write(_do, uuid, folder_name, primary_wing, privacy_level, summary)
143
+
144
+ def get_project(self, project_id: str) -> dict:
145
+ """Retrieves a project by UUID or folder_name."""
146
+ with self._conn_mgr.read() as conn:
147
+ row = conn.execute(
148
+ "SELECT * FROM projects WHERE uuid = ? OR folder_name = ?",
149
+ (project_id, project_id)).fetchone()
150
+ if not row:
151
+ raise ProjectNotFoundError(f"Project not found: {project_id}")
152
+ return dict(row)
153
+
154
+ def update_project(self, uuid: str, **fields: Any) -> None:
155
+ """Updates allowed fields of a project."""
156
+ allowed = {"folder_name", "primary_wing", "privacy_level", "summary"}
157
+ updates = {k: v for k, v in fields.items() if k in allowed}
158
+ if not updates:
159
+ return
160
+ if "privacy_level" in updates and updates["privacy_level"] not in VALID_PRIVACY_LEVELS:
161
+ raise ValueError(f"Invalid privacy_level: {updates['privacy_level']}")
162
+ updates["updated_at"] = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
163
+ set_clause = ", ".join(f"{k} = ?" for k in updates)
164
+ vals = list(updates.values()) + [uuid]
165
+
166
+ def _do(conn, sc, v):
167
+ conn.execute(f"UPDATE projects SET {sc} WHERE uuid = ?", v)
168
+ self._conn_mgr.write(_do, set_clause, vals)
169
+
170
+ def delete_project(self, uuid: str) -> None:
171
+ """Removes a project and all cascaded data."""
172
+ def _do(conn, u):
173
+ conn.execute("DELETE FROM projects WHERE uuid = ?", (u,))
174
+ self._conn_mgr.write(_do, uuid)
175
+
176
+ def list_projects(self) -> list[dict]:
177
+ """Lists all projects sorted by updated_at DESC."""
178
+ with self._conn_mgr.read() as conn:
179
+ rows = conn.execute("SELECT * FROM projects ORDER BY updated_at DESC").fetchall()
180
+ return [dict(r) for r in rows]
181
+
182
+ # ===================================================================
183
+ # NODES
184
+ # ===================================================================
185
+
186
+ def create_node(
187
+ self, project_uuid: str, label: str, summary: Optional[str] = None,
188
+ node_type: str = "FACT", type_: str = "file",
189
+ tags: Optional[list[str]] = None, file_hash: Optional[str] = None,
190
+ status: str = "ACTIVE", content: Optional[str] = None,
191
+ valid_from_commit: Optional[str] = None,
192
+ valid_to_commit: Optional[str] = None,
193
+ ) -> int:
194
+ """Creates a node in the graph (aligned with concierge_mine).
195
+
196
+ Args:
197
+ valid_from_commit: SHA of the commit where the node becomes valid (optional).
198
+ valid_to_commit: SHA of the commit where the node ceases to be valid (optional).
199
+ """
200
+ if node_type not in VALID_NODE_TYPES:
201
+ raise ValueError(f"Invalid node_type: '{node_type}'. Accepted: {sorted(VALID_NODE_TYPES)}")
202
+ if status not in VALID_STATUSES:
203
+ raise ValueError(f"Invalid status: '{status}'. Accepted: {sorted(VALID_STATUSES)}")
204
+
205
+ tags_json = json.dumps(tags, ensure_ascii=False) if tags else None
206
+
207
+ def _do(conn, pu, lb, sm, nt, tp, tg, fh, st, ct, vfc, vtc):
208
+ cur = conn.execute(
209
+ """INSERT INTO nodes
210
+ (project_uuid, label, summary, node_type, type, tags, file_hash, status, content,
211
+ valid_from_commit, valid_to_commit)
212
+ VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
213
+ (pu, lb, sm, nt, tp, tg, fh, st, ct, vfc, vtc))
214
+ return cur.lastrowid
215
+ return self._conn_mgr.write(
216
+ _do, project_uuid, label, summary, node_type, type_, tags_json,
217
+ file_hash, status, content, valid_from_commit, valid_to_commit,
218
+ )
219
+
220
+ def get_node(self, node_id: int) -> dict:
221
+ """Returns a node by ID."""
222
+ with self._conn_mgr.read() as conn:
223
+ row = conn.execute("SELECT * FROM nodes WHERE id = ?", (node_id,)).fetchone()
224
+ if not row:
225
+ raise NodeNotFoundError(f"Node not found: {node_id}")
226
+ result = dict(row)
227
+ if result.get("tags"):
228
+ try:
229
+ result["tags"] = json.loads(result["tags"])
230
+ except (json.JSONDecodeError, TypeError):
231
+ pass
232
+ return result
233
+
234
+ def get_nodes_by_project(
235
+ self, project_uuid: str, node_type: Optional[str] = None, status: Optional[str] = None,
236
+ ) -> list[dict]:
237
+ """Lists nodes of a project with optional filters."""
238
+ sql = "SELECT * FROM nodes WHERE project_uuid = ?"
239
+ params: list[Any] = [project_uuid]
240
+ if node_type:
241
+ sql += " AND node_type = ?"
242
+ params.append(node_type)
243
+ if status:
244
+ sql += " AND status = ?"
245
+ params.append(status)
246
+ with self._conn_mgr.read() as conn:
247
+ rows = conn.execute(sql, params).fetchall()
248
+ results = []
249
+ for r in rows:
250
+ d = dict(r)
251
+ if d.get("tags"):
252
+ try:
253
+ d["tags"] = json.loads(d["tags"])
254
+ except (json.JSONDecodeError, TypeError):
255
+ pass
256
+ results.append(d)
257
+ return results
258
+
259
+ def get_lightweight_topology(self, project_uuid: Optional[str] = None) -> dict[str, list[dict]]:
260
+ """Returns the complete and lightweight topology (nodes and edges) from the database.
261
+
262
+ Avoids loading text summaries (summary) or embeddings for bandwidth optimization.
263
+
264
+ Args:
265
+ project_uuid: Optional UUID to filter the nodes and edges of that project.
266
+
267
+ Returns:
268
+ Dict with structure:
269
+ {
270
+ "nodes": [{"node_id": int, "name": str, "node_type": str}],
271
+ "edges": [{"source": int, "target": int, "edge_type": str}]
272
+ }
273
+ """
274
+ if project_uuid:
275
+ nodes_sql = "SELECT id AS node_id, label AS name, node_type FROM nodes WHERE project_uuid = ? AND status = 'ACTIVE'"
276
+ nodes_params = [project_uuid]
277
+
278
+ edges_sql = """
279
+ SELECT e.source_id AS source, e.target_id AS target, e.relation_type AS edge_type
280
+ FROM edges e
281
+ JOIN nodes n1 ON e.source_id = n1.id
282
+ JOIN nodes n2 ON e.target_id = n2.id
283
+ WHERE n1.project_uuid = ? AND n2.project_uuid = ?
284
+ AND n1.status = 'ACTIVE' AND n2.status = 'ACTIVE'
285
+ """
286
+ edges_params = [project_uuid, project_uuid]
287
+ else:
288
+ nodes_sql = "SELECT id AS node_id, label AS name, node_type FROM nodes WHERE status = 'ACTIVE'"
289
+ nodes_params = []
290
+
291
+ edges_sql = "SELECT source_id AS source, target_id AS target, relation_type AS edge_type FROM edges"
292
+ edges_params = []
293
+
294
+ with self._conn_mgr.read() as conn:
295
+ node_rows = conn.execute(nodes_sql, nodes_params).fetchall()
296
+ edge_rows = conn.execute(edges_sql, edges_params).fetchall()
297
+
298
+ return {
299
+ "nodes": [dict(r) for r in node_rows],
300
+ "edges": [dict(r) for r in edge_rows]
301
+ }
302
+
303
+
304
+ def update_node(self, node_id: int, **fields: Any) -> None:
305
+ """Updates allowed fields of a node (includes temporal fields)."""
306
+ allowed = {"label", "summary", "node_type", "type", "tags", "file_hash",
307
+ "last_accessed", "last_commit_at", "status",
308
+ "valid_from_commit", "valid_to_commit"}
309
+ updates = {k: v for k, v in fields.items() if k in allowed}
310
+ if not updates:
311
+ return
312
+ if "node_type" in updates and updates["node_type"] not in VALID_NODE_TYPES:
313
+ raise ValueError(f"Invalid node_type: {updates['node_type']}")
314
+ if "status" in updates and updates["status"] not in VALID_STATUSES:
315
+ raise ValueError(f"Invalid status: {updates['status']}")
316
+ if "tags" in updates and isinstance(updates["tags"], list):
317
+ updates["tags"] = json.dumps(updates["tags"], ensure_ascii=False)
318
+ set_clause = ", ".join(f"{k} = ?" for k in updates)
319
+ vals = list(updates.values()) + [node_id]
320
+
321
+ def _do(conn, sc, v):
322
+ conn.execute(f"UPDATE nodes SET {sc} WHERE id = ?", v)
323
+ self._conn_mgr.write(_do, set_clause, vals)
324
+
325
+ def delete_node(self, node_id: int) -> None:
326
+ """Removes a node and its edges (CASCADE)."""
327
+ def _do(conn, nid):
328
+ conn.execute("DELETE FROM nodes WHERE id = ?", (nid,))
329
+ self._conn_mgr.write(_do, node_id)
330
+
331
+ def find_node_by_hash(self, project_uuid: str, file_hash: str) -> Optional[dict]:
332
+ """Searches node by SHA256 hash (delta update check in concierge_mine)."""
333
+ with self._conn_mgr.read() as conn:
334
+ row = conn.execute(
335
+ "SELECT * FROM nodes WHERE project_uuid = ? AND file_hash = ?",
336
+ (project_uuid, file_hash)).fetchone()
337
+ return dict(row) if row else None
338
+
339
+ def touch_node_commit(self, node_id: int) -> None:
340
+ """Updates last_commit_at to now (used in commit_memory)."""
341
+ now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
342
+ self.update_node(node_id, last_commit_at=now)
343
+
344
+ def update_nodes_file_hash_bulk(self, updates: list[tuple[int, str]]) -> int:
345
+ """Updates file_hash of multiple nodes in a single transaction.
346
+
347
+ Used by Delta Cache to bind cached nodes to the new file hash,
348
+ preventing Garbage Collection from marking them as orphans.
349
+
350
+ Args:
351
+ updates: List of tuples (node_id, new_file_hash).
352
+
353
+ Returns:
354
+ Number of successfully updated nodes.
355
+ """
356
+ if not updates:
357
+ return 0
358
+
359
+ def _do(conn, upd):
360
+ for node_id, new_hash in upd:
361
+ conn.execute(
362
+ "UPDATE nodes SET file_hash = ? WHERE id = ?",
363
+ (new_hash, node_id),
364
+ )
365
+ self._conn_mgr.write(_do, updates)
366
+ return len(updates)
367
+
368
+ def cleanup_obsolete_nodes(self, project_uuid: str, relative_path: str, current_file_hash: str) -> None:
369
+ """Removes orphan nodes from a modified file (chunks that ceased to exist).
370
+
371
+ When modifying a file, unchanged chunks are cached and new ones are
372
+ inserted (both with the new file_hash). Old chunks of the same file
373
+ that were not reused keep the old hash and are removed here.
374
+ """
375
+ def _do(conn):
376
+ prefix = f"{relative_path}::"
377
+ conn.execute(
378
+ "DELETE FROM nodes WHERE project_uuid = ? AND (label = ? OR label LIKE ?) AND (file_hash IS NULL OR file_hash != ?)",
379
+ (project_uuid, relative_path, prefix + "%", current_file_hash)
380
+ )
381
+ self._conn_mgr.write(_do)
382
+
383
+
384
+ def create_nodes_and_edges_bulk(
385
+ self,
386
+ nodes_to_create: list[dict],
387
+ edges_to_create: list[dict]
388
+ ) -> list[int]:
389
+ """Creates multiple nodes and edges in a single SQLite transaction (WAL-friendly).
390
+
391
+ Supports temporal fields and confidence_tag in each node/edge dict.
392
+ """
393
+ def _do(conn) -> list[int]:
394
+ node_ids = []
395
+ # Insert nodes
396
+ for n in nodes_to_create:
397
+ if n.get("node_type", "FACT") not in VALID_NODE_TYPES:
398
+ raise ValueError(f"Invalid node_type in bulk: {n.get('node_type')}")
399
+ if n.get("status", "ACTIVE") not in VALID_STATUSES:
400
+ raise ValueError(f"Invalid status in bulk: {n.get('status')}")
401
+
402
+ tags_json = json.dumps(n.get("tags"), ensure_ascii=False) if n.get("tags") else None
403
+ cur = conn.execute(
404
+ """INSERT INTO nodes
405
+ (project_uuid, label, summary, content, node_type, type, tags, file_hash, status,
406
+ valid_from_commit, valid_to_commit)
407
+ VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
408
+ (n["project_uuid"], n["label"], n.get("summary"), n.get("content"),
409
+ n.get("node_type", "FACT"), n.get("type", "file"), tags_json,
410
+ n.get("file_hash"), n.get("status", "ACTIVE"),
411
+ n.get("valid_from_commit"), n.get("valid_to_commit"))
412
+ )
413
+ node_ids.append(cur.lastrowid)
414
+
415
+ # Insert edges
416
+ for e in edges_to_create:
417
+ src_id = e["source_id"]
418
+ tgt_id = e["target_id"]
419
+
420
+ # Resolution of index references if passed as idx_0, idx_1 etc.
421
+ if isinstance(src_id, str) and src_id.startswith("idx_"):
422
+ idx = int(src_id.split("_")[1])
423
+ src_id = node_ids[idx]
424
+ if isinstance(tgt_id, str) and tgt_id.startswith("idx_"):
425
+ idx = int(tgt_id.split("_")[1])
426
+ tgt_id = node_ids[idx]
427
+
428
+ conn.execute(
429
+ """INSERT OR REPLACE INTO edges
430
+ (source_id, target_id, relation_type, weight,
431
+ valid_from_commit, valid_to_commit, confidence_tag)
432
+ VALUES (?,?,?,?,?,?,?)""",
433
+ (src_id, tgt_id, e.get("relation_type", "depends_on"), e.get("weight", 1.0),
434
+ e.get("valid_from_commit"), e.get("valid_to_commit"),
435
+ e.get("confidence_tag", "EXTRACTED"))
436
+ )
437
+ return node_ids
438
+
439
+ return self._conn_mgr.write(_do)
440
+
441
+ # ===================================================================
442
+ # EDGES
443
+ # ===================================================================
444
+
445
+ def create_edge(
446
+ self, source_id: int, target_id: int,
447
+ relation_type: str = "depends_on", weight: float = 1.0,
448
+ valid_from_commit: Optional[str] = None,
449
+ valid_to_commit: Optional[str] = None,
450
+ confidence_tag: str = "EXTRACTED",
451
+ ) -> None:
452
+ """Creates or updates an edge between two nodes.
453
+
454
+ Args:
455
+ valid_from_commit: Commit SHA in which the edge becomes valid.
456
+ valid_to_commit: Commit SHA in which the edge ceases to be valid.
457
+ confidence_tag: Confidence degree of the relation ('EXTRACTED'|'INFERRED'|'AMBIGUOUS').
458
+ """
459
+ def _do(conn, s, t, r, w, vfc, vtc, ct):
460
+ conn.execute(
461
+ """INSERT OR REPLACE INTO edges
462
+ (source_id, target_id, relation_type, weight,
463
+ valid_from_commit, valid_to_commit, confidence_tag)
464
+ VALUES (?,?,?,?,?,?,?)""",
465
+ (s, t, r, w, vfc, vtc, ct))
466
+ self._conn_mgr.write(
467
+ _do, source_id, target_id, relation_type, weight,
468
+ valid_from_commit, valid_to_commit, confidence_tag,
469
+ )
470
+
471
+ def get_edges_from(self, node_id: int) -> list[dict]:
472
+ """Edges coming out of a node (source → targets)."""
473
+ with self._conn_mgr.read() as conn:
474
+ rows = conn.execute("SELECT * FROM edges WHERE source_id = ?", (node_id,)).fetchall()
475
+ return [dict(r) for r in rows]
476
+
477
+ def get_edges_to(self, node_id: int) -> list[dict]:
478
+ """Edges arriving at a node (sources → target)."""
479
+ with self._conn_mgr.read() as conn:
480
+ rows = conn.execute("SELECT * FROM edges WHERE target_id = ?", (node_id,)).fetchall()
481
+ return [dict(r) for r in rows]
482
+
483
+ def get_in_degree(self, node_id: int) -> int:
484
+ """Counts incoming edges (in-degree) of a node."""
485
+ with self._conn_mgr.read() as conn:
486
+ row = conn.execute("SELECT COUNT(*) AS c FROM edges WHERE target_id = ?", (node_id,)).fetchone()
487
+ return row["c"] if row else 0
488
+
489
+ def delete_edge(self, source_id: int, target_id: int) -> None:
490
+ """Removes an edge."""
491
+ def _do(conn, s, t):
492
+ conn.execute("DELETE FROM edges WHERE source_id = ? AND target_id = ?", (s, t))
493
+ self._conn_mgr.write(_do, source_id, target_id)
494
+
495
+ # ===================================================================
496
+ # TRAJECTORIES
497
+ # ===================================================================
498
+
499
+ def create_trajectory(
500
+ self, project_uuid: str, prompt_origem: str, tentativa_execucao: str,
501
+ erro_encontrado: Optional[str] = None, solucao_aplicada: Optional[str] = None,
502
+ status: str = "ACTIVE",
503
+ ) -> int:
504
+ """Registers an episodic trajectory (Learning Loop)."""
505
+ if status not in VALID_STATUSES:
506
+ raise ValueError(f"Invalid status: '{status}'")
507
+
508
+ def _do(conn, pu, po, te, ee, sa, st):
509
+ cur = conn.execute(
510
+ """INSERT INTO trajectories
511
+ (project_uuid, prompt_origem, tentativa_execucao, erro_encontrado, solucao_aplicada, status)
512
+ VALUES (?,?,?,?,?,?)""",
513
+ (pu, po, te, ee, sa, st))
514
+ return cur.lastrowid
515
+ return self._conn_mgr.write(_do, project_uuid, prompt_origem, tentativa_execucao,
516
+ erro_encontrado, solucao_aplicada, status)
517
+
518
+ def get_trajectories(self, project_uuid: str, status: Optional[str] = None) -> list[dict]:
519
+ """Lists trajectories of a project with optional status filter."""
520
+ sql = "SELECT * FROM trajectories WHERE project_uuid = ?"
521
+ params: list[Any] = [project_uuid]
522
+ if status:
523
+ sql += " AND status = ?"
524
+ params.append(status)
525
+ sql += " ORDER BY created_at DESC"
526
+ with self._conn_mgr.read() as conn:
527
+ rows = conn.execute(sql, params).fetchall()
528
+ return [dict(r) for r in rows]
529
+
530
+ def decay_trajectory(self, trajectory_id: int, new_status: str) -> bool:
531
+ """Delegates to GraphLogic (state machine with validation)."""
532
+ return self._logic.decay_trajectory(trajectory_id, new_status)
533
+
534
+ # ===================================================================
535
+ # COMMIT LOG (aligned with concierge_commit)
536
+ # ===================================================================
537
+
538
+ def create_commit(
539
+ self, project_uuid: str, phase: str, technical_changes: str,
540
+ updated_pointers: list[str], revisor_approved: bool = False,
541
+ partial_audit: bool = False,
542
+ ) -> int:
543
+ """Registers an audited memory commit.
544
+
545
+ Mandatory validation: technical_changes and updated_pointers cannot be empty.
546
+ """
547
+ if not technical_changes:
548
+ raise CommitValidationError("technical_changes is mandatory and cannot be empty.")
549
+ if not updated_pointers:
550
+ raise CommitValidationError("updated_pointers is mandatory and cannot be empty.")
551
+ pointers_json = json.dumps(updated_pointers, ensure_ascii=False)
552
+
553
+ def _do(conn, pu, ph, tc, up, ra, pa):
554
+ cur = conn.execute(
555
+ """INSERT INTO commit_log
556
+ (project_uuid, phase, technical_changes, updated_pointers, revisor_approved, partial_audit)
557
+ VALUES (?,?,?,?,?,?)""",
558
+ (pu, ph, tc, up, int(ra), int(pa)))
559
+ return cur.lastrowid
560
+ return self._conn_mgr.write(_do, project_uuid, phase, technical_changes,
561
+ pointers_json, revisor_approved, partial_audit)
562
+
563
+ def get_recent_commits(self, project_uuid: str, limit: int = 5) -> list[dict]:
564
+ """Returns the N most recent commits of a project."""
565
+ with self._conn_mgr.read() as conn:
566
+ rows = conn.execute(
567
+ "SELECT * FROM commit_log WHERE project_uuid = ? ORDER BY created_at DESC LIMIT ?",
568
+ (project_uuid, limit)).fetchall()
569
+ results = []
570
+ for r in rows:
571
+ d = dict(r)
572
+ if d.get("updated_pointers"):
573
+ try:
574
+ d["updated_pointers"] = json.loads(d["updated_pointers"])
575
+ except (json.JSONDecodeError, TypeError):
576
+ pass
577
+ results.append(d)
578
+ return results
579
+
580
+ # ===================================================================
581
+ # REFERENCE WINGS
582
+ # ===================================================================
583
+
584
+ def add_reference_wing(self, project_uuid: str, wing_name: str) -> None:
585
+ """Adds a Reference Wing to the project."""
586
+ def _do(conn, pu, wn):
587
+ conn.execute("INSERT OR IGNORE INTO reference_wings (project_uuid, wing_name) VALUES (?,?)", (pu, wn))
588
+ self._conn_mgr.write(_do, project_uuid, wing_name)
589
+
590
+ def get_reference_wings(self, project_uuid: str) -> list[str]:
591
+ """Lists Reference Wings of a project."""
592
+ with self._conn_mgr.read() as conn:
593
+ rows = conn.execute("SELECT wing_name FROM reference_wings WHERE project_uuid = ?", (project_uuid,)).fetchall()
594
+ return [r["wing_name"] for r in rows]
595
+
596
+ def remove_reference_wing(self, project_uuid: str, wing_name: str) -> None:
597
+ """Removes a Reference Wing."""
598
+ def _do(conn, pu, wn):
599
+ conn.execute("DELETE FROM reference_wings WHERE project_uuid = ? AND wing_name = ?", (pu, wn))
600
+ self._conn_mgr.write(_do, project_uuid, wing_name)
601
+
602
+ # ===================================================================
603
+ # INTELLIGENCE (delegated to GraphLogic)
604
+ # ===================================================================
605
+
606
+ def compute_centrality(self, node_id: int) -> float:
607
+ """Normalized centrality: min(in_degree/10, 1.0)."""
608
+ return self._logic.compute_centrality(node_id)
609
+
610
+ def compute_recency_score(self, node_id: int) -> float:
611
+ """Recency score: max(e^(-λ×t), 0.01)."""
612
+ return self._logic.compute_recency_score(node_id)
613
+
614
+ def fts_search(self, query: str, project_uuid: Optional[str] = None,
615
+ node_type: Optional[str] = None, limit: int = 20) -> list[dict]:
616
+ """FTS5 search with normalized BM25 (concierge_search - frequency component)."""
617
+ return self._logic.fts_search(query, project_uuid, node_type, limit)
618
+
619
+ def hybrid_search_score(self, node_id: int, vector_score: float, fts_score: float) -> dict:
620
+ """Individual hybrid score: 0.50×vet + 0.25×fts + 0.25×max(rec,cent)."""
621
+ return self._logic.hybrid_search_score(node_id, vector_score, fts_score)
622
+
623
+ def hybrid_search_score_batch(self, candidates: list[dict]) -> list[dict]:
624
+ """Hybrid score in batch — used by concierge_search."""
625
+ return self._logic.hybrid_search_score_batch(candidates)
626
+
627
+ def fts_rebuild(self) -> None:
628
+ """Rebuilds the FTS5 index (post massive concierge_mine)."""
629
+ self._logic.fts_rebuild()
630
+
631
+ def get_dependency_tree(self, start_node_id: int, max_depth: int = 10) -> list[dict]:
632
+ """CTE: dependency tree with anti-loop protection."""
633
+ return self._logic.get_dependency_tree(start_node_id, max_depth)
634
+
635
+ def get_reverse_dependency_tree(self, start_node_id: int, max_depth: int = 10) -> list[dict]:
636
+ """Reverse CTE: who depends on this node."""
637
+ return self._logic.get_reverse_dependency_tree(start_node_id, max_depth)
638
+
639
+ def get_project_stats(self, project_uuid: str) -> dict:
640
+ """Complete statistics of a project."""
641
+ return self._logic.get_project_stats(project_uuid)
642
+
643
+ def get_last_commit_phase(self, project_uuid: str) -> Optional[str]:
644
+ """Phase of the most recent commit."""
645
+ return self._logic.get_last_commit_phase(project_uuid)
646
+
647
+ def bulk_decay_stale_trajectories(self, project_uuid: str, stale_threshold_days: int = 30) -> int:
648
+ """Mass decay for the Background Janitor."""
649
+ return self._logic.bulk_decay_stale_trajectories(project_uuid, stale_threshold_days)
650
+
651
+ def search_symbols(self, query: str, project_uuid: Optional[str] = None, limit: int = 50) -> list[dict]:
652
+ """Searches for indexed classes, methods, and functions using FTS5."""
653
+ safe_query = query.replace('"', '""')
654
+ sql = """
655
+ SELECT n.id, n.label, n.type, n.project_uuid, n.file_hash
656
+ FROM nodes_fts f
657
+ JOIN nodes n ON n.id = f.rowid
658
+ WHERE nodes_fts MATCH ? AND n.type IN ('class', 'function', 'method')
659
+ ORDER BY CASE n.type WHEN 'class' THEN 1 WHEN 'function' THEN 2 WHEN 'method' THEN 3 ELSE 4 END, n.id
660
+ """
661
+ params: list[Any] = [f'"{safe_query}"']
662
+ if project_uuid:
663
+ sql += " AND n.project_uuid = ?"
664
+ params.append(project_uuid)
665
+ sql += " LIMIT ?"
666
+ params.append(limit)
667
+ return self._conn_mgr.execute_raw_read(sql, tuple(params))
668
+
669
+ def get_callers(self, symbol_id: int) -> list[dict]:
670
+ """Returns all nodes that call the specified symbol."""
671
+ sql = """
672
+ SELECT n.id, n.label, n.type, n.project_uuid
673
+ FROM edges e
674
+ JOIN nodes n ON e.source_id = n.id
675
+ WHERE e.target_id = ? AND e.relation_type = 'calls'
676
+ """
677
+ return self._conn_mgr.execute_raw_read(sql, (symbol_id,))
678
+
679
+
680
+ # ===================================================================
681
+ # ENCAPSULATION — Public API for JanitorService (Patch 2)
682
+ # ===================================================================
683
+
684
+ def is_write_queue_empty(self) -> bool:
685
+ """Returns True if the write queue (SerializedWriteQueue) has no pending jobs.
686
+
687
+ Replaces direct access to _conn_mgr._write_queue._queue.empty() in Janitor.
688
+ """
689
+ return self._conn_mgr.is_write_queue_empty()
690
+
691
+ def execute_read_sql(self, sql: str, params: tuple = ()) -> list[dict]:
692
+ """Executes arbitrary read SQL and returns a list of dicts.
693
+
694
+ Allows JanitorService to make complex queries (WITH RECURSIVE,
695
+ JOIN with FTS5, etc.) without needing to access self._conn_mgr directly.
696
+
697
+ Args:
698
+ sql: SQL query for reading (SELECT / WITH RECURSIVE).
699
+ params: Positional parameters for the query.
700
+
701
+ Returns:
702
+ List of dictionaries with the results.
703
+ """
704
+ return self._conn_mgr.execute_raw_read(sql, params)
705
+
706
+ # ===================================================================
707
+ # USER CORE MEMORY — Complete CRUD (Patch 1)
708
+ # ===================================================================
709
+
710
+ _VALID_SCOPE_TYPES: frozenset[str] = frozenset({"user", "session", "agent", "org"})
711
+
712
+ def set_core_memory(
713
+ self,
714
+ scope_type: str,
715
+ scope_id: str,
716
+ block_label: str,
717
+ content: str,
718
+ ) -> int:
719
+ """Writes or updates a block of user/session core memory.
720
+
721
+ Uses INSERT OR REPLACE to ensure block_label is unique per scope.
722
+
723
+ Args:
724
+ scope_type: Scope type — 'user', 'session', 'agent' or 'org'.
725
+ scope_id: Scope identifier (e.g. user UUID, session UUID).
726
+ block_label: Label of the memory block (e.g. 'preferred_language').
727
+ content: Content of the memory block.
728
+
729
+ Returns:
730
+ The id of the inserted or replaced record.
731
+
732
+ Raises:
733
+ ValueError: If scope_type is invalid or required fields are empty.
734
+ """
735
+ if scope_type not in self._VALID_SCOPE_TYPES:
736
+ raise ValueError(f"Invalid scope_type: '{scope_type}'. Accepted: {sorted(self._VALID_SCOPE_TYPES)}")
737
+ if not scope_id or not scope_id.strip():
738
+ raise ValueError("scope_id cannot be empty.")
739
+ if not block_label or not block_label.strip():
740
+ raise ValueError("block_label cannot be empty.")
741
+
742
+ def _write(conn: "sqlite3.Connection") -> int:
743
+ cursor = conn.execute(
744
+ """INSERT OR REPLACE INTO user_core_memory
745
+ (scope_type, scope_id, block_label, content, updated_at)
746
+ VALUES (?, ?, ?, ?, datetime('now'))""",
747
+ (scope_type, scope_id.strip(), block_label.strip(), content),
748
+ )
749
+ return cursor.lastrowid
750
+
751
+ return self._conn_mgr.write(_write)
752
+
753
+ def get_core_memory(
754
+ self,
755
+ scope_type: str,
756
+ scope_id: str,
757
+ block_label: str,
758
+ ) -> Optional[dict]:
759
+ """Returns a specific core memory block, or None if it doesn't exist.
760
+
761
+ Args:
762
+ scope_type: Scope type.
763
+ scope_id: Scope identifier.
764
+ block_label: Label of the memory block.
765
+
766
+ Returns:
767
+ Dict with the record columns, or None.
768
+ """
769
+ if scope_type not in self._VALID_SCOPE_TYPES:
770
+ raise ValueError(f"Invalid scope_type: '{scope_type}'.")
771
+ rows = self._conn_mgr.execute_raw_read(
772
+ """SELECT id, scope_type, scope_id, block_label, content, updated_at
773
+ FROM user_core_memory
774
+ WHERE scope_type = ? AND scope_id = ? AND block_label = ?
775
+ LIMIT 1""",
776
+ (scope_type, scope_id.strip(), block_label.strip()),
777
+ )
778
+ return rows[0] if rows else None
779
+
780
+ def list_core_memory_blocks(
781
+ self,
782
+ scope_type: str,
783
+ scope_id: str,
784
+ ) -> list[dict]:
785
+ """Returns all core memory blocks for a scope.
786
+
787
+ Args:
788
+ scope_type: Scope type.
789
+ scope_id: Scope identifier.
790
+
791
+ Returns:
792
+ List of dicts (can be empty).
793
+ """
794
+ if scope_type not in self._VALID_SCOPE_TYPES:
795
+ raise ValueError(f"Invalid scope_type: '{scope_type}'.")
796
+ return self._conn_mgr.execute_raw_read(
797
+ """SELECT id, scope_type, scope_id, block_label, content, updated_at
798
+ FROM user_core_memory
799
+ WHERE scope_type = ? AND scope_id = ?
800
+ ORDER BY block_label ASC""",
801
+ (scope_type, scope_id.strip()),
802
+ )