amfs-adapter-postgres 0.2.40__tar.gz → 0.2.41__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.41}/PKG-INFO +2 -2
  2. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.41}/pyproject.toml +2 -2
  3. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.41}/src/amfs_postgres/adapter.py +277 -27
  4. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.41}/.gitignore +0 -0
  5. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.41}/src/amfs_postgres/__init__.py +0 -0
  6. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.41}/src/amfs_postgres/_fts.py +0 -0
  7. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.41}/src/amfs_postgres/async_adapter.py +0 -0
  8. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.41}/src/amfs_postgres/evidence_sql.py +0 -0
  9. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.41}/src/amfs_postgres/knn.py +0 -0
  10. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.41}/src/amfs_postgres/migrations/001_initial.sql +0 -0
  11. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.41}/src/amfs_postgres/migrations/002_artifact_refs.sql +0 -0
  12. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.41}/src/amfs_postgres/migrations/002_search_indexes.sql +0 -0
  13. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.41}/src/amfs_postgres/migrations/003_knowledge_graph.sql +0 -0
  14. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.41}/src/amfs_postgres/migrations/004_agent_groups.sql +0 -0
  15. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.41}/src/amfs_postgres/migrations/005_configurable_embedding_dim.sql +0 -0
  16. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.41}/src/amfs_postgres/migrations/006_is_artifact.sql +0 -0
  17. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.41}/src/amfs_postgres/migrations/006_outcome_confidence_semantics.sql +0 -0
  18. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.41}/src/amfs_postgres/migrations/007_outcome_propagation_row_copy.sql +0 -0
  19. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.41}/src/amfs_postgres/migrations/008_outcome_evidence.sql +0 -0
  20. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.41}/src/amfs_postgres/migrations/009_action_priors.sql +0 -0
  21. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.41}/src/amfs_postgres/migrations/010_local_evidence.sql +0 -0
  22. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.41}/src/amfs_postgres/migrations/011_outcome_task_text.sql +0 -0
  23. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.41}/src/amfs_postgres/migrations/012_evidence_from_model.sql +0 -0
  24. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.41}/src/amfs_postgres/migrations/013_outcome_verified_by_backfill.sql +0 -0
  25. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.41}/src/amfs_postgres/retention.py +0 -0
  26. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.41}/src/amfs_postgres/schema.sql +0 -0
  27. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.41}/src/amfs_postgres/tenant_context.py +0 -0
  28. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.41}/src/amfs_postgres/tenant_gucs.py +0 -0
  29. {amfs_adapter_postgres-0.2.40 → amfs_adapter_postgres-0.2.41}/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.41
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.41"
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
  ]
@@ -477,6 +477,7 @@ from amfs_core.aggregates import ( # noqa: E402
477
477
  CHARS_PER_TOKEN as _CHARS_PER_TOKEN,
478
478
  RECALL_TOKENS_CEIL as _RECALL_TOKENS_CEIL,
479
479
  RECALL_TOKENS_FLOOR as _RECALL_TOKENS_FLOOR,
480
+ entity_confidence_buckets,
480
481
  )
481
482
 
482
483
  # Guarded by recall_count, because an entry never recalled contributes zero
@@ -3434,17 +3435,40 @@ class PostgresAdapter(AdapterABC):
3434
3435
  newest_entry_at=row["newest_entry_at"],
3435
3436
  )
3436
3437
 
3438
+ #: ``sort`` values ``entity_summaries`` accepts, and the ORDER BY each is.
3439
+ _ENTITY_SUMMARY_SORTS: dict[str, str] = {
3440
+ "last_updated": "last_updated DESC NULLS LAST, entity_path",
3441
+ "entry_count": "entry_count DESC, entity_path",
3442
+ "total_recalls": "total_recalls DESC, entity_path",
3443
+ "avg_confidence": "avg_confidence DESC NULLS LAST, entity_path",
3444
+ "avg_confidence_asc": "avg_confidence ASC NULLS LAST, entity_path",
3445
+ "entity_path": "entity_path",
3446
+ }
3447
+
3437
3448
  def entity_summaries(
3438
3449
  self,
3439
3450
  *,
3440
3451
  agent_ids: list[str] | None = None,
3441
3452
  scope: SqlScope | None = None,
3453
+ q: str | None = None,
3454
+ agent: str | None = None,
3455
+ min_confidence: float | None = None,
3456
+ sort: str = "last_updated",
3457
+ limit: int | None = None,
3458
+ offset: int = 0,
3442
3459
  ) -> list[dict[str, Any]]:
3443
3460
  """Per-entity aggregates via GROUP BY — never loads entry values.
3444
3461
 
3445
3462
  *scope* is a visibility predicate (room co-member rules and the like)
3446
3463
  that ``agent_ids`` alone cannot express; with it, a per-user dashboard
3447
3464
  gets this query instead of a Python pass over every entry.
3465
+
3466
+ *q* keeps the paths containing it (case-insensitive), *agent* the
3467
+ entities that agent has written to, *min_confidence* those whose mean
3468
+ confidence reaches it — the topics directory's filters, in SQL. With
3469
+ *limit* one page comes back and every row carries ``total``, the
3470
+ count before paging. Unpaged, the whole list is returned as before —
3471
+ 1.6 MB on a 4,000-entity account, so a directory should page.
3448
3472
  """
3449
3473
  conditions = ["namespace = %s", "branch = 'main'", "superseded_at IS NULL"]
3450
3474
  params: list[Any] = [self._namespace]
@@ -3453,6 +3477,24 @@ class PostgresAdapter(AdapterABC):
3453
3477
  params.append(list(agent_ids))
3454
3478
  if scope is not None:
3455
3479
  scope.apply(conditions, params)
3480
+ if q:
3481
+ pattern = "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
3482
+ conditions.append("entity_path ILIKE %s")
3483
+ params.append(pattern)
3484
+ having: list[str] = []
3485
+ having_params: list[Any] = []
3486
+ if agent:
3487
+ having.append("BOOL_OR(agent_id = %s)")
3488
+ having_params.append(agent)
3489
+ if min_confidence is not None:
3490
+ having.append("AVG(confidence) >= %s")
3491
+ having_params.append(float(min_confidence))
3492
+ if sort not in self._ENTITY_SUMMARY_SORTS:
3493
+ raise ValueError(f"unknown sort {sort!r}")
3494
+ order_by = self._ENTITY_SUMMARY_SORTS[sort]
3495
+ page = ""
3496
+ if limit is not None:
3497
+ page = "LIMIT %s OFFSET %s"
3456
3498
  # No entity_path argument to this one: it is unscoped by construction,
3457
3499
  # so shared namespaces are always excluded. It groups by entity_path,
3458
3500
  # which would otherwise list a shared namespace's topics as though
@@ -3478,8 +3520,21 @@ class PostgresAdapter(AdapterABC):
3478
3520
  FROM amfs_memory_entries
3479
3521
  WHERE {where}
3480
3522
  GROUP BY entity_path
3481
- ORDER BY MAX(written_at) DESC
3523
+ {("HAVING " + " AND ".join(having)) if having else ""}
3482
3524
  """
3525
+ params.extend(having_params)
3526
+ if limit is not None:
3527
+ # Paged: the count before paging rides on every row (one window
3528
+ # over the grouped rows, no second statement).
3529
+ params.extend([int(limit), max(0, int(offset))])
3530
+ sql = f"""
3531
+ SELECT x.*, COUNT(*) OVER () AS total
3532
+ FROM ({sql}) x
3533
+ ORDER BY {order_by}
3534
+ {page}
3535
+ """
3536
+ else:
3537
+ sql = f"SELECT * FROM ({sql}) x ORDER BY {order_by}"
3483
3538
  with self._pool.connection() as conn:
3484
3539
  with conn.cursor() as cur:
3485
3540
  # The checkout is one transaction (see _TenantRLSConnection),
@@ -3491,6 +3546,7 @@ class PostgresAdapter(AdapterABC):
3491
3546
 
3492
3547
  return [
3493
3548
  {
3549
+ **({"total": int(r["total"])} if limit is not None else {}),
3494
3550
  "entity_path": r["entity_path"],
3495
3551
  "entry_count": r["entry_count"],
3496
3552
  "avg_confidence": float(r["avg_confidence"] or 0),
@@ -3504,6 +3560,44 @@ class PostgresAdapter(AdapterABC):
3504
3560
  for r in rows
3505
3561
  ]
3506
3562
 
3563
+ def consolidation_ready_entities(
3564
+ self,
3565
+ *,
3566
+ min_entries: int = 10,
3567
+ limit: int = 10,
3568
+ scope: SqlScope | None = None,
3569
+ ) -> list[str]:
3570
+ """Entity paths with at least *min_entries* live entries, largest first.
3571
+
3572
+ What the consolidation status card shows. Counted in SQL: the handler
3573
+ used to load every entry on the account into Python to count them per
3574
+ path — two to three minutes on 430K entries, for ten path names.
3575
+ """
3576
+ conditions = ["namespace = %s", "branch = 'main'", "superseded_at IS NULL"]
3577
+ params: list[Any] = [self._namespace]
3578
+ if scope is not None:
3579
+ scope.apply(conditions, params)
3580
+ conditions.append(_EXCLUDE_SHARED_PATHS)
3581
+ conditions.append(_EXCLUDE_SYSTEM_ROWS)
3582
+ where = " AND ".join(conditions)
3583
+ params.extend([int(min_entries), int(limit)])
3584
+ with self._pool.connection() as conn:
3585
+ with conn.cursor() as cur:
3586
+ cur.execute(f"SET LOCAL work_mem = '{_AGGREGATE_WORK_MEM}'")
3587
+ cur.execute(
3588
+ f"""
3589
+ SELECT entity_path
3590
+ FROM amfs_memory_entries
3591
+ WHERE {where}
3592
+ GROUP BY entity_path
3593
+ HAVING COUNT(*) >= %s
3594
+ ORDER BY COUNT(*) DESC, entity_path
3595
+ LIMIT %s
3596
+ """,
3597
+ params,
3598
+ )
3599
+ return [r["entity_path"] for r in cur.fetchall()]
3600
+
3507
3601
  def agent_summaries(
3508
3602
  self,
3509
3603
  *,
@@ -3634,6 +3728,7 @@ class PostgresAdapter(AdapterABC):
3634
3728
  *,
3635
3729
  agent_ids: list[str] | None = None,
3636
3730
  scope: SqlScope | None = None,
3731
+ breakdown: bool = True,
3637
3732
  ) -> dict[str, Any]:
3638
3733
  """Extended aggregate stats in SQL (recalls, weekly deltas, types).
3639
3734
 
@@ -3641,6 +3736,16 @@ class PostgresAdapter(AdapterABC):
3641
3736
  to the entry aggregates only: the reuse-event query below is keyed by
3642
3737
  account and reader agent, and an entry-visibility predicate has no
3643
3738
  meaning over event rows.
3739
+
3740
+ ``breakdown=False`` leaves out the per-agent and per-entity count maps
3741
+ (``agents``, ``entities``) and takes the two distinct counts from one
3742
+ grouped subquery each instead. On a 6,000-agent, 4,000-entity account
3743
+ the maps were 600 KB of a response whose reader wanted two integers.
3744
+
3745
+ ``entity_confidence`` buckets the entities by their mean confidence
3746
+ (``healthy`` >= 0.9, ``moderate`` >= 0.7, ``low`` >= 0.4, ``critical``
3747
+ below) with the mean of those means as ``avg`` — the health page's
3748
+ donut, which used to need every entity summary to draw four counts.
3644
3749
  """
3645
3750
  conditions = ["namespace = %s", "superseded_at IS NULL"]
3646
3751
  params: list[Any] = [self._namespace]
@@ -3697,23 +3802,72 @@ class PostgresAdapter(AdapterABC):
3697
3802
  )
3698
3803
  row = cur.fetchone()
3699
3804
 
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()
3805
+ agent_rows: list[Any] = []
3806
+ entity_rows: list[Any] = []
3807
+ # Entities bucketed by mean confidence; one pass over the
3808
+ # per-entity groups either way.
3809
+ bucket_sql = """
3810
+ COUNT(*) AS total_entities,
3811
+ COALESCE(AVG(c), 0) AS entity_confidence_avg,
3812
+ COUNT(*) FILTER (WHERE c >= 0.9) AS entities_healthy,
3813
+ COUNT(*) FILTER (WHERE c >= 0.7 AND c < 0.9) AS entities_moderate,
3814
+ COUNT(*) FILTER (WHERE c >= 0.4 AND c < 0.7) AS entities_low,
3815
+ COUNT(*) FILTER (WHERE c < 0.4) AS entities_critical
3816
+ """
3817
+ if breakdown:
3818
+ cur.execute(
3819
+ f"""
3820
+ SELECT agent_id, COUNT(*) AS cnt
3821
+ FROM amfs_memory_entries WHERE {where} GROUP BY agent_id
3822
+ """,
3823
+ params,
3824
+ )
3825
+ agent_rows = cur.fetchall()
3708
3826
 
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()
3827
+ cur.execute(
3828
+ f"""
3829
+ SELECT entity_path, COUNT(*) AS cnt,
3830
+ COALESCE(AVG(confidence), 0) AS c
3831
+ FROM amfs_memory_entries WHERE {where} GROUP BY entity_path
3832
+ """,
3833
+ params,
3834
+ )
3835
+ entity_rows = cur.fetchall()
3836
+ total_agents = len(agent_rows)
3837
+ total_entities = len(entity_rows)
3838
+ # The same edges the pure-Python aggregate uses.
3839
+ buckets = entity_confidence_buckets(r["c"] for r in entity_rows)
3840
+ else:
3841
+ # Grouped subqueries rather than COUNT(DISTINCT ...): the
3842
+ # distinct aggregate forces a sort of every row and keeps
3843
+ # the statement off the parallel plan (see above).
3844
+ cur.execute(
3845
+ f"""
3846
+ SELECT
3847
+ (SELECT COUNT(*) FROM (
3848
+ SELECT 1 FROM amfs_memory_entries WHERE {where}
3849
+ GROUP BY agent_id) a) AS total_agents,
3850
+ e.*
3851
+ FROM (
3852
+ SELECT {bucket_sql}
3853
+ FROM (
3854
+ SELECT COALESCE(AVG(confidence), 0) AS c
3855
+ FROM amfs_memory_entries WHERE {where}
3856
+ GROUP BY entity_path) g
3857
+ ) e
3858
+ """,
3859
+ [*params, *params],
3860
+ )
3861
+ counts = cur.fetchone() or {}
3862
+ total_agents = int(counts.get("total_agents") or 0)
3863
+ total_entities = int(counts.get("total_entities") or 0)
3864
+ buckets = {
3865
+ "avg": float(counts.get("entity_confidence_avg") or 0),
3866
+ "healthy": int(counts.get("entities_healthy") or 0),
3867
+ "moderate": int(counts.get("entities_moderate") or 0),
3868
+ "low": int(counts.get("entities_low") or 0),
3869
+ "critical": int(counts.get("entities_critical") or 0),
3870
+ }
3717
3871
 
3718
3872
  cur.execute(
3719
3873
  f"""
@@ -3781,14 +3935,17 @@ class PostgresAdapter(AdapterABC):
3781
3935
  # and the dashboard hides the trend line.
3782
3936
  pass
3783
3937
 
3784
- return {
3938
+ out: dict[str, Any] = {
3785
3939
  "recalls_this_week": recalls_this_week,
3786
3940
  "recalls_last_week": recalls_last_week,
3787
3941
  "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},
3942
+ "total_entities": total_entities,
3943
+ "total_agents": total_agents,
3944
+ }
3945
+ if breakdown:
3946
+ out["agents"] = {r["agent_id"]: r["cnt"] for r in agent_rows}
3947
+ out["entities"] = {r["entity_path"]: r["cnt"] for r in entity_rows}
3948
+ out.update({
3792
3949
  "confidence_avg": float(row["confidence_avg"] or 0) if row else 0.0,
3793
3950
  "confidence_min": float(row["confidence_min"] or 0) if row else 0.0,
3794
3951
  "confidence_max": float(row["confidence_max"] or 0) if row else 0.0,
@@ -3802,7 +3959,9 @@ class PostgresAdapter(AdapterABC):
3802
3959
  "memory_type_counts": {
3803
3960
  (r["memory_type"] or "fact"): r["cnt"] for r in type_rows
3804
3961
  },
3805
- }
3962
+ "entity_confidence": buckets,
3963
+ })
3964
+ return out
3806
3965
 
3807
3966
  def share_stats(
3808
3967
  self,
@@ -5194,11 +5353,18 @@ class PostgresAdapter(AdapterABC):
5194
5353
  namespace: str = "default",
5195
5354
  branch: str = "main",
5196
5355
  *,
5356
+ scope: str | None = None,
5357
+ written_by: str | None = None,
5197
5358
  relevant_to: list[str] | None = None,
5198
5359
  limit: int | None = None,
5199
5360
  ) -> list[Digest]:
5200
- """List digests, optionally filtered by type and branch.
5361
+ """List digests, optionally filtered by type, branch and scope.
5201
5362
 
5363
+ ``scope`` keeps the digests compiled for exactly that scope — an
5364
+ agent's brief, one entity — in SQL, so a page asking about one agent
5365
+ does not receive every brief on the account (5 MB on a large one) to
5366
+ pick its own out of. ``written_by`` keeps the digests whose scope is
5367
+ an entity path that agent has live entries under.
5202
5368
  ``relevant_to`` keeps only digests that name one of the given strings
5203
5369
  — as their scope, or anywhere in their summary. The briefing scores a
5204
5370
  digest only when the entity or agent it is asked about appears in one
@@ -5213,6 +5379,21 @@ class PostgresAdapter(AdapterABC):
5213
5379
  if digest_type:
5214
5380
  where.append("digest_type = %s")
5215
5381
  params.append(digest_type.value)
5382
+ if scope:
5383
+ where.append("scope = %s")
5384
+ params.append(scope)
5385
+ if written_by:
5386
+ # The agent's entity paths, resolved in the database; RLS on the
5387
+ # entries table keeps the subquery to what the caller may see.
5388
+ # Same branch as the digests, and without benchmark or system
5389
+ # rows — the set ``entity_summaries`` would list for the agent,
5390
+ # so a digest shows up here only for a topic that page shows.
5391
+ where.append(
5392
+ "scope IN (SELECT DISTINCT entity_path FROM amfs_memory_entries"
5393
+ " WHERE namespace = %s AND branch = %s AND agent_id = %s"
5394
+ f" AND superseded_at IS NULL AND {_EXCLUDE_SYSTEM_ROWS})"
5395
+ )
5396
+ params.extend([namespace, branch, written_by])
5216
5397
  if account_id:
5217
5398
  where.append("account_id = %s")
5218
5399
  params.append(account_id)
@@ -5761,7 +5942,37 @@ class PostgresAdapter(AdapterABC):
5761
5942
  params,
5762
5943
  )
5763
5944
 
5764
- def list_agents_enriched(self, namespace: str = "default") -> list[dict[str, Any]]:
5945
+ #: ``sort`` values ``list_agents_enriched`` accepts, and the ORDER BY each is.
5946
+ _ENRICHED_AGENT_SORTS: dict[str, str] = {
5947
+ "last_active": "last_active DESC NULLS LAST, agent_id",
5948
+ "entries_written": "entries_written DESC, agent_id",
5949
+ "created_at": "created_at DESC NULLS LAST, agent_id",
5950
+ "agent_id": "agent_id",
5951
+ }
5952
+
5953
+ def list_agents_enriched(
5954
+ self,
5955
+ namespace: str = "default",
5956
+ *,
5957
+ agent_ids: list[str] | None = None,
5958
+ q: str | None = None,
5959
+ platform: str | None = None,
5960
+ sort: str = "last_active",
5961
+ limit: int | None = None,
5962
+ offset: int = 0,
5963
+ ) -> list[dict[str, Any]]:
5964
+ """Enriched agent rows, optionally one page of them.
5965
+
5966
+ *agent_ids* narrows to the agents the caller may see, so a page is a
5967
+ page of visible agents. *q* is a case-insensitive substring match on
5968
+ the agent id, description, model, platform and primary repo;
5969
+ *platform* keeps one platform. With *limit* set, every row carries
5970
+ ``total`` — the count before paging — and ``active_7d``, how many of
5971
+ those were active in the last seven days, so a directory can show
5972
+ "showing 50 of 6,356 · 412 active this week" without a second query.
5973
+ Unpaged, the whole roster is returned as before (3 MB on that account;
5974
+ the dashboard pages).
5975
+ """
5765
5976
  account_id = self._get_current_account_id()
5766
5977
  if account_id:
5767
5978
  account_filter = "AND a.account_id = %s"
@@ -5773,6 +5984,7 @@ class PostgresAdapter(AdapterABC):
5773
5984
  entry_account_filter = ""
5774
5985
  graph_account_filter = ""
5775
5986
  acct_params = []
5987
+ agent_ids_filter = "AND a.agent_id = ANY(%s)" if agent_ids is not None else ""
5776
5988
 
5777
5989
  sql = f"""
5778
5990
  WITH agent_entries AS (
@@ -5845,14 +6057,47 @@ class PostgresAdapter(AdapterABC):
5845
6057
  LEFT JOIN agent_collab c ON c.agent_id = a.agent_id
5846
6058
  LEFT JOIN amfs_agent_group_members gm
5847
6059
  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
6060
+ WHERE a.namespace = %s {account_filter} {agent_ids_filter}
5850
6061
  """
5851
6062
  params: list[Any] = [
5852
6063
  namespace, *acct_params,
5853
6064
  namespace, *acct_params,
5854
6065
  namespace, *acct_params,
5855
6066
  ]
6067
+ if agent_ids is not None:
6068
+ params.append(list(agent_ids))
6069
+ if sort not in self._ENRICHED_AGENT_SORTS:
6070
+ raise ValueError(f"unknown sort {sort!r}")
6071
+ order_by = self._ENRICHED_AGENT_SORTS[sort]
6072
+ # The roster as a subquery so the search sees the computed columns
6073
+ # and the page count is taken over the rows the search kept.
6074
+ outer: list[str] = []
6075
+ if q:
6076
+ pattern = "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
6077
+ outer.append(
6078
+ "(x.agent_id ILIKE %s OR x.description ILIKE %s OR x.model ILIKE %s"
6079
+ " OR x.platform ILIKE %s OR x.primary_repo ILIKE %s)"
6080
+ )
6081
+ params.extend([pattern] * 5)
6082
+ if platform:
6083
+ outer.append("x.platform = %s")
6084
+ params.append(platform)
6085
+ outer_where = ("WHERE " + " AND ".join(outer)) if outer else ""
6086
+ page = ""
6087
+ if limit is not None:
6088
+ page = "LIMIT %s OFFSET %s"
6089
+ params.extend([int(limit), max(0, int(offset))])
6090
+ sql = f"""
6091
+ SELECT x.*,
6092
+ COUNT(*) OVER () AS total,
6093
+ COUNT(*) FILTER (
6094
+ WHERE x.last_active >= NOW() - INTERVAL '7 days'
6095
+ ) OVER () AS active_7d
6096
+ FROM ({sql}) x
6097
+ {outer_where}
6098
+ ORDER BY {order_by}
6099
+ {page}
6100
+ """
5856
6101
 
5857
6102
  with self._pool.connection() as conn:
5858
6103
  with conn.cursor() as cur:
@@ -5865,6 +6110,11 @@ class PostgresAdapter(AdapterABC):
5865
6110
  if isinstance(tags, str):
5866
6111
  tags = json.loads(tags)
5867
6112
  results.append({
6113
+ **(
6114
+ {"total": int(r["total"]), "active_7d": int(r["active_7d"] or 0)}
6115
+ if limit is not None
6116
+ else {}
6117
+ ),
5868
6118
  "agent_id": r["agent_id"],
5869
6119
  "created_at": r["created_at"],
5870
6120
  "entries_written": r["entries_written"],