agentdatabase 0.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.
@@ -0,0 +1,1108 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ import json
5
+ import os
6
+ import sqlite3
7
+ import warnings
8
+ from contextlib import contextmanager
9
+ from dataclasses import dataclass, field
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+ from typing import Any, Callable, Optional
13
+
14
+ import yaml
15
+
16
+ from ..core.models import ConflictRecord, MemoryRecord, Skill
17
+ from ..core.profiles import BUILTIN_PROFILES
18
+ from ..core.store import MemoryStore
19
+ from ..core.system import SystemStats
20
+ from ..gateway import Gateway
21
+ from ..governance.conflict_detector import ConflictDetector
22
+ from ..governance.lifecycle_manager import LifecycleManager
23
+ from ..governance.permission_router import PermissionDenied, PermissionRouter
24
+ from ..retrieval.algorithm import MultiSignalRanker
25
+ from ..skills.skill_store import SkillStore
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Event constants
30
+ # ---------------------------------------------------------------------------
31
+
32
+ EVENT_MEMORY_WRITE = "memory_write"
33
+ EVENT_MEMORY_DELETE = "memory_delete"
34
+ EVENT_MEMORY_RETRIEVE = "memory_retrieve"
35
+ EVENT_MEMORY_OUTCOME = "memory_outcome"
36
+ EVENT_EXTERNAL_CONFLICT_DETECTED = "external_conflict_detected"
37
+ EVENT_PERMISSION_DENIED = "permission_denied"
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Data containers
42
+ # ---------------------------------------------------------------------------
43
+
44
+ @dataclass
45
+ class AuditEntry:
46
+ event_type: str
47
+ payload: dict
48
+ sequence: int
49
+
50
+
51
+ @dataclass
52
+ class RetrievalResult:
53
+ records: list
54
+ conflicts: list
55
+ override_instructions: list
56
+ skills: list
57
+ skill_warnings: list
58
+ token_estimate: int = 0
59
+ truncated: bool = False
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # SQLite schema
64
+ # ---------------------------------------------------------------------------
65
+
66
+ _CREATE_AUDIT_LOG = """
67
+ CREATE TABLE IF NOT EXISTS audit_log (
68
+ sequence INTEGER PRIMARY KEY AUTOINCREMENT,
69
+ event_type TEXT NOT NULL,
70
+ payload TEXT NOT NULL,
71
+ created_at TEXT NOT NULL
72
+ )
73
+ """
74
+
75
+ _CREATE_OUTCOMES = """
76
+ CREATE TABLE IF NOT EXISTS outcomes (
77
+ id TEXT PRIMARY KEY,
78
+ session_id TEXT NOT NULL,
79
+ agent_id TEXT NOT NULL,
80
+ outcome_type TEXT NOT NULL,
81
+ outcome_value REAL NOT NULL,
82
+ linked_retrieval_sequence INTEGER,
83
+ created_at TEXT NOT NULL
84
+ )
85
+ """
86
+
87
+ _CREATE_EXTERNAL_RETRIEVES = """
88
+ CREATE TABLE IF NOT EXISTS external_retrieves (
89
+ session_id TEXT NOT NULL,
90
+ had_external_context INTEGER NOT NULL,
91
+ had_conflict INTEGER NOT NULL DEFAULT 0
92
+ )
93
+ """
94
+
95
+ def _utcnow() -> datetime:
96
+ return datetime.now(timezone.utc)
97
+
98
+
99
+ def _estimate_tokens(record: MemoryRecord) -> int:
100
+ value_str = json.dumps(record.value) if isinstance(record.value, (dict, list)) else str(record.value)
101
+ return max(1, len(value_str) // 4)
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # Audit log
106
+ # ---------------------------------------------------------------------------
107
+
108
+ class AuditLog:
109
+ def __init__(self, db_path: Path) -> None:
110
+ self._db_path = db_path
111
+ db_path.parent.mkdir(parents=True, exist_ok=True)
112
+ with self._connect() as conn:
113
+ conn.execute(_CREATE_AUDIT_LOG)
114
+
115
+ @contextmanager
116
+ def _connect(self):
117
+ conn = sqlite3.connect(str(self._db_path))
118
+ conn.row_factory = sqlite3.Row
119
+ conn.execute("PRAGMA journal_mode=WAL")
120
+ try:
121
+ yield conn
122
+ conn.commit()
123
+ except Exception:
124
+ conn.rollback()
125
+ raise
126
+ finally:
127
+ conn.close()
128
+
129
+ def append(self, event_type: str, payload: dict) -> int:
130
+ now = _utcnow().isoformat()
131
+ with self._connect() as conn:
132
+ cursor = conn.execute(
133
+ "INSERT INTO audit_log (event_type, payload, created_at) VALUES (?, ?, ?)",
134
+ (event_type, json.dumps(payload), now),
135
+ )
136
+ return cursor.lastrowid
137
+
138
+ def replay(self, from_sequence: int = 0) -> list[AuditEntry]:
139
+ with self._connect() as conn:
140
+ rows = conn.execute(
141
+ "SELECT sequence, event_type, payload FROM audit_log WHERE sequence >= ? ORDER BY sequence",
142
+ (from_sequence,),
143
+ ).fetchall()
144
+ return [
145
+ AuditEntry(
146
+ event_type=row["event_type"],
147
+ payload=json.loads(row["payload"]),
148
+ sequence=row["sequence"],
149
+ )
150
+ for row in rows
151
+ ]
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # Skills API (high-level facade)
156
+ # ---------------------------------------------------------------------------
157
+
158
+ class SkillsAPI:
159
+ def __init__(
160
+ self,
161
+ skill_store: SkillStore,
162
+ embedding_provider=None,
163
+ embedding_store=None,
164
+ ) -> None:
165
+ self._store = skill_store
166
+ self._embedding_provider = embedding_provider
167
+ self._embedding_store = embedding_store
168
+
169
+ def _maybe_embed_skill(self, skill: Skill) -> None:
170
+ if self._embedding_provider is None or self._embedding_store is None:
171
+ return
172
+ try:
173
+ from ..embeddings.text import _embed_text_for_skill
174
+ vec = self._embedding_provider.embed(_embed_text_for_skill(skill))
175
+ self._embedding_store.put(
176
+ "skills", f"{skill.id}:{skill.version}", vec, working_dirs=[]
177
+ )
178
+ except Exception:
179
+ pass
180
+
181
+ def create_skill(
182
+ self,
183
+ skill_id: str,
184
+ name: str,
185
+ content: str,
186
+ scope: list[str],
187
+ ) -> Skill:
188
+ skill = Skill(
189
+ id=skill_id,
190
+ name=name,
191
+ content=content,
192
+ authored_by="human",
193
+ scope=scope,
194
+ )
195
+ created = self._store.create(skill)
196
+ self._maybe_embed_skill(created)
197
+ return created
198
+
199
+ def update_skill_version(self, skill_id: str, content: str, new_version: str) -> Skill:
200
+ updated = self._store.update(skill_id, content, new_version)
201
+ self._maybe_embed_skill(updated)
202
+ return updated
203
+
204
+ def get_skill(self, skill_id: str) -> Optional[Skill]:
205
+ return self._store.get(skill_id)
206
+
207
+ def archive_skill(self, skill_id: str, version: str) -> None:
208
+ self._store.archive(skill_id, version)
209
+ if self._embedding_store is not None:
210
+ try:
211
+ self._embedding_store.delete("skills", f"{skill_id}:{version}")
212
+ except Exception:
213
+ pass
214
+
215
+ def list_stale_skills_for_agent(self, agent_id: str) -> list[Skill]:
216
+ return self._store.list_stale(agent_id)
217
+
218
+ def list_skill_warnings_for_agent(self, agent_id: str) -> list[str]:
219
+ stale = self._store.list_stale(agent_id)
220
+ return [
221
+ f"Skill '{skill.id}' is stale (health_score={skill.health_score:.2f})"
222
+ for skill in stale
223
+ ]
224
+
225
+
226
+ # ---------------------------------------------------------------------------
227
+ # Profile-based defaults and config resolution
228
+ # ---------------------------------------------------------------------------
229
+
230
+ class ConfigurationError(Exception):
231
+ """Raised for any invalid memory.yaml profile configuration. Never
232
+ silently substituted for a default -- see Design Principle in
233
+ docs/superpowers/specs/2026-07-27-unified-profile-configuration-design.md."""
234
+
235
+
236
+ _OVERRIDE_SECTIONS = ("retrieval", "injection", "lifecycle", "capture")
237
+
238
+
239
+ def _builtin_profile_dict(name: str) -> dict:
240
+ if name not in BUILTIN_PROFILES:
241
+ raise ConfigurationError(
242
+ f"profile.name '{name}' is not a recognized built-in profile "
243
+ f"(expected one of {sorted(BUILTIN_PROFILES.keys())})"
244
+ )
245
+ return dataclasses.asdict(BUILTIN_PROFILES[name])
246
+
247
+
248
+ def _deep_merge(base: dict, override: dict) -> dict:
249
+ result = dict(base)
250
+ for key, val in override.items():
251
+ if key in result and isinstance(result[key], dict) and isinstance(val, dict):
252
+ result[key] = _deep_merge(result[key], val)
253
+ else:
254
+ result[key] = val
255
+ return result
256
+
257
+
258
+ def _validate_and_merge_overrides(base: dict, profile_block: dict, where: str) -> dict:
259
+ if not isinstance(profile_block, dict):
260
+ raise ConfigurationError(
261
+ f"{where}: expected a mapping (e.g. '{where}:\n name: ...'), got "
262
+ f"{type(profile_block).__name__} ({profile_block!r})"
263
+ )
264
+ result = dict(base)
265
+ for key in profile_block:
266
+ if key in ("name", "agents"):
267
+ continue
268
+ if key not in _OVERRIDE_SECTIONS:
269
+ raise ConfigurationError(
270
+ f"{where}: unknown key '{key}' (expected one of {_OVERRIDE_SECTIONS}, 'name', or 'agents')"
271
+ )
272
+ section_override = profile_block[key]
273
+ if not isinstance(section_override, dict):
274
+ raise ConfigurationError(
275
+ f"{where}.{key}: expected a mapping, got {type(section_override).__name__} ({section_override!r})"
276
+ )
277
+ base_section = result.get(key, {})
278
+ for field, value in section_override.items():
279
+ if field not in base_section:
280
+ raise ConfigurationError(f"{where}.{key}: unknown field '{field}'")
281
+ base_value = base_section[field]
282
+ if isinstance(base_value, dict):
283
+ if not isinstance(value, dict):
284
+ raise ConfigurationError(
285
+ f"{where}.{key}.{field}: expected dict, got {type(value).__name__} ({value!r})"
286
+ )
287
+ unknown = set(value) - set(base_value)
288
+ if unknown:
289
+ raise ConfigurationError(f"{where}.{key}.{field}: unknown field(s) {sorted(unknown)}")
290
+ for sub_field, sub_value in value.items():
291
+ expected_sub_type = type(base_value[sub_field])
292
+ if not isinstance(sub_value, expected_sub_type):
293
+ raise ConfigurationError(
294
+ f"{where}.{key}.{field}.{sub_field}: expected {expected_sub_type.__name__}, "
295
+ f"got {type(sub_value).__name__} ({sub_value!r})"
296
+ )
297
+ else:
298
+ expected_type = type(base_value)
299
+ if not isinstance(value, expected_type):
300
+ raise ConfigurationError(
301
+ f"{where}.{key}.{field}: expected {expected_type.__name__}, got {type(value).__name__} ({value!r})"
302
+ )
303
+ result[key] = _deep_merge(base_section, section_override)
304
+ return result
305
+
306
+
307
+ _LEGACY_ROOT_KEYS = ("mode", "profiles", "retrieval", "injection", "lifecycle", "capture")
308
+
309
+
310
+ def _resolve_config(yaml_dict: dict) -> dict:
311
+ """
312
+ Resolve the final config dict from the new single-root `profile:` schema.
313
+ Priority (low -> high): built-in profile defaults, then override sections
314
+ (retrieval/injection/lifecycle/capture) declared directly under profile:.
315
+ Raises ConfigurationError on any unrecognized name, key, or wrong type --
316
+ never silently substitutes a default.
317
+ """
318
+ if yaml_dict:
319
+ stale_keys = [k for k in _LEGACY_ROOT_KEYS if k in yaml_dict]
320
+ if stale_keys:
321
+ raise ConfigurationError(
322
+ f"memory.yaml has old-schema root key(s) {stale_keys} -- these must nest under "
323
+ f"a single top-level 'profile:' key now (e.g. 'capture:' becomes "
324
+ f"'profile.capture:'), see docs/superpowers/specs/"
325
+ f"2026-07-27-unified-profile-configuration-design.md"
326
+ )
327
+ profile_block = yaml_dict.get("profile", {}) if yaml_dict else {}
328
+ if not isinstance(profile_block, dict):
329
+ raise ConfigurationError(
330
+ f"profile: expected a mapping (e.g. 'profile:\n name: ...'), got "
331
+ f"{type(profile_block).__name__} ({profile_block!r})"
332
+ )
333
+ name = profile_block.get("name")
334
+ if not name:
335
+ raise ConfigurationError("profile.name is required")
336
+ base = _builtin_profile_dict(name)
337
+ return _validate_and_merge_overrides(base, profile_block, where="profile")
338
+
339
+
340
+ # ---------------------------------------------------------------------------
341
+ # Default memory.yaml content
342
+ # ---------------------------------------------------------------------------
343
+
344
+ _DEFAULT_MEMORY_YAML = """\
345
+ # profile.name: budget_optimized | full_storage | research
346
+ # Any of retrieval:/injection:/lifecycle:/capture: declared alongside name
347
+ # override just those fields on top of the named profile.
348
+ profile:
349
+ name: budget_optimized
350
+
351
+ reconciliation:
352
+ triggers:
353
+ - conversation_started
354
+ database:
355
+ mode: embedded
356
+ path: database/agentdb.sqlite
357
+ """
358
+
359
+
360
+ # ---------------------------------------------------------------------------
361
+ # Main AgentDB class
362
+ # ---------------------------------------------------------------------------
363
+
364
+ class AgentDB:
365
+ def __init__(
366
+ self,
367
+ path: Optional[Any] = None,
368
+ memory_dir: Optional[Any] = None,
369
+ permissions_path: Optional[Any] = None,
370
+ tracking_directories: Optional[list[str]] = None,
371
+ tracking_root: Any = ".",
372
+ tracking_max_files_per_directory: Optional[int] = None,
373
+ db_path: Optional[Any] = None,
374
+ summarizer: Optional[Callable] = None,
375
+ infer_fn: Optional[Callable] = None,
376
+ embed_fn: Optional[Callable] = None,
377
+ local_embed: bool = False,
378
+ ) -> None:
379
+ # db_path is an alias for path (used by adapter tests)
380
+ if db_path is not None and path is None:
381
+ path = db_path
382
+ self._summarizer = summarizer
383
+ self._infer_fn = infer_fn
384
+ # Resolve memory_dir from args or env vars
385
+ resolved_memory_dir: Optional[Path] = None
386
+ if memory_dir is not None:
387
+ resolved_memory_dir = Path(memory_dir).resolve()
388
+ else:
389
+ env_memory_dir = os.environ.get("AGENTDB_MEMORY_DIR")
390
+ env_home = os.environ.get("AGENTDB_HOME")
391
+ if env_memory_dir:
392
+ resolved_memory_dir = Path(env_memory_dir).resolve()
393
+ elif env_home:
394
+ resolved_memory_dir = Path(env_home).resolve()
395
+
396
+ self.memory_dir: Optional[Path] = resolved_memory_dir
397
+
398
+ # Set up workspace directories if memory_dir is provided
399
+ self.memory_config_path: Optional[Path] = None
400
+ self.memory_config: dict = {}
401
+
402
+ if resolved_memory_dir is not None:
403
+ # Create workspace layout
404
+ for subdir in ("config", "database", "conversations", "embeddings", "reconciliation", "logs"):
405
+ (resolved_memory_dir / subdir).mkdir(parents=True, exist_ok=True)
406
+
407
+ config_path = resolved_memory_dir / "config" / "memory.yaml"
408
+ self.memory_config_path = config_path.resolve()
409
+
410
+ if not config_path.exists():
411
+ config_path.write_text(_DEFAULT_MEMORY_YAML, encoding="utf-8")
412
+
413
+ with config_path.open("r", encoding="utf-8") as f:
414
+ self.memory_config = yaml.safe_load(f) or {}
415
+
416
+ # Validate full_storage profile. profile may be a non-dict (e.g. a
417
+ # bare scalar like `profile: research`) here -- that's a config
418
+ # error, but it's _resolve_config's job (below) to raise it with a
419
+ # proper ConfigurationError; this check just needs to not crash on
420
+ # its way there.
421
+ _profile_block_for_check = self.memory_config.get("profile", {})
422
+ if not isinstance(_profile_block_for_check, dict):
423
+ _profile_block_for_check = {}
424
+ if _profile_block_for_check.get("name") == "full_storage":
425
+ td = self.memory_config.get("tracked_directories", [])
426
+ if not td:
427
+ raise ValueError(
428
+ "tracked_directories must be non-empty when profile.name is 'full_storage'"
429
+ )
430
+
431
+ # Determine DB path
432
+ if path is not None:
433
+ db_path = Path(path).resolve()
434
+ elif resolved_memory_dir is not None:
435
+ db_path = (resolved_memory_dir / "database" / "agentdb.sqlite").resolve()
436
+ else:
437
+ db_path = Path(":memory:")
438
+
439
+ self._path = db_path
440
+
441
+ # Set up permissions router
442
+ self._permissions: Optional[PermissionRouter] = None
443
+ if permissions_path is not None:
444
+ self._permissions = PermissionRouter(permissions_path)
445
+
446
+ # Initialize stores using shared DB path
447
+ self._store = MemoryStore(db_path)
448
+ self.audit_log = AuditLog(db_path)
449
+ self._system = SystemStats(db_path)
450
+
451
+ # Skills store - use separate DB or same
452
+ skills_db_path = db_path.parent / "skills.sqlite" if db_path != Path(":memory:") else db_path
453
+
454
+ def _on_skill_stale(skill_id: str) -> None:
455
+ self.gateway.dispatch("skill_stale", skill_id=skill_id)
456
+
457
+ self.skills = SkillStore(db_path=skills_db_path, on_stale_crossing=_on_skill_stale)
458
+
459
+ # Initialize outcomes table
460
+ self._init_outcomes_table()
461
+
462
+ # Resolve profile-based config. When no memory_dir was provided at
463
+ # all, there is no config file to speak of and we fall back to the
464
+ # budget_optimized built-in directly -- this is "no config given"
465
+ # rather than "invalid config". But if memory_dir WAS provided, any
466
+ # memory_config content (including a blank/empty file that parses to
467
+ # None/{}) goes through _resolve_config's strict profile.name
468
+ # validation -- never a silent fallback.
469
+ if self.memory_dir is not None:
470
+ self._resolved_config = _resolve_config(self.memory_config)
471
+ else:
472
+ self._resolved_config = _builtin_profile_dict("budget_optimized")
473
+
474
+ # Eagerly validate every profile.agents.* block now, so a typo in any
475
+ # bot's config is a startup failure, not a failure the first time
476
+ # that specific bot happens to retrieve.
477
+ agents = self.memory_config.get("profile", {}).get("agents", {}) if self.memory_config else {}
478
+ for agent_id in agents:
479
+ self.config_for(agent_id)
480
+
481
+ # Tracking config
482
+ self.tracking_directories: list[str] = tracking_directories or []
483
+ self.tracking_root: Path = Path(tracking_root).resolve()
484
+ self.tracking_max_files_per_directory: int = (
485
+ tracking_max_files_per_directory
486
+ if tracking_max_files_per_directory is not None
487
+ else self._resolved_config["capture"]["tracking_max_files_per_directory"]
488
+ )
489
+
490
+ # Session tracking: session_id -> list of record ids retrieved
491
+ self._session_record_ids: dict[str, list[str]] = {}
492
+
493
+ # Embedding layer (optional) — must be initialized before ranker
494
+ self._raw_embeddings_cfg: dict = (self.memory_config or {}).get("embeddings", {})
495
+ self._embedding_provider = None
496
+ self._embedding_store = None
497
+ if embed_fn is not None:
498
+ from ..embeddings import FunctionEmbeddingProvider, EmbeddingStore
499
+ self._embedding_provider = FunctionEmbeddingProvider(embed_fn)
500
+ elif local_embed:
501
+ from ..embeddings import LocalEmbeddingProvider, EmbeddingStore
502
+ self._embedding_provider = LocalEmbeddingProvider()
503
+ if self._embedding_provider is not None and str(self._path) != ":memory:":
504
+ from ..embeddings import EmbeddingStore
505
+ embed_db_path = self._path.parent / "embeddings.db"
506
+ self._embedding_store = EmbeddingStore(
507
+ db_path=embed_db_path,
508
+ model_name=self._embedding_provider.model_name,
509
+ dimensions=self._embedding_provider.dimensions,
510
+ )
511
+
512
+ # Skills API — created after embedding init so it receives embedding deps
513
+ self.skills_api = SkillsAPI(
514
+ self.skills,
515
+ embedding_provider=self._embedding_provider,
516
+ embedding_store=self._embedding_store,
517
+ )
518
+
519
+ # Ranker with weights from resolved config; semantic weight activated when provider present
520
+ self._ranker = self._build_ranker()
521
+
522
+ # Gateway surface (initialized after all dependencies are ready)
523
+ self._gateway: Optional[Gateway] = None
524
+
525
+ # Register session topic handler when embedding is active
526
+ if self._embedding_provider is not None:
527
+ self.gateway.subscribe("conversation_started", self._handle_conversation_started_embedding)
528
+
529
+ # Lifecycle manager — initialized after _session_record_ids is created
530
+ lc = self._resolved_config["lifecycle"]
531
+ self._lifecycle = LifecycleManager(
532
+ store=self._store,
533
+ system=self._system,
534
+ session_record_ids=self._session_record_ids,
535
+ embedding_store=self._embedding_store,
536
+ skill_store=self.skills,
537
+ confidence_decay_rate=lc["confidence_decay_rate"],
538
+ archival_threshold=lc["archival_threshold"],
539
+ staleness_days=lc["staleness_days"],
540
+ reinforce_rate=lc["reinforce_rate"],
541
+ )
542
+
543
+ def _build_ranker(self) -> "MultiSignalRanker":
544
+ rw = self._resolved_config["retrieval"]["ranker_weights"]
545
+ lc = self._resolved_config["lifecycle"]
546
+ staleness = lc["staleness_days"]
547
+ if self._embedding_provider is not None:
548
+ w = float(self._raw_embeddings_cfg.get("semantic_ranker_weight", 0.20))
549
+ scale = 1.0 - w
550
+ return MultiSignalRanker(
551
+ weight_provenance=rw["provenance"] * scale,
552
+ weight_recency=rw["recency"] * scale,
553
+ weight_confidence=rw["confidence"] * scale,
554
+ weight_causal=rw["causal"] * scale,
555
+ weight_semantic=w,
556
+ weight_workspace=rw.get("workspace", 0.0) * scale,
557
+ staleness_days=staleness,
558
+ )
559
+ return MultiSignalRanker(
560
+ weight_provenance=rw["provenance"],
561
+ weight_recency=rw["recency"],
562
+ weight_confidence=rw["confidence"],
563
+ weight_causal=rw["causal"],
564
+ weight_semantic=rw["semantic"],
565
+ weight_workspace=rw.get("workspace", 0.0),
566
+ staleness_days=staleness,
567
+ )
568
+
569
+ def _handle_conversation_started_embedding(self, **kwargs) -> None:
570
+ session_id = kwargs.get("session_id")
571
+ opening_message = kwargs.get("opening_message", "")
572
+ if not session_id or not opening_message:
573
+ return
574
+ try:
575
+ vec = self._embedding_provider.embed(opening_message)
576
+ self._embedding_store.put_session_topic(session_id, vec)
577
+ except Exception:
578
+ pass
579
+
580
+ @property
581
+ def _reconciliation_handlers(self) -> dict[str, list[Callable]]: # type: ignore[override]
582
+ """Deprecated — handlers now live in db.gateway._handlers."""
583
+ warnings.warn(
584
+ "AgentDB._reconciliation_handlers is deprecated and will be removed in a future version. "
585
+ "Use db.gateway._handlers instead.",
586
+ DeprecationWarning,
587
+ stacklevel=2,
588
+ )
589
+ return self.gateway._handlers
590
+
591
+ @property
592
+ def gateway(self) -> Gateway:
593
+ """Unified governance surface: reconciliation, conflict queue, proposals."""
594
+ if self._gateway is None:
595
+ self._gateway = Gateway(self)
596
+ return self._gateway
597
+
598
+ @property
599
+ def system(self) -> SystemStats:
600
+ """System statistics surface."""
601
+ return self._system
602
+
603
+ @property
604
+ def lifecycle(self) -> LifecycleManager:
605
+ """Lifecycle manager: sweep, reinforce, record_outcome."""
606
+ return self._lifecycle
607
+
608
+ @property
609
+ def config(self) -> dict:
610
+ """Fully resolved config: profile defaults merged with memory.yaml overrides."""
611
+ return self._resolved_config
612
+
613
+ def config_for(self, agent_id: str) -> dict:
614
+ """Resolved config for a specific agent: the global profile resolution,
615
+ with this agent's own overrides (if listed under profile.agents) merged
616
+ on top. isolate_by_agent is derived here -- True iff agent_id is listed
617
+ -- and is never accepted as an input anywhere in this codebase."""
618
+ agents = self.memory_config.get("profile", {}).get("agents", {}) if self.memory_config else {}
619
+ if agent_id not in agents:
620
+ return {**self.config, "isolate_by_agent": False}
621
+
622
+ agent_block = agents[agent_id] or {}
623
+ base = _builtin_profile_dict(agent_block["name"]) if "name" in agent_block else self.config
624
+ resolved = _validate_and_merge_overrides(base, agent_block, where=f"profile.agents.{agent_id}")
625
+ return {**resolved, "isolate_by_agent": True}
626
+
627
+ def _init_outcomes_table(self) -> None:
628
+ with self._store._connect() as conn:
629
+ conn.execute(_CREATE_OUTCOMES)
630
+ conn.execute(_CREATE_EXTERNAL_RETRIEVES)
631
+
632
+ def _derive_trust_level(self, origin: str) -> str:
633
+ if origin == "human_approved":
634
+ return "human"
635
+ elif origin == "provider_ingested":
636
+ return "provider"
637
+ else:
638
+ return "agent"
639
+
640
+ def _derive_trust_zone(self, origin: str) -> str:
641
+ """Derive the trust zone from origin for permission checks."""
642
+ if origin == "human_approved":
643
+ return "human_approved"
644
+ return "agent_inferred"
645
+
646
+ def write(
647
+ self,
648
+ key: str,
649
+ value: Any,
650
+ origin: str,
651
+ agent_id: str,
652
+ session_id: Optional[str] = None,
653
+ entities: Optional[list] = None,
654
+ confidence: Optional[float] = None,
655
+ infer: bool = False,
656
+ expiry_time: Optional["datetime"] = None,
657
+ working_dirs: Optional[list[str]] = None,
658
+ ) -> MemoryRecord:
659
+ if infer:
660
+ if self._infer_fn is None:
661
+ raise ValueError(
662
+ "infer=True requires an infer_fn — pass infer_fn=<callable> to AgentDB()"
663
+ )
664
+ value = self._infer_fn(value)
665
+
666
+ trust_zone = self._derive_trust_zone(origin)
667
+
668
+ # Permission check
669
+ if self._permissions is not None:
670
+ try:
671
+ self._permissions.check_write(key, agent_id, trust_zone)
672
+ except PermissionDenied as e:
673
+ self.audit_log.append(
674
+ EVENT_PERMISSION_DENIED,
675
+ {
676
+ "key": key,
677
+ "agent_id": agent_id,
678
+ "trust_zone": trust_zone,
679
+ "reason": str(e),
680
+ },
681
+ )
682
+ raise
683
+
684
+ trust_level = self._derive_trust_level(origin)
685
+ record = MemoryRecord(
686
+ key=key,
687
+ value=value,
688
+ origin=origin,
689
+ trust_level=trust_level,
690
+ agent_id=agent_id,
691
+ confidence=confidence if confidence is not None else 1.0,
692
+ entities=list(entities) if entities else [],
693
+ expiry_time=expiry_time,
694
+ inferred=infer,
695
+ working_dirs=[os.path.realpath(d) for d in (working_dirs or [])],
696
+ )
697
+
698
+ # Conflict detection: check before persisting
699
+ existing = self._store.get_by_key(key)
700
+ if existing is not None and not existing.archived:
701
+ conflict = ConflictDetector().check(new_record=record, existing=existing)
702
+ if conflict is not None:
703
+ record.conflict_id = conflict.id
704
+ existing.conflict_id = conflict.id
705
+ self._store.update_conflict_id(existing.id, conflict.id)
706
+ self._store.store_conflict(conflict)
707
+ self.gateway.dispatch(
708
+ "conflict_detected",
709
+ conflict_id=conflict.id,
710
+ conflict_type=conflict.conflict_type,
711
+ key=key,
712
+ agent_id=agent_id,
713
+ session_id=session_id,
714
+ )
715
+ self.audit_log.append(
716
+ "conflict_detected",
717
+ {
718
+ "conflict_id": conflict.id,
719
+ "conflict_type": conflict.conflict_type,
720
+ "key": key,
721
+ "record_a_id": existing.id,
722
+ "record_b_id": record.id,
723
+ "agent_id": agent_id,
724
+ },
725
+ )
726
+
727
+ self._store.write(record)
728
+
729
+ # Store embedding (non-blocking — failures never propagate to caller)
730
+ if self._embedding_provider is not None and self._embedding_store is not None:
731
+ try:
732
+ from ..embeddings.text import _embed_text_for_record
733
+ vec = self._embedding_provider.embed(_embed_text_for_record(record))
734
+ self._embedding_store.put(
735
+ "memories", record.id, vec, working_dirs=record.working_dirs
736
+ )
737
+ except Exception:
738
+ pass
739
+
740
+ # Semantic conflict detection (requires embedding to be stored first)
741
+ try:
742
+ threshold = float(
743
+ self._raw_embeddings_cfg.get("semantic_conflict_threshold", 0.92)
744
+ )
745
+ recent = [r for r in self._store.list_all()
746
+ if r.agent_id == agent_id and r.id != record.id and not r.archived]
747
+ recent.sort(key=lambda r: r.created_at, reverse=True)
748
+ sem_conflicts = ConflictDetector.check_semantic_batch(
749
+ new_record=record,
750
+ candidates=recent,
751
+ embedding_store=self._embedding_store,
752
+ threshold=threshold,
753
+ )
754
+ for sc in sem_conflicts:
755
+ self._store.store_conflict(sc)
756
+ except Exception:
757
+ pass
758
+
759
+ self.audit_log.append(
760
+ EVENT_MEMORY_WRITE,
761
+ {
762
+ "key": key,
763
+ "agent_id": agent_id,
764
+ "session_id": session_id,
765
+ "record_id": record.id,
766
+ },
767
+ )
768
+ return record
769
+
770
+ def read(self, key: str, agent_id: Optional[str] = None) -> Optional[MemoryRecord]:
771
+ """Raw read by key, no permission check."""
772
+ return self._store.get_by_key(key)
773
+
774
+ def delete(self, key: str, agent_id: str, trust_zone: str) -> None:
775
+ """Archive a record by key. Raises PermissionDenied if not allowed."""
776
+ if self._permissions is not None:
777
+ try:
778
+ self._permissions.check_write(key, agent_id, trust_zone)
779
+ except PermissionDenied as e:
780
+ self.audit_log.append(
781
+ EVENT_PERMISSION_DENIED,
782
+ {
783
+ "key": key,
784
+ "agent_id": agent_id,
785
+ "trust_zone": trust_zone,
786
+ "reason": str(e),
787
+ },
788
+ )
789
+ raise
790
+
791
+ self._store.delete_by_key(key)
792
+ self.audit_log.append(
793
+ EVENT_MEMORY_DELETE,
794
+ {"key": key, "agent_id": agent_id},
795
+ )
796
+
797
+ def retrieve(
798
+ self,
799
+ query: str,
800
+ agent_id: str,
801
+ limit: Optional[int] = None,
802
+ session_id: Optional[str] = None,
803
+ external_context: Optional[list[dict]] = None,
804
+ budget_tokens: Optional[int] = None,
805
+ working_dirs: Optional[list[str]] = None,
806
+ ) -> RetrievalResult:
807
+ agent_config = self.config_for(agent_id)
808
+ isolate_by_agent = agent_config["isolate_by_agent"]
809
+ resolved_limit = limit if limit is not None else agent_config["retrieval"]["retrieve_limit"]
810
+ resolved_budget_tokens = budget_tokens if budget_tokens is not None else agent_config["injection"]["context_token_limit"]
811
+
812
+ # Query gate — skip embedding work for generic/short queries
813
+ _stop_phrases = frozenset(
814
+ self._raw_embeddings_cfg.get("generic_query_stop_phrases", [
815
+ "ok", "go ahead", "sounds good", "yes", "no", "sure",
816
+ "proceed", "continue", "let's review", "sounds great",
817
+ ])
818
+ )
819
+ _min_tokens = int(self._raw_embeddings_cfg.get("min_query_tokens", 3))
820
+ _query_passes_gate = (
821
+ self._embedding_provider is not None
822
+ and len(query.split()) >= _min_tokens
823
+ and query.strip().lower() not in _stop_phrases
824
+ )
825
+ query_embedding: Optional[list[float]] = None
826
+ if _query_passes_gate:
827
+ try:
828
+ query_embedding = self._embedding_provider.embed(query)
829
+ except Exception:
830
+ query_embedding = None
831
+
832
+ candidates = self._store.search(
833
+ query, limit=max(resolved_limit, 50), agent_id=agent_id if isolate_by_agent else None,
834
+ )
835
+
836
+ # Scope episodic records to the current session. Episodic records are
837
+ # session-local by design; cross-session insights should be promoted to
838
+ # structured non-episodic keys. Non-episodic records pass through unfiltered.
839
+ _EPISODIC_PREFIX = "memory/episodic/"
840
+ if session_id is not None:
841
+ candidates = [
842
+ r for r in candidates
843
+ if _EPISODIC_PREFIX not in r.key or f"/{session_id}/" in r.key
844
+ ]
845
+
846
+ # Embedding hydration + session topic filter
847
+ if _query_passes_gate and self._embedding_store is not None:
848
+ from ..embeddings.provider import _cosine
849
+ from ..embeddings.text import _embed_text_for_record
850
+
851
+ record_ids = [r.id for r in candidates]
852
+ stored_embeddings = self._embedding_store.get_batch("memories", record_ids)
853
+
854
+ # Lazy regen: embed records that have no stored vector
855
+ missing = [r for r in candidates if r.id not in stored_embeddings]
856
+ if missing:
857
+ try:
858
+ vecs = self._embedding_provider.embed_batch(
859
+ [_embed_text_for_record(r) for r in missing]
860
+ )
861
+ for rec, vec in zip(missing, vecs):
862
+ self._embedding_store.put(
863
+ "memories", rec.id, vec, working_dirs=rec.working_dirs
864
+ )
865
+ stored_embeddings[rec.id] = vec
866
+ except Exception:
867
+ pass
868
+
869
+ # Session topic filter
870
+ if session_id:
871
+ session_topic = self._embedding_store.get_session_topic(session_id)
872
+ if session_topic is not None:
873
+ _threshold = float(
874
+ self._raw_embeddings_cfg.get("session_topic_threshold", 0.35)
875
+ )
876
+ candidates = [
877
+ r for r in candidates
878
+ if r.id not in stored_embeddings # no embedding → always pass
879
+ or _cosine(stored_embeddings[r.id], session_topic) >= _threshold
880
+ ]
881
+
882
+ # Attach embeddings as transient field before ranking
883
+ for r in candidates:
884
+ if r.id in stored_embeddings:
885
+ r.embedding = stored_embeddings[r.id]
886
+
887
+ ranked_all = self._ranker.rank(
888
+ query, candidates, agent_id=agent_id,
889
+ query_embedding=query_embedding,
890
+ session_working_dirs=working_dirs,
891
+ )
892
+
893
+ # Drop records that don't meet the minimum relevance threshold.
894
+ min_score = agent_config["retrieval"]["min_composite_score"]
895
+ if min_score > 0.0:
896
+ ranked_all = [rr for rr in ranked_all if rr.composite_score >= min_score]
897
+
898
+ ranked = ranked_all[:resolved_limit]
899
+
900
+ # Budget cap
901
+ selected: list[MemoryRecord] = []
902
+ token_total = 0
903
+ truncated = False
904
+ for rr in ranked:
905
+ record = rr.record
906
+ est = _estimate_tokens(record)
907
+ if resolved_budget_tokens is not None and token_total + est > resolved_budget_tokens:
908
+ truncated = True
909
+ break
910
+ selected.append(record)
911
+ token_total += est
912
+ records = selected
913
+
914
+ # Increment retrieval counts
915
+ for record in records:
916
+ self._store.increment_retrieval_count(record.id)
917
+
918
+ record_ids = [r.id for r in records]
919
+ signal_scores = {rr.record.id: rr.composite_score for rr in ranked}
920
+
921
+ # Track for outcome recording
922
+ if session_id is not None:
923
+ if session_id not in self._session_record_ids:
924
+ self._session_record_ids[session_id] = []
925
+ self._session_record_ids[session_id].extend(record_ids)
926
+
927
+ seq = self.audit_log.append(
928
+ EVENT_MEMORY_RETRIEVE,
929
+ {
930
+ "query": query,
931
+ "record_ids": record_ids,
932
+ "signal_scores": signal_scores,
933
+ "session_id": session_id,
934
+ "trigger_source": "query",
935
+ "reconciliation_run_id": None,
936
+ "isolate_by_agent": isolate_by_agent,
937
+ },
938
+ )
939
+
940
+ # Handle external context conflict detection
941
+ had_conflict = False
942
+ if external_context is not None:
943
+ # Check for overlap: any content word matches a record value
944
+ ext_words = set()
945
+ for ctx in external_context:
946
+ content = ctx.get("content", "")
947
+ ext_words.update(w.lower() for w in content.split())
948
+
949
+ overlap_records = []
950
+ for record in records:
951
+ value_str = (
952
+ json.dumps(record.value)
953
+ if isinstance(record.value, (dict, list))
954
+ else str(record.value)
955
+ ).lower()
956
+ for word in ext_words:
957
+ if word in value_str:
958
+ overlap_records.append(record)
959
+ break
960
+
961
+ if overlap_records:
962
+ had_conflict = True
963
+ self.audit_log.append(
964
+ EVENT_EXTERNAL_CONFLICT_DETECTED,
965
+ {
966
+ "query": query,
967
+ "count": len(overlap_records),
968
+ "sources": [ctx.get("source", "") for ctx in external_context],
969
+ "session_id": session_id,
970
+ "trigger_source": "query",
971
+ "reconciliation_run_id": None,
972
+ },
973
+ )
974
+
975
+ # Record external retrieve stats
976
+ with self._store._connect() as conn:
977
+ conn.execute(
978
+ "INSERT INTO external_retrieves (session_id, had_external_context, had_conflict) VALUES (?, ?, ?)",
979
+ (session_id or "", 1, int(had_conflict)),
980
+ )
981
+
982
+ # Retrieve matching skills for this agent
983
+ all_skills = self.skills.get_for_agent(agent_id)
984
+ skill_warnings: list[str] = []
985
+ for skill in all_skills:
986
+ self.skills.mark_retrieved(skill.id)
987
+ if skill.health_score < 0.6 or "staleness" in skill.health_score_reasons:
988
+ skill_warnings.append(
989
+ f"Skill '{skill.id}' is stale (health_score={skill.health_score:.2f})"
990
+ )
991
+
992
+ # Fetch conflicts for returned records
993
+ conflicts = self._store.get_conflicts_for_records([r.id for r in records])
994
+
995
+ # Build override instructions for authority conflicts
996
+ override_instructions = []
997
+ for c in conflicts:
998
+ if c.conflict_type == "authority":
999
+ override_instructions.append(
1000
+ f"Authority conflict on a retrieved memory (conflict_id={c.id}): "
1001
+ "the human-approved version takes precedence over any agent-inferred value for this key."
1002
+ )
1003
+
1004
+ return RetrievalResult(
1005
+ records=records,
1006
+ conflicts=conflicts,
1007
+ override_instructions=override_instructions,
1008
+ skills=all_skills,
1009
+ skill_warnings=skill_warnings,
1010
+ token_estimate=token_total,
1011
+ truncated=truncated,
1012
+ )
1013
+
1014
+ def retrieve_by_entities(
1015
+ self,
1016
+ entities: list[str],
1017
+ agent_id: str,
1018
+ session_id: Optional[str] = None,
1019
+ ) -> RetrievalResult:
1020
+ agent_config = self.config_for(agent_id)
1021
+ isolate_by_agent = agent_config["isolate_by_agent"]
1022
+ records = self._store.search_by_entities(
1023
+ entities, agent_id=agent_id if isolate_by_agent else None,
1024
+ )
1025
+
1026
+ record_ids = [r.id for r in records]
1027
+
1028
+ # Track for outcome recording
1029
+ if session_id is not None:
1030
+ if session_id not in self._session_record_ids:
1031
+ self._session_record_ids[session_id] = []
1032
+ self._session_record_ids[session_id].extend(record_ids)
1033
+
1034
+ self.audit_log.append(
1035
+ EVENT_MEMORY_RETRIEVE,
1036
+ {
1037
+ "query": "",
1038
+ "record_ids": record_ids,
1039
+ "signal_scores": {r.id: 1.0 for r in records},
1040
+ "session_id": session_id,
1041
+ "trigger_source": "entity",
1042
+ "entities": entities,
1043
+ "reconciliation_run_id": None,
1044
+ "isolate_by_agent": isolate_by_agent,
1045
+ },
1046
+ )
1047
+
1048
+ all_skills = self.skills.get_for_agent(agent_id)
1049
+ skill_warnings: list[str] = []
1050
+ for skill in all_skills:
1051
+ self.skills.mark_retrieved(skill.id)
1052
+ if skill.health_score < 0.6 or "staleness" in skill.health_score_reasons:
1053
+ skill_warnings.append(
1054
+ f"Skill '{skill.id}' is stale (health_score={skill.health_score:.2f})"
1055
+ )
1056
+
1057
+ # Fetch conflicts for returned records
1058
+ conflicts = self._store.get_conflicts_for_records([r.id for r in records])
1059
+
1060
+ override_instructions = []
1061
+ for c in conflicts:
1062
+ if c.conflict_type == "authority":
1063
+ override_instructions.append(
1064
+ f"Authority conflict on a retrieved memory (conflict_id={c.id}): "
1065
+ "the human-approved version takes precedence over any agent-inferred value for this key."
1066
+ )
1067
+
1068
+ return RetrievalResult(
1069
+ records=records,
1070
+ conflicts=conflicts,
1071
+ override_instructions=override_instructions,
1072
+ skills=all_skills,
1073
+ skill_warnings=skill_warnings,
1074
+ )
1075
+
1076
+ def record_outcome(
1077
+ self,
1078
+ session_id: str,
1079
+ agent_id: str,
1080
+ outcome_type: str,
1081
+ outcome_value: float,
1082
+ ) -> int:
1083
+ """Backward-compat alias — delegates to db.lifecycle.record_outcome()."""
1084
+ adjusted_ids = self.lifecycle.record_outcome(
1085
+ session_id=session_id,
1086
+ agent_id=agent_id,
1087
+ outcome_type=outcome_type,
1088
+ outcome_value=outcome_value,
1089
+ )
1090
+ self.audit_log.append(
1091
+ EVENT_MEMORY_OUTCOME,
1092
+ {
1093
+ "session_id": session_id,
1094
+ "outcome_type": outcome_type,
1095
+ "outcome_value": float(outcome_value),
1096
+ "linked_retrieval_sequence": None,
1097
+ "adjusted_record_ids": adjusted_ids,
1098
+ },
1099
+ )
1100
+ return len(adjusted_ids)
1101
+
1102
+ def register_reconciliation_handler(self, trigger: str, handler: Callable) -> None:
1103
+ """Backward-compat alias → db.gateway.subscribe(trigger, handler)."""
1104
+ self.gateway.subscribe(trigger, handler)
1105
+
1106
+ def dispatch_reconciliation_trigger(self, trigger: str, **kwargs) -> int:
1107
+ """Backward-compat alias → db.gateway.dispatch(trigger, **kwargs)."""
1108
+ return self.gateway.dispatch(trigger, **kwargs)