amfs-adapter-postgres 0.2.40__tar.gz → 0.2.42__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/PKG-INFO +2 -2
  2. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/pyproject.toml +2 -2
  3. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/adapter.py +400 -27
  4. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/.gitignore +0 -0
  5. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/__init__.py +0 -0
  6. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/_fts.py +0 -0
  7. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/async_adapter.py +0 -0
  8. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/evidence_sql.py +0 -0
  9. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/knn.py +0 -0
  10. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/migrations/001_initial.sql +0 -0
  11. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/migrations/002_artifact_refs.sql +0 -0
  12. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/migrations/002_search_indexes.sql +0 -0
  13. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/migrations/003_knowledge_graph.sql +0 -0
  14. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/migrations/004_agent_groups.sql +0 -0
  15. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/migrations/005_configurable_embedding_dim.sql +0 -0
  16. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/migrations/006_is_artifact.sql +0 -0
  17. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/migrations/006_outcome_confidence_semantics.sql +0 -0
  18. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/migrations/007_outcome_propagation_row_copy.sql +0 -0
  19. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/migrations/008_outcome_evidence.sql +0 -0
  20. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/migrations/009_action_priors.sql +0 -0
  21. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/migrations/010_local_evidence.sql +0 -0
  22. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/migrations/011_outcome_task_text.sql +0 -0
  23. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/migrations/012_evidence_from_model.sql +0 -0
  24. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/migrations/013_outcome_verified_by_backfill.sql +0 -0
  25. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/retention.py +0 -0
  26. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/schema.sql +0 -0
  27. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/tenant_context.py +0 -0
  28. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/tenant_gucs.py +0 -0
  29. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.42}/src/amfs_postgres/trace_partitions.py +0 -0
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: amfs-adapter-postgres
3
- Version: 0.2.40
3
+ Version: 0.2.42
4
4
  Summary: AMFS Postgres adapter with back-propagation triggers
5
5
  License-Expression: Apache-2.0
6
6
  Requires-Python: >=3.11
7
- Requires-Dist: amfs-core>=0.3.52
7
+ Requires-Dist: amfs-core>=0.3.53
8
8
  Requires-Dist: psycopg-pool>=3.1
9
9
  Requires-Dist: psycopg[binary]<4,>=3.1
@@ -1,13 +1,13 @@
1
1
  [project]
2
2
  name = "amfs-adapter-postgres"
3
- version = "0.2.40"
3
+ version = "0.2.42"
4
4
  description = "AMFS Postgres adapter with back-propagation triggers"
5
5
  requires-python = ">=3.11"
6
6
  license = "Apache-2.0"
7
7
  dependencies = [
8
8
  # 0.3.14 adds the capture fields this adapter now persists and selects back,
9
9
  # tool_calls among them.
10
- "amfs-core>=0.3.52",
10
+ "amfs-core>=0.3.53",
11
11
  "psycopg[binary]>=3.1,<4",
12
12
  "psycopg-pool>=3.1",
13
13
  ]
@@ -183,6 +183,45 @@ def _paginate_sql(
183
183
  return query, params
184
184
 
185
185
 
186
+ def priority_score_sql(now: datetime, config: Any | None = None) -> tuple[str, list[Any]]:
187
+ """``amfs_core.tiering.PriorityScorer.score`` as a SQL expression.
188
+
189
+ The tiers pages rank every current entry in the account by this score and
190
+ take the top 50 as Hot and the next 200 as Warm. Scoring in Python meant
191
+ loading the whole namespace first; on a 420K-entry account the two
192
+ requests the page makes together took an 8 GiB instance down. The
193
+ expression below is the same formula over the same columns, so the
194
+ database can order by it and hand back one page::
195
+
196
+ (alpha * importance + beta * recency) * freq_boost * time_decay
197
+
198
+ with ``importance = importance_score or confidence`` clamped to [0, 1],
199
+ ``recency = exp(-lambda * age_days)``, ``freq_boost = max(1, ln(1 +
200
+ recall_count))`` and ``time_decay = exp(-lambda * age_days / freq_boost)``.
201
+ ``importance_score`` of 0 falls through to confidence exactly as the
202
+ Python ``or`` does. Returns the expression and its parameters in placeholder
203
+ order: alpha, beta, lambda, *now*, lambda, *now*.
204
+ """
205
+ if config is None:
206
+ from amfs_core.models import TierConfig
207
+
208
+ config = TierConfig()
209
+ age_days = "GREATEST(0.0, EXTRACT(EPOCH FROM (%s - written_at)) / 86400.0)"
210
+ importance = (
211
+ "LEAST(1.0, GREATEST(0.0, COALESCE(NULLIF(importance_score, 0), confidence)::float8))"
212
+ )
213
+ freq_boost = "GREATEST(1.0, LN(1.0 + COALESCE(recall_count, 0)))"
214
+ expr = (
215
+ f"((%s * {importance} + %s * EXP(-%s * {age_days}))"
216
+ f" * {freq_boost}"
217
+ f" * EXP(-%s * {age_days} / {freq_boost}))"
218
+ )
219
+ # The age expression appears twice, each with its own ``now``.
220
+ lam = float(config.decay_lambda)
221
+ params: list[Any] = [float(config.alpha), float(config.beta), lam, now, lam, now]
222
+ return expr, params
223
+
224
+
186
225
  def list_conditions(
187
226
  namespace: str,
188
227
  entity_path: str | None,
@@ -477,6 +516,7 @@ from amfs_core.aggregates import ( # noqa: E402
477
516
  CHARS_PER_TOKEN as _CHARS_PER_TOKEN,
478
517
  RECALL_TOKENS_CEIL as _RECALL_TOKENS_CEIL,
479
518
  RECALL_TOKENS_FLOOR as _RECALL_TOKENS_FLOOR,
519
+ entity_confidence_buckets,
480
520
  )
481
521
 
482
522
  # Guarded by recall_count, because an entry never recalled contributes zero
@@ -2499,6 +2539,90 @@ class PostgresAdapter(AdapterABC):
2499
2539
  row = cur.fetchone()
2500
2540
  return int(row["n"]) if row else 0
2501
2541
 
2542
+ def priority_stats(
2543
+ self,
2544
+ *,
2545
+ scope: SqlScope | None = None,
2546
+ agent_id: str | None = None,
2547
+ now: datetime | None = None,
2548
+ branch: str = "main",
2549
+ ) -> tuple[int, float | None]:
2550
+ """``(count, mean priority score)`` over the rows :meth:`list` returns.
2551
+
2552
+ What the tiers distribution needs and nothing more: the count fixes
2553
+ the Hot/Warm/Archive split (the tiers are rank bands) and the mean is
2554
+ the page's "average score". Computed by the database with
2555
+ :func:`priority_score_sql`; no entry row leaves it.
2556
+ """
2557
+ now = now or datetime.now(timezone.utc)
2558
+ score, score_params = priority_score_sql(now)
2559
+ with self._pool.connection() as conn:
2560
+ with conn.cursor() as cur:
2561
+ conditions, params = list_conditions(
2562
+ self._namespace,
2563
+ None,
2564
+ include_superseded=False,
2565
+ branch=branch,
2566
+ scope=scope,
2567
+ agent_id=agent_id,
2568
+ branch_scope=self._branch_scope(cur, branch),
2569
+ )
2570
+ where = " AND ".join(conditions)
2571
+ cur.execute(
2572
+ f"SELECT COUNT(*) AS n, AVG({score}) AS mean "
2573
+ f"FROM amfs_memory_entries WHERE {where}",
2574
+ [*score_params, *params],
2575
+ )
2576
+ row = cur.fetchone()
2577
+ if not row or not row["n"]:
2578
+ return 0, None
2579
+ mean = row["mean"]
2580
+ return int(row["n"]), (float(mean) if mean is not None else None)
2581
+
2582
+ def list_by_priority(
2583
+ self,
2584
+ *,
2585
+ scope: SqlScope | None = None,
2586
+ agent_id: str | None = None,
2587
+ limit: int,
2588
+ offset: int = 0,
2589
+ now: datetime | None = None,
2590
+ branch: str = "main",
2591
+ ) -> list[MemoryEntry]:
2592
+ """One page of the rows :meth:`list` returns, highest priority first.
2593
+
2594
+ The order is :func:`priority_score_sql` descending with the stable
2595
+ ``written_at DESC, entity_path, key`` tiebreak, so a page boundary is
2596
+ deterministic. A tier is a band of this ranking: rows 0–49 are Hot,
2597
+ 50–249 Warm, the rest Archive — the caller picks *offset* and *limit*
2598
+ from the band and the database returns only that page.
2599
+ """
2600
+ now = now or datetime.now(timezone.utc)
2601
+ score, score_params = priority_score_sql(now)
2602
+ with self._pool.connection() as conn:
2603
+ with conn.cursor() as cur:
2604
+ conditions, params = list_conditions(
2605
+ self._namespace,
2606
+ None,
2607
+ include_superseded=False,
2608
+ branch=branch,
2609
+ scope=scope,
2610
+ agent_id=agent_id,
2611
+ branch_scope=self._branch_scope(cur, branch),
2612
+ )
2613
+ where = " AND ".join(conditions)
2614
+ query = (
2615
+ f"SELECT {entry_select(self._has_is_artifact_col, self._has_validators_col)} "
2616
+ f"FROM amfs_memory_entries WHERE {where} "
2617
+ f"ORDER BY {score} DESC, written_at DESC, entity_path, key"
2618
+ )
2619
+ query, all_params = _paginate_sql(
2620
+ query, [*params, *score_params], limit=limit, offset=offset
2621
+ )
2622
+ cur.execute(query, all_params)
2623
+ rows = cur.fetchall()
2624
+ return [self._row_to_entry(r) for r in rows]
2625
+
2502
2626
  def entry_authors(self, refs: list[tuple[str, str]]) -> dict[tuple[str, str], str]:
2503
2627
  """Who wrote each ``(entity_path, key)``, for the current version.
2504
2628
 
@@ -3434,17 +3558,40 @@ class PostgresAdapter(AdapterABC):
3434
3558
  newest_entry_at=row["newest_entry_at"],
3435
3559
  )
3436
3560
 
3561
+ #: ``sort`` values ``entity_summaries`` accepts, and the ORDER BY each is.
3562
+ _ENTITY_SUMMARY_SORTS: dict[str, str] = {
3563
+ "last_updated": "last_updated DESC NULLS LAST, entity_path",
3564
+ "entry_count": "entry_count DESC, entity_path",
3565
+ "total_recalls": "total_recalls DESC, entity_path",
3566
+ "avg_confidence": "avg_confidence DESC NULLS LAST, entity_path",
3567
+ "avg_confidence_asc": "avg_confidence ASC NULLS LAST, entity_path",
3568
+ "entity_path": "entity_path",
3569
+ }
3570
+
3437
3571
  def entity_summaries(
3438
3572
  self,
3439
3573
  *,
3440
3574
  agent_ids: list[str] | None = None,
3441
3575
  scope: SqlScope | None = None,
3576
+ q: str | None = None,
3577
+ agent: str | None = None,
3578
+ min_confidence: float | None = None,
3579
+ sort: str = "last_updated",
3580
+ limit: int | None = None,
3581
+ offset: int = 0,
3442
3582
  ) -> list[dict[str, Any]]:
3443
3583
  """Per-entity aggregates via GROUP BY — never loads entry values.
3444
3584
 
3445
3585
  *scope* is a visibility predicate (room co-member rules and the like)
3446
3586
  that ``agent_ids`` alone cannot express; with it, a per-user dashboard
3447
3587
  gets this query instead of a Python pass over every entry.
3588
+
3589
+ *q* keeps the paths containing it (case-insensitive), *agent* the
3590
+ entities that agent has written to, *min_confidence* those whose mean
3591
+ confidence reaches it — the topics directory's filters, in SQL. With
3592
+ *limit* one page comes back and every row carries ``total``, the
3593
+ count before paging. Unpaged, the whole list is returned as before —
3594
+ 1.6 MB on a 4,000-entity account, so a directory should page.
3448
3595
  """
3449
3596
  conditions = ["namespace = %s", "branch = 'main'", "superseded_at IS NULL"]
3450
3597
  params: list[Any] = [self._namespace]
@@ -3453,6 +3600,24 @@ class PostgresAdapter(AdapterABC):
3453
3600
  params.append(list(agent_ids))
3454
3601
  if scope is not None:
3455
3602
  scope.apply(conditions, params)
3603
+ if q:
3604
+ pattern = "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
3605
+ conditions.append("entity_path ILIKE %s")
3606
+ params.append(pattern)
3607
+ having: list[str] = []
3608
+ having_params: list[Any] = []
3609
+ if agent:
3610
+ having.append("BOOL_OR(agent_id = %s)")
3611
+ having_params.append(agent)
3612
+ if min_confidence is not None:
3613
+ having.append("AVG(confidence) >= %s")
3614
+ having_params.append(float(min_confidence))
3615
+ if sort not in self._ENTITY_SUMMARY_SORTS:
3616
+ raise ValueError(f"unknown sort {sort!r}")
3617
+ order_by = self._ENTITY_SUMMARY_SORTS[sort]
3618
+ page = ""
3619
+ if limit is not None:
3620
+ page = "LIMIT %s OFFSET %s"
3456
3621
  # No entity_path argument to this one: it is unscoped by construction,
3457
3622
  # so shared namespaces are always excluded. It groups by entity_path,
3458
3623
  # which would otherwise list a shared namespace's topics as though
@@ -3478,8 +3643,21 @@ class PostgresAdapter(AdapterABC):
3478
3643
  FROM amfs_memory_entries
3479
3644
  WHERE {where}
3480
3645
  GROUP BY entity_path
3481
- ORDER BY MAX(written_at) DESC
3646
+ {("HAVING " + " AND ".join(having)) if having else ""}
3482
3647
  """
3648
+ params.extend(having_params)
3649
+ if limit is not None:
3650
+ # Paged: the count before paging rides on every row (one window
3651
+ # over the grouped rows, no second statement).
3652
+ params.extend([int(limit), max(0, int(offset))])
3653
+ sql = f"""
3654
+ SELECT x.*, COUNT(*) OVER () AS total
3655
+ FROM ({sql}) x
3656
+ ORDER BY {order_by}
3657
+ {page}
3658
+ """
3659
+ else:
3660
+ sql = f"SELECT * FROM ({sql}) x ORDER BY {order_by}"
3483
3661
  with self._pool.connection() as conn:
3484
3662
  with conn.cursor() as cur:
3485
3663
  # The checkout is one transaction (see _TenantRLSConnection),
@@ -3491,6 +3669,7 @@ class PostgresAdapter(AdapterABC):
3491
3669
 
3492
3670
  return [
3493
3671
  {
3672
+ **({"total": int(r["total"])} if limit is not None else {}),
3494
3673
  "entity_path": r["entity_path"],
3495
3674
  "entry_count": r["entry_count"],
3496
3675
  "avg_confidence": float(r["avg_confidence"] or 0),
@@ -3504,6 +3683,44 @@ class PostgresAdapter(AdapterABC):
3504
3683
  for r in rows
3505
3684
  ]
3506
3685
 
3686
+ def consolidation_ready_entities(
3687
+ self,
3688
+ *,
3689
+ min_entries: int = 10,
3690
+ limit: int = 10,
3691
+ scope: SqlScope | None = None,
3692
+ ) -> list[str]:
3693
+ """Entity paths with at least *min_entries* live entries, largest first.
3694
+
3695
+ What the consolidation status card shows. Counted in SQL: the handler
3696
+ used to load every entry on the account into Python to count them per
3697
+ path — two to three minutes on 430K entries, for ten path names.
3698
+ """
3699
+ conditions = ["namespace = %s", "branch = 'main'", "superseded_at IS NULL"]
3700
+ params: list[Any] = [self._namespace]
3701
+ if scope is not None:
3702
+ scope.apply(conditions, params)
3703
+ conditions.append(_EXCLUDE_SHARED_PATHS)
3704
+ conditions.append(_EXCLUDE_SYSTEM_ROWS)
3705
+ where = " AND ".join(conditions)
3706
+ params.extend([int(min_entries), int(limit)])
3707
+ with self._pool.connection() as conn:
3708
+ with conn.cursor() as cur:
3709
+ cur.execute(f"SET LOCAL work_mem = '{_AGGREGATE_WORK_MEM}'")
3710
+ cur.execute(
3711
+ f"""
3712
+ SELECT entity_path
3713
+ FROM amfs_memory_entries
3714
+ WHERE {where}
3715
+ GROUP BY entity_path
3716
+ HAVING COUNT(*) >= %s
3717
+ ORDER BY COUNT(*) DESC, entity_path
3718
+ LIMIT %s
3719
+ """,
3720
+ params,
3721
+ )
3722
+ return [r["entity_path"] for r in cur.fetchall()]
3723
+
3507
3724
  def agent_summaries(
3508
3725
  self,
3509
3726
  *,
@@ -3634,6 +3851,7 @@ class PostgresAdapter(AdapterABC):
3634
3851
  *,
3635
3852
  agent_ids: list[str] | None = None,
3636
3853
  scope: SqlScope | None = None,
3854
+ breakdown: bool = True,
3637
3855
  ) -> dict[str, Any]:
3638
3856
  """Extended aggregate stats in SQL (recalls, weekly deltas, types).
3639
3857
 
@@ -3641,6 +3859,16 @@ class PostgresAdapter(AdapterABC):
3641
3859
  to the entry aggregates only: the reuse-event query below is keyed by
3642
3860
  account and reader agent, and an entry-visibility predicate has no
3643
3861
  meaning over event rows.
3862
+
3863
+ ``breakdown=False`` leaves out the per-agent and per-entity count maps
3864
+ (``agents``, ``entities``) and takes the two distinct counts from one
3865
+ grouped subquery each instead. On a 6,000-agent, 4,000-entity account
3866
+ the maps were 600 KB of a response whose reader wanted two integers.
3867
+
3868
+ ``entity_confidence`` buckets the entities by their mean confidence
3869
+ (``healthy`` >= 0.9, ``moderate`` >= 0.7, ``low`` >= 0.4, ``critical``
3870
+ below) with the mean of those means as ``avg`` — the health page's
3871
+ donut, which used to need every entity summary to draw four counts.
3644
3872
  """
3645
3873
  conditions = ["namespace = %s", "superseded_at IS NULL"]
3646
3874
  params: list[Any] = [self._namespace]
@@ -3697,23 +3925,72 @@ class PostgresAdapter(AdapterABC):
3697
3925
  )
3698
3926
  row = cur.fetchone()
3699
3927
 
3700
- cur.execute(
3701
- f"""
3702
- SELECT agent_id, COUNT(*) AS cnt
3703
- FROM amfs_memory_entries WHERE {where} GROUP BY agent_id
3704
- """,
3705
- params,
3706
- )
3707
- agent_rows = cur.fetchall()
3928
+ agent_rows: list[Any] = []
3929
+ entity_rows: list[Any] = []
3930
+ # Entities bucketed by mean confidence; one pass over the
3931
+ # per-entity groups either way.
3932
+ bucket_sql = """
3933
+ COUNT(*) AS total_entities,
3934
+ COALESCE(AVG(c), 0) AS entity_confidence_avg,
3935
+ COUNT(*) FILTER (WHERE c >= 0.9) AS entities_healthy,
3936
+ COUNT(*) FILTER (WHERE c >= 0.7 AND c < 0.9) AS entities_moderate,
3937
+ COUNT(*) FILTER (WHERE c >= 0.4 AND c < 0.7) AS entities_low,
3938
+ COUNT(*) FILTER (WHERE c < 0.4) AS entities_critical
3939
+ """
3940
+ if breakdown:
3941
+ cur.execute(
3942
+ f"""
3943
+ SELECT agent_id, COUNT(*) AS cnt
3944
+ FROM amfs_memory_entries WHERE {where} GROUP BY agent_id
3945
+ """,
3946
+ params,
3947
+ )
3948
+ agent_rows = cur.fetchall()
3708
3949
 
3709
- cur.execute(
3710
- f"""
3711
- SELECT entity_path, COUNT(*) AS cnt
3712
- FROM amfs_memory_entries WHERE {where} GROUP BY entity_path
3713
- """,
3714
- params,
3715
- )
3716
- entity_rows = cur.fetchall()
3950
+ cur.execute(
3951
+ f"""
3952
+ SELECT entity_path, COUNT(*) AS cnt,
3953
+ COALESCE(AVG(confidence), 0) AS c
3954
+ FROM amfs_memory_entries WHERE {where} GROUP BY entity_path
3955
+ """,
3956
+ params,
3957
+ )
3958
+ entity_rows = cur.fetchall()
3959
+ total_agents = len(agent_rows)
3960
+ total_entities = len(entity_rows)
3961
+ # The same edges the pure-Python aggregate uses.
3962
+ buckets = entity_confidence_buckets(r["c"] for r in entity_rows)
3963
+ else:
3964
+ # Grouped subqueries rather than COUNT(DISTINCT ...): the
3965
+ # distinct aggregate forces a sort of every row and keeps
3966
+ # the statement off the parallel plan (see above).
3967
+ cur.execute(
3968
+ f"""
3969
+ SELECT
3970
+ (SELECT COUNT(*) FROM (
3971
+ SELECT 1 FROM amfs_memory_entries WHERE {where}
3972
+ GROUP BY agent_id) a) AS total_agents,
3973
+ e.*
3974
+ FROM (
3975
+ SELECT {bucket_sql}
3976
+ FROM (
3977
+ SELECT COALESCE(AVG(confidence), 0) AS c
3978
+ FROM amfs_memory_entries WHERE {where}
3979
+ GROUP BY entity_path) g
3980
+ ) e
3981
+ """,
3982
+ [*params, *params],
3983
+ )
3984
+ counts = cur.fetchone() or {}
3985
+ total_agents = int(counts.get("total_agents") or 0)
3986
+ total_entities = int(counts.get("total_entities") or 0)
3987
+ buckets = {
3988
+ "avg": float(counts.get("entity_confidence_avg") or 0),
3989
+ "healthy": int(counts.get("entities_healthy") or 0),
3990
+ "moderate": int(counts.get("entities_moderate") or 0),
3991
+ "low": int(counts.get("entities_low") or 0),
3992
+ "critical": int(counts.get("entities_critical") or 0),
3993
+ }
3717
3994
 
3718
3995
  cur.execute(
3719
3996
  f"""
@@ -3781,14 +4058,17 @@ class PostgresAdapter(AdapterABC):
3781
4058
  # and the dashboard hides the trend line.
3782
4059
  pass
3783
4060
 
3784
- return {
4061
+ out: dict[str, Any] = {
3785
4062
  "recalls_this_week": recalls_this_week,
3786
4063
  "recalls_last_week": recalls_last_week,
3787
4064
  "total_entries": row["total_entries"] if row else 0,
3788
- "total_entities": len(entity_rows),
3789
- "total_agents": len(agent_rows),
3790
- "agents": {r["agent_id"]: r["cnt"] for r in agent_rows},
3791
- "entities": {r["entity_path"]: r["cnt"] for r in entity_rows},
4065
+ "total_entities": total_entities,
4066
+ "total_agents": total_agents,
4067
+ }
4068
+ if breakdown:
4069
+ out["agents"] = {r["agent_id"]: r["cnt"] for r in agent_rows}
4070
+ out["entities"] = {r["entity_path"]: r["cnt"] for r in entity_rows}
4071
+ out.update({
3792
4072
  "confidence_avg": float(row["confidence_avg"] or 0) if row else 0.0,
3793
4073
  "confidence_min": float(row["confidence_min"] or 0) if row else 0.0,
3794
4074
  "confidence_max": float(row["confidence_max"] or 0) if row else 0.0,
@@ -3802,7 +4082,9 @@ class PostgresAdapter(AdapterABC):
3802
4082
  "memory_type_counts": {
3803
4083
  (r["memory_type"] or "fact"): r["cnt"] for r in type_rows
3804
4084
  },
3805
- }
4085
+ "entity_confidence": buckets,
4086
+ })
4087
+ return out
3806
4088
 
3807
4089
  def share_stats(
3808
4090
  self,
@@ -5194,11 +5476,18 @@ class PostgresAdapter(AdapterABC):
5194
5476
  namespace: str = "default",
5195
5477
  branch: str = "main",
5196
5478
  *,
5479
+ scope: str | None = None,
5480
+ written_by: str | None = None,
5197
5481
  relevant_to: list[str] | None = None,
5198
5482
  limit: int | None = None,
5199
5483
  ) -> list[Digest]:
5200
- """List digests, optionally filtered by type and branch.
5484
+ """List digests, optionally filtered by type, branch and scope.
5201
5485
 
5486
+ ``scope`` keeps the digests compiled for exactly that scope — an
5487
+ agent's brief, one entity — in SQL, so a page asking about one agent
5488
+ does not receive every brief on the account (5 MB on a large one) to
5489
+ pick its own out of. ``written_by`` keeps the digests whose scope is
5490
+ an entity path that agent has live entries under.
5202
5491
  ``relevant_to`` keeps only digests that name one of the given strings
5203
5492
  — as their scope, or anywhere in their summary. The briefing scores a
5204
5493
  digest only when the entity or agent it is asked about appears in one
@@ -5213,6 +5502,21 @@ class PostgresAdapter(AdapterABC):
5213
5502
  if digest_type:
5214
5503
  where.append("digest_type = %s")
5215
5504
  params.append(digest_type.value)
5505
+ if scope:
5506
+ where.append("scope = %s")
5507
+ params.append(scope)
5508
+ if written_by:
5509
+ # The agent's entity paths, resolved in the database; RLS on the
5510
+ # entries table keeps the subquery to what the caller may see.
5511
+ # Same branch as the digests, and without benchmark or system
5512
+ # rows — the set ``entity_summaries`` would list for the agent,
5513
+ # so a digest shows up here only for a topic that page shows.
5514
+ where.append(
5515
+ "scope IN (SELECT DISTINCT entity_path FROM amfs_memory_entries"
5516
+ " WHERE namespace = %s AND branch = %s AND agent_id = %s"
5517
+ f" AND superseded_at IS NULL AND {_EXCLUDE_SYSTEM_ROWS})"
5518
+ )
5519
+ params.extend([namespace, branch, written_by])
5216
5520
  if account_id:
5217
5521
  where.append("account_id = %s")
5218
5522
  params.append(account_id)
@@ -5761,7 +6065,37 @@ class PostgresAdapter(AdapterABC):
5761
6065
  params,
5762
6066
  )
5763
6067
 
5764
- def list_agents_enriched(self, namespace: str = "default") -> list[dict[str, Any]]:
6068
+ #: ``sort`` values ``list_agents_enriched`` accepts, and the ORDER BY each is.
6069
+ _ENRICHED_AGENT_SORTS: dict[str, str] = {
6070
+ "last_active": "last_active DESC NULLS LAST, agent_id",
6071
+ "entries_written": "entries_written DESC, agent_id",
6072
+ "created_at": "created_at DESC NULLS LAST, agent_id",
6073
+ "agent_id": "agent_id",
6074
+ }
6075
+
6076
+ def list_agents_enriched(
6077
+ self,
6078
+ namespace: str = "default",
6079
+ *,
6080
+ agent_ids: list[str] | None = None,
6081
+ q: str | None = None,
6082
+ platform: str | None = None,
6083
+ sort: str = "last_active",
6084
+ limit: int | None = None,
6085
+ offset: int = 0,
6086
+ ) -> list[dict[str, Any]]:
6087
+ """Enriched agent rows, optionally one page of them.
6088
+
6089
+ *agent_ids* narrows to the agents the caller may see, so a page is a
6090
+ page of visible agents. *q* is a case-insensitive substring match on
6091
+ the agent id, description, model, platform and primary repo;
6092
+ *platform* keeps one platform. With *limit* set, every row carries
6093
+ ``total`` — the count before paging — and ``active_7d``, how many of
6094
+ those were active in the last seven days, so a directory can show
6095
+ "showing 50 of 6,356 · 412 active this week" without a second query.
6096
+ Unpaged, the whole roster is returned as before (3 MB on that account;
6097
+ the dashboard pages).
6098
+ """
5765
6099
  account_id = self._get_current_account_id()
5766
6100
  if account_id:
5767
6101
  account_filter = "AND a.account_id = %s"
@@ -5773,6 +6107,7 @@ class PostgresAdapter(AdapterABC):
5773
6107
  entry_account_filter = ""
5774
6108
  graph_account_filter = ""
5775
6109
  acct_params = []
6110
+ agent_ids_filter = "AND a.agent_id = ANY(%s)" if agent_ids is not None else ""
5776
6111
 
5777
6112
  sql = f"""
5778
6113
  WITH agent_entries AS (
@@ -5845,14 +6180,47 @@ class PostgresAdapter(AdapterABC):
5845
6180
  LEFT JOIN agent_collab c ON c.agent_id = a.agent_id
5846
6181
  LEFT JOIN amfs_agent_group_members gm
5847
6182
  ON gm.agent_id = a.agent_id AND gm.namespace = a.namespace
5848
- WHERE a.namespace = %s {account_filter}
5849
- ORDER BY last_active DESC NULLS LAST
6183
+ WHERE a.namespace = %s {account_filter} {agent_ids_filter}
5850
6184
  """
5851
6185
  params: list[Any] = [
5852
6186
  namespace, *acct_params,
5853
6187
  namespace, *acct_params,
5854
6188
  namespace, *acct_params,
5855
6189
  ]
6190
+ if agent_ids is not None:
6191
+ params.append(list(agent_ids))
6192
+ if sort not in self._ENRICHED_AGENT_SORTS:
6193
+ raise ValueError(f"unknown sort {sort!r}")
6194
+ order_by = self._ENRICHED_AGENT_SORTS[sort]
6195
+ # The roster as a subquery so the search sees the computed columns
6196
+ # and the page count is taken over the rows the search kept.
6197
+ outer: list[str] = []
6198
+ if q:
6199
+ pattern = "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
6200
+ outer.append(
6201
+ "(x.agent_id ILIKE %s OR x.description ILIKE %s OR x.model ILIKE %s"
6202
+ " OR x.platform ILIKE %s OR x.primary_repo ILIKE %s)"
6203
+ )
6204
+ params.extend([pattern] * 5)
6205
+ if platform:
6206
+ outer.append("x.platform = %s")
6207
+ params.append(platform)
6208
+ outer_where = ("WHERE " + " AND ".join(outer)) if outer else ""
6209
+ page = ""
6210
+ if limit is not None:
6211
+ page = "LIMIT %s OFFSET %s"
6212
+ params.extend([int(limit), max(0, int(offset))])
6213
+ sql = f"""
6214
+ SELECT x.*,
6215
+ COUNT(*) OVER () AS total,
6216
+ COUNT(*) FILTER (
6217
+ WHERE x.last_active >= NOW() - INTERVAL '7 days'
6218
+ ) OVER () AS active_7d
6219
+ FROM ({sql}) x
6220
+ {outer_where}
6221
+ ORDER BY {order_by}
6222
+ {page}
6223
+ """
5856
6224
 
5857
6225
  with self._pool.connection() as conn:
5858
6226
  with conn.cursor() as cur:
@@ -5865,6 +6233,11 @@ class PostgresAdapter(AdapterABC):
5865
6233
  if isinstance(tags, str):
5866
6234
  tags = json.loads(tags)
5867
6235
  results.append({
6236
+ **(
6237
+ {"total": int(r["total"]), "active_7d": int(r["active_7d"] or 0)}
6238
+ if limit is not None
6239
+ else {}
6240
+ ),
5868
6241
  "agent_id": r["agent_id"],
5869
6242
  "created_at": r["created_at"],
5870
6243
  "entries_written": r["entries_written"],