astrocyte-postgres 0.13.1__tar.gz → 0.14.2__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 (63) hide show
  1. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/.gitignore +2 -0
  2. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/PKG-INFO +14 -4
  3. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/README.md +13 -3
  4. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/astrocyte_postgres/__init__.py +6 -3
  5. astrocyte_postgres-0.14.2/astrocyte_postgres/conversation_store.py +269 -0
  6. astrocyte_postgres-0.14.2/astrocyte_postgres/document_store.py +368 -0
  7. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/astrocyte_postgres/mental_model_store.py +20 -4
  8. astrocyte_postgres-0.14.2/astrocyte_postgres/pageindex_store.py +1554 -0
  9. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/astrocyte_postgres/source_store.py +3 -5
  10. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/astrocyte_postgres/store.py +74 -28
  11. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/astrocyte_postgres/wiki_store.py +1 -0
  12. astrocyte_postgres-0.14.2/migrations/001_extension.sql +14 -0
  13. astrocyte_postgres-0.14.2/migrations/003_indexes.sql +31 -0
  14. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/migrations/009_entities_trigram_embedding.sql +10 -5
  15. astrocyte_postgres-0.14.2/migrations/015_tier2_recall.sql +156 -0
  16. astrocyte_postgres-0.14.2/migrations/016_drop_age.sql +76 -0
  17. astrocyte_postgres-0.14.2/migrations/017_pi_section_embedding.sql +28 -0
  18. astrocyte_postgres-0.14.2/migrations/018_wiki_page_embedding.sql +23 -0
  19. astrocyte_postgres-0.14.2/migrations/019_pi_section_event_date.sql +24 -0
  20. astrocyte_postgres-0.14.2/migrations/020_pi_facts.sql +54 -0
  21. astrocyte_postgres-0.14.2/migrations/021_pi_fact_assistant_statement.sql +25 -0
  22. astrocyte_postgres-0.14.2/migrations/022_mental_model_kind.sql +30 -0
  23. astrocyte_postgres-0.14.2/migrations/023_astrocyte_audit_log.sql +38 -0
  24. astrocyte_postgres-0.14.2/migrations/024_astrocyte_bank_disposition.sql +39 -0
  25. astrocyte_postgres-0.14.2/migrations/025_astrocyte_documents.sql +32 -0
  26. astrocyte_postgres-0.14.2/migrations/026_astrocyte_document_nodes.sql +49 -0
  27. astrocyte_postgres-0.14.2/migrations/027_astrocyte_conversations.sql +26 -0
  28. astrocyte_postgres-0.14.2/migrations/028_astrocyte_conversation_turns.sql +36 -0
  29. astrocyte_postgres-0.14.2/migrations/029_pi_facts_nullable_document_id.sql +41 -0
  30. astrocyte_postgres-0.14.2/migrations/030_document_node_page_range.sql +13 -0
  31. astrocyte_postgres-0.14.2/migrations/031_astrocyte_conversation_documents.sql +38 -0
  32. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/pyproject.toml +3 -0
  33. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/scripts/migrate.sh +18 -1
  34. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/tests/test_postgres_bm25_idf.py +37 -27
  35. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/tests/test_postgres_fts.py +56 -36
  36. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/tests/test_postgres_mental_model_store.py +4 -2
  37. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/tests/test_postgres_mental_model_tenant_isolation.py +2 -6
  38. astrocyte_postgres-0.14.2/tests/test_postgres_pageindex_store.py +277 -0
  39. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/tests/test_postgres_source_aware_retain.py +36 -37
  40. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/tests/test_postgres_source_store.py +28 -22
  41. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/tests/test_postgres_source_tenant_isolation.py +13 -14
  42. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/tests/test_postgres_store.py +68 -48
  43. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/uv.lock +38 -6
  44. astrocyte_postgres-0.13.1/migrations/001_extension.sql +0 -2
  45. astrocyte_postgres-0.13.1/migrations/003_indexes.sql +0 -11
  46. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/migrations/002_astrocytes_vectors.sql +0 -0
  47. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/migrations/004_memory_layer.sql +0 -0
  48. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/migrations/005_banks_access.sql +0 -0
  49. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/migrations/006_lifecycle_indexes.sql +0 -0
  50. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/migrations/007_wiki_tables.sql +0 -0
  51. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/migrations/008_entities_temporal.sql +0 -0
  52. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/migrations/010_hybrid_recall_indexes.sql +0 -0
  53. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/migrations/011_text_fts.sql +0 -0
  54. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/migrations/012_mental_models.sql +0 -0
  55. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/migrations/013_bm25_materialized_view.sql +0 -0
  56. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/migrations/014_source_documents.sql +0 -0
  57. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/scripts/migrate-all-tenants.sh +0 -0
  58. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/scripts/migrate-wiki-piggyback-to-mental-models.sh +0 -0
  59. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/scripts/test-integration.sh +0 -0
  60. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/tests/__init__.py +0 -0
  61. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/tests/conftest.py +0 -0
  62. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/tests/test_postgres_tenant_isolation.py +0 -0
  63. {astrocyte_postgres-0.13.1 → astrocyte_postgres-0.14.2}/tests/test_postgres_wiki_store.py +0 -0
@@ -52,3 +52,5 @@ target/
52
52
 
53
53
  # IDE (optional)
54
54
  .idea/
55
+ astrocyte-py/logs/
56
+ astrocyte-py/logs/ # if not already there
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: astrocyte-postgres
3
- Version: 0.13.1
3
+ Version: 0.14.2
4
4
  Summary: PostgreSQL adapter for Astrocyte (vector + document + wiki stores backed by pgvector and tsvector)
5
5
  License-Expression: Apache-2.0
6
6
  Requires-Python: >=3.11
@@ -16,7 +16,7 @@ Description-Content-Type: text/markdown
16
16
 
17
17
  # astrocyte-postgres
18
18
 
19
- **PostgreSQL + [pgvector](https://github.com/pgvector/pgvector)** implementation of the Astrocyte **`VectorStore`** and **`WikiStore`** SPIs ([`provider-spi.md`](../../docs/_plugins/provider-spi.md)).
19
+ **PostgreSQL** implementation of the Astrocyte **`VectorStore`** and **`WikiStore`** SPIs ([`provider-spi.md`](../../docs/_plugins/provider-spi.md)). Uses **[pgvectorscale](https://github.com/timescale/pgvectorscale)** (DiskANN) for ANN indexing — pgvectorscale CASCADE-installs pgvector, so pgvector's `vector` type and `<=>` operator are available too. The shipped Docker image bakes pgvectorscale in; for self-host, use that image, Supabase, Neon, or Timescale Cloud.
20
20
 
21
21
  ## Install
22
22
 
@@ -72,6 +72,16 @@ After migrations are applied, set **`bootstrap_schema: false`** in `vector_store
72
72
 
73
73
  **Embedding width:** [`migrations/002_astrocyte_vectors.sql`](migrations/002_astrocyte_vectors.sql) creates `vector(${ASTROCYTE_EMBEDDING_DIMENSIONS:-128})`. That must match **`embedding_dimensions`** in config. For OpenAI `text-embedding-3-small`, run migrations with `ASTROCYTE_EMBEDDING_DIMENSIONS=1536`.
74
74
 
75
+ **ANN backend (DiskANN via pgvectorscale):** vector indexes are created with `USING diskann (embedding vector_cosine_ops) WITH (num_neighbors = 50)`. pgvectorscale CASCADE-installs pgvector, so application code using pgvector's `<=>` operator works unchanged. Astrocyte standardized on pgvectorscale in 2026-05 — the LongMemEval bench surfaced HNSW per-page write-lock drift (~1.0s → 2.0s/session as the index grew under concurrent retain), and DiskANN's graph layout serializes less aggressively. pgvectorscale is OSS under the PostgreSQL License with no feature gates.
76
+
77
+ ```bash
78
+ ./scripts/migrate.sh
79
+ ```
80
+
81
+ **Supported deployment targets:** the shipped `ghcr.io/astrocyteai/astrocyte-postgres` image (bakes pgvectorscale in), Supabase, Neon, Timescale Cloud, or any Postgres with `postgresql-16-pgvectorscale` from Timescale's apt repo. AWS RDS, GCP Cloud SQL, and Azure Database for PostgreSQL do **not** ship pgvectorscale and are not supported targets — run the shipped image on ECS / GCE / a VM instead.
82
+
83
+ The DiskANN index DDL is in [`migrations/003_indexes.sql`](migrations/003_indexes.sql) and [`migrations/009_entities_trigram_embedding.sql`](migrations/009_entities_trigram_embedding.sql); the `CREATE EXTENSION vectorscale CASCADE` lives in [`migrations/001_extension.sql`](migrations/001_extension.sql).
84
+
75
85
  **Custom `table_name`:** The shipped SQL targets **`astrocyte_vectors`**. If you use another table name, copy and adjust the migration files accordingly.
76
86
 
77
87
  The later migrations add the Hindsight-comparable Postgres substrate around vectors: bank metadata and access grants, lifecycle columns (`retained_at`, `forgotten_at`), durable wiki pages/revisions/provenance, canonical entity/link tables, and normalized temporal facts.
@@ -83,7 +93,7 @@ The later migrations add the Hindsight-comparable Postgres substrate around vect
83
93
  | `dsn` | PostgreSQL connection URI (or set `DATABASE_URL` / `ASTROCYTE_PG_DSN`) |
84
94
  | `table_name` | Table name (default `astrocyte_vectors`; alphanumeric + underscore only) |
85
95
  | `embedding_dimensions` | Fixed `vector(N)` width; must match your embedding model and the **`vector(N)`** in SQL migrations (default **128**) |
86
- | `bootstrap_schema` | If **`true`** (default), create extension / table / btree index on first use (dev-friendly; no HNSW). If **`false`**, assume **`migrate.sh`** already applied [`migrations/`](migrations/) (production). |
96
+ | `bootstrap_schema` | If **`true`** (default), create extension / table / btree index on first use (dev-friendly; no DiskANN). If **`false`**, assume **`migrate.sh`** already applied [`migrations/`](migrations/) (production). |
87
97
 
88
98
  ## How this fits `astrocyte_gateway`
89
99
 
@@ -128,6 +138,6 @@ cd astrocyte-services-py/astrocyte-gateway-py && uv sync --extra postgres
128
138
 
129
139
  ## Production notes
130
140
 
131
- - **HNSW** parameters (`m`, `ef_construction`) live in [`migrations/003_indexes.sql`](migrations/003_indexes.sql); tune with DBA guidance as load grows.
141
+ - **DiskANN** parameters (`num_neighbors`) live in [`migrations/003_indexes.sql`](migrations/003_indexes.sql); tune with DBA guidance as load grows.
132
142
  - **Embedding dimension** must match the **`LLMProvider.embed()`** output used by the pipeline.
133
143
  - Use **secrets** for `dsn`, not committed YAML.
@@ -1,6 +1,6 @@
1
1
  # astrocyte-postgres
2
2
 
3
- **PostgreSQL + [pgvector](https://github.com/pgvector/pgvector)** implementation of the Astrocyte **`VectorStore`** and **`WikiStore`** SPIs ([`provider-spi.md`](../../docs/_plugins/provider-spi.md)).
3
+ **PostgreSQL** implementation of the Astrocyte **`VectorStore`** and **`WikiStore`** SPIs ([`provider-spi.md`](../../docs/_plugins/provider-spi.md)). Uses **[pgvectorscale](https://github.com/timescale/pgvectorscale)** (DiskANN) for ANN indexing — pgvectorscale CASCADE-installs pgvector, so pgvector's `vector` type and `<=>` operator are available too. The shipped Docker image bakes pgvectorscale in; for self-host, use that image, Supabase, Neon, or Timescale Cloud.
4
4
 
5
5
  ## Install
6
6
 
@@ -56,6 +56,16 @@ After migrations are applied, set **`bootstrap_schema: false`** in `vector_store
56
56
 
57
57
  **Embedding width:** [`migrations/002_astrocyte_vectors.sql`](migrations/002_astrocyte_vectors.sql) creates `vector(${ASTROCYTE_EMBEDDING_DIMENSIONS:-128})`. That must match **`embedding_dimensions`** in config. For OpenAI `text-embedding-3-small`, run migrations with `ASTROCYTE_EMBEDDING_DIMENSIONS=1536`.
58
58
 
59
+ **ANN backend (DiskANN via pgvectorscale):** vector indexes are created with `USING diskann (embedding vector_cosine_ops) WITH (num_neighbors = 50)`. pgvectorscale CASCADE-installs pgvector, so application code using pgvector's `<=>` operator works unchanged. Astrocyte standardized on pgvectorscale in 2026-05 — the LongMemEval bench surfaced HNSW per-page write-lock drift (~1.0s → 2.0s/session as the index grew under concurrent retain), and DiskANN's graph layout serializes less aggressively. pgvectorscale is OSS under the PostgreSQL License with no feature gates.
60
+
61
+ ```bash
62
+ ./scripts/migrate.sh
63
+ ```
64
+
65
+ **Supported deployment targets:** the shipped `ghcr.io/astrocyteai/astrocyte-postgres` image (bakes pgvectorscale in), Supabase, Neon, Timescale Cloud, or any Postgres with `postgresql-16-pgvectorscale` from Timescale's apt repo. AWS RDS, GCP Cloud SQL, and Azure Database for PostgreSQL do **not** ship pgvectorscale and are not supported targets — run the shipped image on ECS / GCE / a VM instead.
66
+
67
+ The DiskANN index DDL is in [`migrations/003_indexes.sql`](migrations/003_indexes.sql) and [`migrations/009_entities_trigram_embedding.sql`](migrations/009_entities_trigram_embedding.sql); the `CREATE EXTENSION vectorscale CASCADE` lives in [`migrations/001_extension.sql`](migrations/001_extension.sql).
68
+
59
69
  **Custom `table_name`:** The shipped SQL targets **`astrocyte_vectors`**. If you use another table name, copy and adjust the migration files accordingly.
60
70
 
61
71
  The later migrations add the Hindsight-comparable Postgres substrate around vectors: bank metadata and access grants, lifecycle columns (`retained_at`, `forgotten_at`), durable wiki pages/revisions/provenance, canonical entity/link tables, and normalized temporal facts.
@@ -67,7 +77,7 @@ The later migrations add the Hindsight-comparable Postgres substrate around vect
67
77
  | `dsn` | PostgreSQL connection URI (or set `DATABASE_URL` / `ASTROCYTE_PG_DSN`) |
68
78
  | `table_name` | Table name (default `astrocyte_vectors`; alphanumeric + underscore only) |
69
79
  | `embedding_dimensions` | Fixed `vector(N)` width; must match your embedding model and the **`vector(N)`** in SQL migrations (default **128**) |
70
- | `bootstrap_schema` | If **`true`** (default), create extension / table / btree index on first use (dev-friendly; no HNSW). If **`false`**, assume **`migrate.sh`** already applied [`migrations/`](migrations/) (production). |
80
+ | `bootstrap_schema` | If **`true`** (default), create extension / table / btree index on first use (dev-friendly; no DiskANN). If **`false`**, assume **`migrate.sh`** already applied [`migrations/`](migrations/) (production). |
71
81
 
72
82
  ## How this fits `astrocyte_gateway`
73
83
 
@@ -112,6 +122,6 @@ cd astrocyte-services-py/astrocyte-gateway-py && uv sync --extra postgres
112
122
 
113
123
  ## Production notes
114
124
 
115
- - **HNSW** parameters (`m`, `ef_construction`) live in [`migrations/003_indexes.sql`](migrations/003_indexes.sql); tune with DBA guidance as load grows.
125
+ - **DiskANN** parameters (`num_neighbors`) live in [`migrations/003_indexes.sql`](migrations/003_indexes.sql); tune with DBA guidance as load grows.
116
126
  - **Embedding dimension** must match the **`LLMProvider.embed()`** output used by the pipeline.
117
127
  - Use **secrets** for `dsn`, not committed YAML.
@@ -1,17 +1,20 @@
1
- """PostgreSQL adapter for Astrocyte Tier 1.
1
+ """PostgreSQL adapter for Astrocyte storage and section recall.
2
2
 
3
3
  Backed by pgvector (HNSW vector index) and tsvector (BM25 keyword search).
4
- Provides VectorStore, DocumentStore, WikiStore, MentalModelStore, and
5
- SourceStore implementations against a single Postgres database.
4
+ Provides VectorStore, DocumentStore, WikiStore, MentalModelStore,
5
+ SourceStore, and (M9) PageIndexStore implementations against a single
6
+ Postgres database.
6
7
  """
7
8
 
8
9
  from astrocyte_postgres.mental_model_store import PostgresMentalModelStore
10
+ from astrocyte_postgres.pageindex_store import PostgresPageIndexStore
9
11
  from astrocyte_postgres.source_store import PostgresSourceStore
10
12
  from astrocyte_postgres.store import PostgresStore
11
13
  from astrocyte_postgres.wiki_store import PostgresWikiStore
12
14
 
13
15
  __all__ = [
14
16
  "PostgresMentalModelStore",
17
+ "PostgresPageIndexStore",
15
18
  "PostgresSourceStore",
16
19
  "PostgresStore",
17
20
  "PostgresWikiStore",
@@ -0,0 +1,269 @@
1
+ """PostgresConversationStore — Postgres impl of ConversationStore.
2
+
3
+ Mirrors PostgresDocumentStore's pattern: psycopg async pool, optional
4
+ schema bootstrap, narrow upsert / read / delete surface. Schema lives
5
+ in migrations 027 (conversations) + 028 (conversation_turns).
6
+
7
+ Composability invariant: this store does NOT reference any Memory
8
+ Engine table. Conversation→Memory references go through opaque
9
+ metadata strings, not typed FKs.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import json
16
+ import logging
17
+ import os
18
+ from typing import Any
19
+
20
+ import psycopg
21
+ from astrocyte.conversations.storage import ConversationStore
22
+ from astrocyte.conversations.types import Conversation, ConversationTurn
23
+ from psycopg_pool import AsyncConnectionPool
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ class PostgresConversationStore(ConversationStore):
29
+ """Durable ConversationStore on Postgres (migrations 027 + 028)."""
30
+
31
+ def __init__(
32
+ self,
33
+ dsn: str | None = None,
34
+ *,
35
+ bootstrap_schema: bool = True,
36
+ **kwargs: Any,
37
+ ) -> None:
38
+ self._dsn = dsn or os.environ.get("DATABASE_URL") or os.environ.get("ASTROCYTE_PG_DSN")
39
+ if not self._dsn:
40
+ raise ValueError(
41
+ "PostgresConversationStore requires `dsn` or DATABASE_URL / ASTROCYTE_PG_DSN",
42
+ )
43
+ self._bootstrap_schema = bool(bootstrap_schema)
44
+ self._pool: AsyncConnectionPool | None = None
45
+ self._pool_lock = asyncio.Lock()
46
+ self._schema_ready = False
47
+ self._schema_lock = asyncio.Lock()
48
+
49
+ # ── lifecycle ─────────────────────────────────────────────────────
50
+
51
+ async def _ensure_pool(self) -> AsyncConnectionPool:
52
+ async with self._pool_lock:
53
+ if self._pool is None:
54
+
55
+ async def configure(conn: psycopg.AsyncConnection) -> None:
56
+ await conn.execute('SET search_path = public, "$user"')
57
+ await conn.commit()
58
+
59
+ self._pool = AsyncConnectionPool(
60
+ conninfo=self._dsn,
61
+ configure=configure,
62
+ open=False,
63
+ min_size=1,
64
+ max_size=10,
65
+ kwargs={"connect_timeout": 10},
66
+ )
67
+ await self._pool.open()
68
+ return self._pool
69
+
70
+ async def close(self) -> None:
71
+ async with self._pool_lock:
72
+ if self._pool is not None:
73
+ await self._pool.close()
74
+ self._pool = None
75
+
76
+ async def _ensure_schema(self, pool: AsyncConnectionPool) -> None:
77
+ if not self._bootstrap_schema or self._schema_ready:
78
+ return
79
+ async with self._schema_lock:
80
+ if self._schema_ready:
81
+ return
82
+ async with pool.connection() as conn:
83
+ async with conn.cursor() as cur:
84
+ await cur.execute(_DDL_CONVERSATIONS)
85
+ await cur.execute(_DDL_CONVERSATION_TURNS)
86
+ await cur.execute(_INDEXES)
87
+ await conn.commit()
88
+ self._schema_ready = True
89
+
90
+ # ── save ──────────────────────────────────────────────────────────
91
+
92
+ async def save_conversation(self, conversation: Conversation) -> None:
93
+ pool = await self._ensure_pool()
94
+ await self._ensure_schema(pool)
95
+ async with pool.connection() as conn:
96
+ async with conn.cursor() as cur:
97
+ # Upsert the conversation row
98
+ await cur.execute(
99
+ """
100
+ INSERT INTO astrocyte_conversations
101
+ (id, source_uri, title, metadata, created_at)
102
+ VALUES (%s, %s, %s, %s::jsonb, %s)
103
+ ON CONFLICT (id) DO UPDATE SET
104
+ source_uri = EXCLUDED.source_uri,
105
+ title = EXCLUDED.title,
106
+ metadata = EXCLUDED.metadata
107
+ """,
108
+ (
109
+ conversation.id,
110
+ conversation.source_uri,
111
+ conversation.title,
112
+ json.dumps(conversation.metadata),
113
+ conversation.created_at,
114
+ ),
115
+ )
116
+ # Replace any prior turns
117
+ await cur.execute(
118
+ "DELETE FROM astrocyte_conversation_turns WHERE conversation_id = %s",
119
+ (conversation.id,),
120
+ )
121
+ if conversation.turns:
122
+ rows = [
123
+ (
124
+ t.id,
125
+ conversation.id,
126
+ i,
127
+ t.role,
128
+ t.content,
129
+ t.timestamp,
130
+ json.dumps(t.metadata),
131
+ )
132
+ for i, t in enumerate(conversation.turns)
133
+ ]
134
+ await cur.executemany(
135
+ """
136
+ INSERT INTO astrocyte_conversation_turns
137
+ (id, conversation_id, turn_index, role, content, "timestamp", metadata)
138
+ VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb)
139
+ """,
140
+ rows,
141
+ )
142
+ await conn.commit()
143
+
144
+ # ── reads ─────────────────────────────────────────────────────────
145
+
146
+ async def get_conversation(self, conversation_id: str) -> Conversation | None:
147
+ pool = await self._ensure_pool()
148
+ await self._ensure_schema(pool)
149
+ async with pool.connection() as conn:
150
+ async with conn.cursor() as cur:
151
+ await cur.execute(
152
+ """
153
+ SELECT id, source_uri, title, metadata, created_at
154
+ FROM astrocyte_conversations
155
+ WHERE id = %s
156
+ """,
157
+ (conversation_id,),
158
+ )
159
+ row = await cur.fetchone()
160
+ if row is None:
161
+ return None
162
+ conv = Conversation(
163
+ id=str(row[0]),
164
+ turns=[],
165
+ source_uri=row[1] or "",
166
+ title=row[2] or "",
167
+ metadata=row[3] or {},
168
+ created_at=row[4],
169
+ )
170
+ await cur.execute(
171
+ """
172
+ SELECT id, turn_index, role, content, "timestamp", metadata
173
+ FROM astrocyte_conversation_turns
174
+ WHERE conversation_id = %s
175
+ ORDER BY turn_index
176
+ """,
177
+ (conversation_id,),
178
+ )
179
+ turn_rows = await cur.fetchall()
180
+ conv.turns = [
181
+ ConversationTurn(
182
+ id=str(r[0]),
183
+ role=r[2] or "user",
184
+ content=r[3] or "",
185
+ timestamp=r[4],
186
+ metadata=r[5] or {},
187
+ )
188
+ for r in turn_rows
189
+ ]
190
+ return conv
191
+
192
+ async def list_conversations(self, *, limit: int = 100) -> list[Conversation]:
193
+ pool = await self._ensure_pool()
194
+ await self._ensure_schema(pool)
195
+ async with pool.connection() as conn:
196
+ async with conn.cursor() as cur:
197
+ await cur.execute(
198
+ """
199
+ SELECT id FROM astrocyte_conversations
200
+ ORDER BY created_at DESC LIMIT %s
201
+ """,
202
+ (limit,),
203
+ )
204
+ ids = [str(r[0]) for r in await cur.fetchall()]
205
+ # Eager-load turns. For huge conversations this is expensive;
206
+ # callers should pass small `limit` or add an `include_turns=False`
207
+ # variant (Phase 3+ if needed).
208
+ result: list[Conversation] = []
209
+ for cid in ids:
210
+ conv = await self.get_conversation(cid)
211
+ if conv is not None:
212
+ result.append(conv)
213
+ return result
214
+
215
+ # ── delete ────────────────────────────────────────────────────────
216
+
217
+ async def delete_conversation(self, conversation_id: str) -> None:
218
+ pool = await self._ensure_pool()
219
+ await self._ensure_schema(pool)
220
+ async with pool.connection() as conn:
221
+ async with conn.cursor() as cur:
222
+ # FK CASCADE handles the turns
223
+ await cur.execute(
224
+ "DELETE FROM astrocyte_conversations WHERE id = %s",
225
+ (conversation_id,),
226
+ )
227
+ await conn.commit()
228
+
229
+
230
+ # ─── inline DDL (mirrors migrations 027+028) ──────────────────────────
231
+
232
+ _DDL_CONVERSATIONS = """
233
+ CREATE TABLE IF NOT EXISTS astrocyte_conversations (
234
+ id UUID PRIMARY KEY,
235
+ source_uri TEXT NOT NULL DEFAULT '',
236
+ title TEXT NOT NULL DEFAULT '',
237
+ metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
238
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
239
+ );
240
+ """
241
+
242
+ _DDL_CONVERSATION_TURNS = """
243
+ CREATE TABLE IF NOT EXISTS astrocyte_conversation_turns (
244
+ id UUID PRIMARY KEY,
245
+ conversation_id UUID NOT NULL REFERENCES astrocyte_conversations(id) ON DELETE CASCADE,
246
+ turn_index INTEGER NOT NULL,
247
+ role TEXT NOT NULL,
248
+ content TEXT NOT NULL,
249
+ "timestamp" TIMESTAMPTZ,
250
+ metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
251
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
252
+ UNIQUE (conversation_id, turn_index)
253
+ );
254
+ """
255
+
256
+ _INDEXES = """
257
+ CREATE INDEX IF NOT EXISTS astrocyte_conversations_source_idx
258
+ ON astrocyte_conversations (source_uri)
259
+ WHERE source_uri <> '';
260
+ CREATE INDEX IF NOT EXISTS astrocyte_conversations_created_at_idx
261
+ ON astrocyte_conversations (created_at DESC);
262
+ CREATE INDEX IF NOT EXISTS astrocyte_conversation_turns_conv_idx
263
+ ON astrocyte_conversation_turns (conversation_id, turn_index);
264
+ CREATE INDEX IF NOT EXISTS astrocyte_conversation_turns_role_idx
265
+ ON astrocyte_conversation_turns (conversation_id, role);
266
+ CREATE INDEX IF NOT EXISTS astrocyte_conversation_turns_ts_idx
267
+ ON astrocyte_conversation_turns (conversation_id, "timestamp")
268
+ WHERE "timestamp" IS NOT NULL;
269
+ """