graphgrail 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.
Files changed (133) hide show
  1. grail/__init__.py +59 -0
  2. grail/_version.py +1 -0
  3. grail/apps/__init__.py +0 -0
  4. grail/apps/chat/__init__.py +0 -0
  5. grail/apps/chat/auth.py +69 -0
  6. grail/apps/chat/database.py +349 -0
  7. grail/apps/chat/frontend/dist/assets/index-2a40wZqj.css +1 -0
  8. grail/apps/chat/frontend/dist/assets/index-DfRgtpKs.js +239 -0
  9. grail/apps/chat/frontend/dist/index.html +17 -0
  10. grail/apps/chat/frontend/index.html +16 -0
  11. grail/apps/chat/frontend/package-lock.json +3990 -0
  12. grail/apps/chat/frontend/package.json +29 -0
  13. grail/apps/chat/frontend/src/App.tsx +76 -0
  14. grail/apps/chat/frontend/src/components/ChatInput.tsx +131 -0
  15. grail/apps/chat/frontend/src/components/ChatView.tsx +96 -0
  16. grail/apps/chat/frontend/src/components/InfoPanel.tsx +119 -0
  17. grail/apps/chat/frontend/src/components/Layout.tsx +118 -0
  18. grail/apps/chat/frontend/src/components/LoginView.tsx +199 -0
  19. grail/apps/chat/frontend/src/components/MarkdownRenderer.tsx +227 -0
  20. grail/apps/chat/frontend/src/components/MessageBubble.tsx +237 -0
  21. grail/apps/chat/frontend/src/components/ModeSelector.tsx +189 -0
  22. grail/apps/chat/frontend/src/components/Sidebar.tsx +211 -0
  23. grail/apps/chat/frontend/src/components/WelcomeView.tsx +182 -0
  24. grail/apps/chat/frontend/src/index.css +166 -0
  25. grail/apps/chat/frontend/src/lib/api.ts +162 -0
  26. grail/apps/chat/frontend/src/lib/store.ts +395 -0
  27. grail/apps/chat/frontend/src/main.tsx +10 -0
  28. grail/apps/chat/frontend/src/vite-env.d.ts +1 -0
  29. grail/apps/chat/frontend/tsconfig.app.json +21 -0
  30. grail/apps/chat/frontend/tsconfig.app.tsbuildinfo +1 -0
  31. grail/apps/chat/frontend/tsconfig.json +7 -0
  32. grail/apps/chat/frontend/tsconfig.node.json +19 -0
  33. grail/apps/chat/frontend/tsconfig.node.tsbuildinfo +1 -0
  34. grail/apps/chat/frontend/vite.config.ts +13 -0
  35. grail/apps/chat/schemas.py +90 -0
  36. grail/apps/chat/server.py +414 -0
  37. grail/apps/cli_chat/__init__.py +8 -0
  38. grail/apps/cli_chat/app.py +990 -0
  39. grail/apps/cli_chat/commands.py +181 -0
  40. grail/apps/cli_chat/reporter.py +73 -0
  41. grail/apps/cli_chat/widgets.py +496 -0
  42. grail/cli/__init__.py +7 -0
  43. grail/cli/banner.py +819 -0
  44. grail/cli/main.py +1663 -0
  45. grail/config.py +548 -0
  46. grail/core.py +1064 -0
  47. grail/export/__init__.py +5 -0
  48. grail/export/neo4j.py +298 -0
  49. grail/indexing/__init__.py +36 -0
  50. grail/indexing/communities.py +214 -0
  51. grail/indexing/community_reports.py +486 -0
  52. grail/indexing/entities_relationships.py +978 -0
  53. grail/indexing/entity_dedup.py +396 -0
  54. grail/indexing/incremental_community.py +382 -0
  55. grail/indexing/leiden.py +177 -0
  56. grail/indexing/loader.py +612 -0
  57. grail/indexing/preprocess.py +312 -0
  58. grail/indexing/run_manifest.py +274 -0
  59. grail/indexing/schema_migration.py +114 -0
  60. grail/indexing/stable_lcc.py +42 -0
  61. grail/indexing/summarize_descriptions.py +249 -0
  62. grail/llm/__init__.py +39 -0
  63. grail/llm/cache.py +122 -0
  64. grail/llm/cost.py +253 -0
  65. grail/llm/embeddings.py +228 -0
  66. grail/llm/providers.py +198 -0
  67. grail/llm/reranker.py +220 -0
  68. grail/llm/wrapper.py +654 -0
  69. grail/memory/__init__.py +55 -0
  70. grail/memory/_merge.py +288 -0
  71. grail/memory/analyses/__init__.py +70 -0
  72. grail/memory/analyses/alias_detect.py +190 -0
  73. grail/memory/analyses/edge_density.py +164 -0
  74. grail/memory/analyses/folder_split.py +135 -0
  75. grail/memory/analyses/membership.py +118 -0
  76. grail/memory/consolidate.py +164 -0
  77. grail/memory/identity.py +231 -0
  78. grail/memory/observation.py +165 -0
  79. grail/memory/project.py +1805 -0
  80. grail/memory/proposals.py +240 -0
  81. grail/memory/types.py +127 -0
  82. grail/prompts/__init__.py +25 -0
  83. grail/prompts/builtin/__init__.py +8 -0
  84. grail/prompts/builtin/claim_extraction.py +107 -0
  85. grail/prompts/builtin/community_report.py +170 -0
  86. grail/prompts/builtin/create_custom_entities.py +138 -0
  87. grail/prompts/builtin/entity_dedup.py +117 -0
  88. grail/prompts/builtin/entity_relation.py +267 -0
  89. grail/prompts/builtin/global_map.py +114 -0
  90. grail/prompts/builtin/global_reduce.py +77 -0
  91. grail/prompts/builtin/json_correction.py +83 -0
  92. grail/prompts/builtin/local_search.py +73 -0
  93. grail/prompts/builtin/summarize_description.py +105 -0
  94. grail/prompts/loader.py +172 -0
  95. grail/query/__init__.py +29 -0
  96. grail/query/agent.py +560 -0
  97. grail/query/cascade_search.py +376 -0
  98. grail/query/document_search.py +294 -0
  99. grail/query/global_search.py +287 -0
  100. grail/query/local_search.py +331 -0
  101. grail/query/recall_filter.py +312 -0
  102. grail/query/recall_search.py +264 -0
  103. grail/query/retrieval.py +425 -0
  104. grail/query/trace.py +119 -0
  105. grail/reporting/__init__.py +7 -0
  106. grail/reporting/rich_reporter.py +183 -0
  107. grail/schemas.py +382 -0
  108. grail/storage/__init__.py +29 -0
  109. grail/storage/base.py +127 -0
  110. grail/storage/local.py +116 -0
  111. grail/storage/s3.py +118 -0
  112. grail/utils/__init__.py +19 -0
  113. grail/utils/chunker.py +47 -0
  114. grail/utils/ids.py +19 -0
  115. grail/utils/text.py +44 -0
  116. grail/utils/tokens.py +29 -0
  117. grail/utils/zip.py +50 -0
  118. grail/vectorstores/__init__.py +29 -0
  119. grail/vectorstores/base.py +75 -0
  120. grail/vectorstores/chroma.py +145 -0
  121. grail/vectorstores/faiss.py +158 -0
  122. grail/vectorstores/lancedb.py +111 -0
  123. grail/viz/__init__.py +24 -0
  124. grail/viz/builder.py +129 -0
  125. grail/viz/colors.py +127 -0
  126. grail/viz/exporter.py +552 -0
  127. grail/viz/layout.py +315 -0
  128. grail/viz/template.py +1015 -0
  129. graphgrail-0.1.0.dist-info/METADATA +68 -0
  130. graphgrail-0.1.0.dist-info/RECORD +133 -0
  131. graphgrail-0.1.0.dist-info/WHEEL +4 -0
  132. graphgrail-0.1.0.dist-info/entry_points.txt +2 -0
  133. graphgrail-0.1.0.dist-info/licenses/LICENSE +21 -0
grail/__init__.py ADDED
@@ -0,0 +1,59 @@
1
+ """
2
+ GRAIL — Graph RAG with Advanced Integration and Learning.
3
+
4
+ Provided by Nirvai (Nirvana). Author: Benjamin González Guerrero.
5
+
6
+ Public surface re-exports the most common entry points so users can do::
7
+
8
+ from grail import GRAIL, LLMClient, EmbeddingClient
9
+ from grail import load_config, PromptRegistry
10
+ """
11
+ from grail._version import __version__
12
+ from grail.config import Config, load_config
13
+ from grail.core import GRAIL
14
+ from grail.llm import EmbeddingClient, LLMClient
15
+ from grail.memory import (
16
+ CommunityRecord,
17
+ EntityRecord,
18
+ MemoryProject,
19
+ Observation,
20
+ RelationshipRecord,
21
+ Reply,
22
+ )
23
+ from grail.prompts import PromptRegistry
24
+ from grail.query.recall_filter import RecallFilter
25
+ from grail.schemas import (
26
+ Community,
27
+ CommunityReport,
28
+ Covariate,
29
+ Document,
30
+ Entity,
31
+ Relationship,
32
+ SearchResult,
33
+ TextUnit,
34
+ )
35
+
36
+ __all__ = [
37
+ "Community",
38
+ "CommunityRecord",
39
+ "CommunityReport",
40
+ "Config",
41
+ "Covariate",
42
+ "Document",
43
+ "EmbeddingClient",
44
+ "Entity",
45
+ "EntityRecord",
46
+ "GRAIL",
47
+ "LLMClient",
48
+ "MemoryProject",
49
+ "Observation",
50
+ "PromptRegistry",
51
+ "RecallFilter",
52
+ "Relationship",
53
+ "RelationshipRecord",
54
+ "Reply",
55
+ "SearchResult",
56
+ "TextUnit",
57
+ "__version__",
58
+ "load_config",
59
+ ]
grail/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
grail/apps/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,69 @@
1
+ """
2
+ JWT authentication and password hashing for the GRAIL chat API.
3
+
4
+ Provided by Nirvai (Nirvana). Author: Benjamin González Guerrero.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import secrets
10
+ from datetime import datetime, timedelta, timezone
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from fastapi import Depends, HTTPException, status
15
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
16
+ from jose import JWTError, jwt
17
+ from passlib.context import CryptContext
18
+
19
+ _pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
20
+ _bearer_scheme = HTTPBearer()
21
+
22
+ ALGORITHM = "HS256"
23
+ ACCESS_TOKEN_EXPIRE_HOURS = 24
24
+ _SECRET_FILE = Path.home() / ".grail" / "jwt_secret"
25
+
26
+
27
+ def _get_secret() -> str:
28
+ env = os.environ.get("GRAIL_JWT_SECRET")
29
+ if env:
30
+ return env
31
+ if _SECRET_FILE.exists():
32
+ return _SECRET_FILE.read_text().strip()
33
+ secret = secrets.token_urlsafe(64)
34
+ _SECRET_FILE.parent.mkdir(parents=True, exist_ok=True)
35
+ _SECRET_FILE.write_text(secret)
36
+ return secret
37
+
38
+
39
+ def hash_password(plain: str) -> str:
40
+ return _pwd_context.hash(plain)
41
+
42
+
43
+ def verify_password(plain: str, hashed: str) -> bool:
44
+ return _pwd_context.verify(plain, hashed)
45
+
46
+
47
+ def create_access_token(user_id: str, username: str) -> str:
48
+ expire = datetime.now(timezone.utc) + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS)
49
+ payload = {"sub": user_id, "username": username, "exp": expire}
50
+ return jwt.encode(payload, _get_secret(), algorithm=ALGORITHM)
51
+
52
+
53
+ def decode_token(token: str) -> dict[str, Any]:
54
+ return jwt.decode(token, _get_secret(), algorithms=[ALGORITHM])
55
+
56
+
57
+ async def get_current_user(
58
+ credentials: HTTPAuthorizationCredentials = Depends(_bearer_scheme),
59
+ ) -> dict[str, str]:
60
+ token = credentials.credentials
61
+ try:
62
+ payload = decode_token(token)
63
+ user_id: str | None = payload.get("sub")
64
+ username: str | None = payload.get("username")
65
+ if not user_id or not username:
66
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
67
+ return {"id": user_id, "username": username}
68
+ except JWTError:
69
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token")
@@ -0,0 +1,349 @@
1
+ """
2
+ SQLite database layer for the GRAIL chat API.
3
+
4
+ Provided by Nirvai (Nirvana). Author: Benjamin González Guerrero.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import uuid
10
+ from contextlib import asynccontextmanager
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Any, AsyncIterator, Optional
14
+
15
+ import aiosqlite
16
+
17
+ _db_path: Path = Path.home() / ".grail" / "chat.db"
18
+
19
+ _SCHEMA = """\
20
+ CREATE TABLE IF NOT EXISTS users (
21
+ id TEXT PRIMARY KEY,
22
+ username TEXT UNIQUE NOT NULL,
23
+ password_hash TEXT NOT NULL,
24
+ created_at TEXT NOT NULL
25
+ );
26
+
27
+ CREATE TABLE IF NOT EXISTS sessions (
28
+ id TEXT PRIMARY KEY,
29
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
30
+ title TEXT NOT NULL DEFAULT 'New Chat',
31
+ mode TEXT NOT NULL DEFAULT 'local',
32
+ source TEXT NOT NULL DEFAULT 'web',
33
+ created_at TEXT NOT NULL,
34
+ updated_at TEXT NOT NULL
35
+ );
36
+
37
+ CREATE TABLE IF NOT EXISTS messages (
38
+ id TEXT PRIMARY KEY,
39
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
40
+ role TEXT NOT NULL,
41
+ content TEXT NOT NULL,
42
+ metadata TEXT NOT NULL DEFAULT '{}',
43
+ tool_calls TEXT,
44
+ tool_call_id TEXT,
45
+ created_at TEXT NOT NULL
46
+ );
47
+
48
+ CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
49
+ CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
50
+ """
51
+
52
+ _MIGRATIONS = [
53
+ "ALTER TABLE messages ADD COLUMN tool_calls TEXT",
54
+ "ALTER TABLE messages ADD COLUMN tool_call_id TEXT",
55
+ "ALTER TABLE sessions ADD COLUMN source TEXT NOT NULL DEFAULT 'web'",
56
+ ]
57
+
58
+
59
+ def configure_db_path(path: Path) -> None:
60
+ global _db_path
61
+ _db_path = path
62
+
63
+
64
+ def _now() -> str:
65
+ return datetime.now(timezone.utc).isoformat()
66
+
67
+
68
+ def _uuid() -> str:
69
+ return str(uuid.uuid4())
70
+
71
+
72
+ async def init_db() -> None:
73
+ _db_path.parent.mkdir(parents=True, exist_ok=True)
74
+ async with aiosqlite.connect(str(_db_path)) as db:
75
+ await db.execute("PRAGMA journal_mode=WAL")
76
+ await db.execute("PRAGMA foreign_keys=ON")
77
+ await db.executescript(_SCHEMA)
78
+ for migration in _MIGRATIONS:
79
+ try:
80
+ await db.execute(migration)
81
+ except Exception:
82
+ pass
83
+ await db.commit()
84
+
85
+
86
+ @asynccontextmanager
87
+ async def get_db() -> AsyncIterator[aiosqlite.Connection]:
88
+ db = await aiosqlite.connect(str(_db_path))
89
+ await db.execute("PRAGMA foreign_keys=ON")
90
+ db.row_factory = aiosqlite.Row
91
+ try:
92
+ yield db
93
+ finally:
94
+ await db.close()
95
+
96
+
97
+ # ------------------------------------------------------------------ Users
98
+
99
+
100
+ async def get_user_count() -> int:
101
+ async with get_db() as db:
102
+ cursor = await db.execute("SELECT COUNT(*) FROM users")
103
+ row = await cursor.fetchone()
104
+ return row[0]
105
+
106
+
107
+ async def create_user(username: str, password_hash: str) -> dict[str, Any]:
108
+ user_id = _uuid()
109
+ now = _now()
110
+ async with get_db() as db:
111
+ await db.execute(
112
+ "INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)",
113
+ (user_id, username, password_hash, now),
114
+ )
115
+ await db.commit()
116
+ return {"id": user_id, "username": username, "created_at": now}
117
+
118
+
119
+ async def get_user_by_username(username: str) -> Optional[dict[str, Any]]:
120
+ async with get_db() as db:
121
+ cursor = await db.execute(
122
+ "SELECT id, username, password_hash, created_at FROM users WHERE username = ?",
123
+ (username,),
124
+ )
125
+ row = await cursor.fetchone()
126
+ if not row:
127
+ return None
128
+ return {"id": row[0], "username": row[1], "password_hash": row[2], "created_at": row[3]}
129
+
130
+
131
+ async def get_user_by_id(user_id: str) -> Optional[dict[str, Any]]:
132
+ async with get_db() as db:
133
+ cursor = await db.execute(
134
+ "SELECT id, username, created_at FROM users WHERE id = ?",
135
+ (user_id,),
136
+ )
137
+ row = await cursor.fetchone()
138
+ if not row:
139
+ return None
140
+ return {"id": row[0], "username": row[1], "created_at": row[2]}
141
+
142
+
143
+ # ------------------------------------------------------------------ Sessions
144
+
145
+
146
+ async def list_sessions(user_id: str, source: Optional[str] = None) -> list[dict[str, Any]]:
147
+ """List sessions for a user. If *source* is given, filter by source (e.g. 'web' or 'cli')."""
148
+ async with get_db() as db:
149
+ if source is None:
150
+ cursor = await db.execute(
151
+ """
152
+ SELECT s.id, s.title, s.mode, s.source, s.created_at, s.updated_at,
153
+ (SELECT COUNT(*) FROM messages WHERE session_id = s.id AND role IN ('user', 'assistant') AND tool_calls IS NULL) AS message_count
154
+ FROM sessions s
155
+ WHERE s.user_id = ?
156
+ ORDER BY s.updated_at DESC
157
+ """,
158
+ (user_id,),
159
+ )
160
+ else:
161
+ cursor = await db.execute(
162
+ """
163
+ SELECT s.id, s.title, s.mode, s.source, s.created_at, s.updated_at,
164
+ (SELECT COUNT(*) FROM messages WHERE session_id = s.id AND role IN ('user', 'assistant') AND tool_calls IS NULL) AS message_count
165
+ FROM sessions s
166
+ WHERE s.user_id = ? AND s.source = ?
167
+ ORDER BY s.updated_at DESC
168
+ """,
169
+ (user_id, source),
170
+ )
171
+ rows = await cursor.fetchall()
172
+ return [
173
+ {
174
+ "id": r[0], "title": r[1], "mode": r[2], "source": r[3],
175
+ "created_at": r[4], "updated_at": r[5], "message_count": r[6],
176
+ }
177
+ for r in rows
178
+ ]
179
+
180
+
181
+ async def create_session(
182
+ user_id: str, title: str = "New Chat", mode: str = "local", source: str = "web",
183
+ ) -> dict[str, Any]:
184
+ session_id = _uuid()
185
+ now = _now()
186
+ async with get_db() as db:
187
+ await db.execute(
188
+ "INSERT INTO sessions (id, user_id, title, mode, source, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
189
+ (session_id, user_id, title, mode, source, now, now),
190
+ )
191
+ await db.commit()
192
+ return {
193
+ "id": session_id, "title": title, "mode": mode, "source": source,
194
+ "created_at": now, "updated_at": now,
195
+ }
196
+
197
+
198
+ async def get_session(session_id: str, user_id: str) -> Optional[dict[str, Any]]:
199
+ async with get_db() as db:
200
+ cursor = await db.execute(
201
+ "SELECT id, user_id, title, mode, created_at, updated_at FROM sessions WHERE id = ? AND user_id = ?",
202
+ (session_id, user_id),
203
+ )
204
+ row = await cursor.fetchone()
205
+ if not row:
206
+ return None
207
+ return {
208
+ "id": row[0], "user_id": row[1], "title": row[2],
209
+ "mode": row[3], "created_at": row[4], "updated_at": row[5],
210
+ }
211
+
212
+
213
+ async def update_session(session_id: str, user_id: str, **fields: Any) -> Optional[dict[str, Any]]:
214
+ allowed = {"title", "mode"}
215
+ updates = {k: v for k, v in fields.items() if k in allowed and v is not None}
216
+ if not updates:
217
+ return await get_session(session_id, user_id)
218
+ updates["updated_at"] = _now()
219
+ set_clause = ", ".join(f"{k} = ?" for k in updates)
220
+ values = list(updates.values()) + [session_id, user_id]
221
+ async with get_db() as db:
222
+ await db.execute(
223
+ f"UPDATE sessions SET {set_clause} WHERE id = ? AND user_id = ?",
224
+ values,
225
+ )
226
+ await db.commit()
227
+ return await get_session(session_id, user_id)
228
+
229
+
230
+ async def delete_session(session_id: str, user_id: str) -> bool:
231
+ async with get_db() as db:
232
+ cursor = await db.execute(
233
+ "DELETE FROM sessions WHERE id = ? AND user_id = ?",
234
+ (session_id, user_id),
235
+ )
236
+ await db.commit()
237
+ return cursor.rowcount > 0
238
+
239
+
240
+ # ------------------------------------------------------------------ Messages
241
+
242
+
243
+ def _row_to_message(r: tuple) -> dict[str, Any]:
244
+ """Convert a DB row to a message dict."""
245
+ msg: dict[str, Any] = {
246
+ "id": r[0], "role": r[1], "content": r[2],
247
+ "metadata": json.loads(r[3]), "created_at": r[4],
248
+ }
249
+ if r[5]:
250
+ msg["tool_calls"] = json.loads(r[5])
251
+ if r[6]:
252
+ msg["tool_call_id"] = r[6]
253
+ return msg
254
+
255
+
256
+ async def get_messages(session_id: str, *, include_tool_messages: bool = False) -> list[dict[str, Any]]:
257
+ """Get messages for display. By default excludes tool-call machinery."""
258
+ async with get_db() as db:
259
+ if include_tool_messages:
260
+ cursor = await db.execute(
261
+ "SELECT id, role, content, metadata, created_at, tool_calls, tool_call_id "
262
+ "FROM messages WHERE session_id = ? ORDER BY created_at ASC",
263
+ (session_id,),
264
+ )
265
+ else:
266
+ cursor = await db.execute(
267
+ "SELECT id, role, content, metadata, created_at, tool_calls, tool_call_id "
268
+ "FROM messages WHERE session_id = ? "
269
+ "AND role IN ('user', 'assistant') AND tool_calls IS NULL "
270
+ "ORDER BY created_at ASC",
271
+ (session_id,),
272
+ )
273
+ rows = await cursor.fetchall()
274
+ return [_row_to_message(r) for r in rows]
275
+
276
+
277
+ async def create_message(
278
+ session_id: str,
279
+ role: str,
280
+ content: str,
281
+ metadata: Optional[dict[str, Any]] = None,
282
+ tool_calls: Optional[list[dict[str, Any]]] = None,
283
+ tool_call_id: Optional[str] = None,
284
+ ) -> dict[str, Any]:
285
+ msg_id = _uuid()
286
+ now = _now()
287
+ meta_json = json.dumps(metadata or {})
288
+ tc_json = json.dumps(tool_calls) if tool_calls else None
289
+ async with get_db() as db:
290
+ await db.execute(
291
+ "INSERT INTO messages (id, session_id, role, content, metadata, tool_calls, tool_call_id, created_at) "
292
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
293
+ (msg_id, session_id, role, content, meta_json, tc_json, tool_call_id, now),
294
+ )
295
+ await db.execute(
296
+ "UPDATE sessions SET updated_at = ? WHERE id = ?",
297
+ (now, session_id),
298
+ )
299
+ await db.commit()
300
+ msg: dict[str, Any] = {
301
+ "id": msg_id, "role": role, "content": content,
302
+ "metadata": metadata or {}, "created_at": now,
303
+ }
304
+ if tool_calls:
305
+ msg["tool_calls"] = tool_calls
306
+ if tool_call_id:
307
+ msg["tool_call_id"] = tool_call_id
308
+ return msg
309
+
310
+
311
+ async def create_messages_batch(
312
+ session_id: str, messages: list[dict[str, Any]],
313
+ ) -> None:
314
+ """Insert a batch of messages preserving order (for agent tool-call sequences)."""
315
+ base = datetime.now(timezone.utc)
316
+ async with get_db() as db:
317
+ for i, msg in enumerate(messages):
318
+ msg_id = _uuid()
319
+ ts = (base + __import__("datetime").timedelta(microseconds=i)).isoformat()
320
+ meta_json = json.dumps(msg.get("metadata") or {})
321
+ tc = msg.get("tool_calls")
322
+ tc_json = json.dumps(tc) if tc else None
323
+ await db.execute(
324
+ "INSERT INTO messages (id, session_id, role, content, metadata, tool_calls, tool_call_id, created_at) "
325
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
326
+ (msg_id, session_id, msg["role"], msg.get("content", ""),
327
+ meta_json, tc_json, msg.get("tool_call_id"), ts),
328
+ )
329
+ await db.execute(
330
+ "UPDATE sessions SET updated_at = ? WHERE id = ?",
331
+ (base.isoformat(), session_id),
332
+ )
333
+ await db.commit()
334
+
335
+
336
+ async def get_recent_messages(session_id: str, limit: int = 20) -> list[dict[str, Any]]:
337
+ """Get recent messages including tool-call messages for history building."""
338
+ async with get_db() as db:
339
+ cursor = await db.execute(
340
+ """
341
+ SELECT id, role, content, metadata, created_at, tool_calls, tool_call_id
342
+ FROM messages WHERE session_id = ?
343
+ ORDER BY created_at DESC LIMIT ?
344
+ """,
345
+ (session_id, limit),
346
+ )
347
+ rows = await cursor.fetchall()
348
+ rows = list(reversed(rows))
349
+ return [_row_to_message(r) for r in rows]
@@ -0,0 +1 @@
1
+ /*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-400:oklch(70.4% .191 22.216);--color-red-900:oklch(39.6% .141 25.723);--color-amber-400:oklch(82.8% .189 84.429);--color-teal-300:#5eead4;--color-teal-400:#2dd4bf;--color-teal-500:#14b8a6;--color-teal-600:#0d9488;--color-zinc-50:oklch(98.5% 0 0);--color-zinc-100:oklch(96.7% .001 286.375);--color-zinc-200:oklch(92% .004 286.32);--color-zinc-300:oklch(87.1% .006 286.286);--color-zinc-400:oklch(70.5% .015 286.067);--color-zinc-500:oklch(55.2% .016 285.938);--color-zinc-600:oklch(44.2% .017 285.786);--color-zinc-700:oklch(37% .013 285.805);--color-zinc-800:oklch(27.4% .006 286.033);--color-zinc-900:oklch(21% .006 285.885);--color-zinc-950:oklch(14.1% .005 285.823);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-2xl:42rem;--container-3xl:48rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height: 1.2 ;--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--tracking-widest:.1em;--leading-tight:1.25;--leading-relaxed:1.625;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--ease-in:cubic-bezier(.4, 0, 1, 1);--ease-out:cubic-bezier(0, 0, .2, 1);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-sm:8px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-none{pointer-events:none}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.inset-0{inset:calc(var(--spacing) * 0)}.inset-x-0{inset-inline:calc(var(--spacing) * 0)}.inset-y-0{inset-block:calc(var(--spacing) * 0)}.top-1\/2{top:50%}.top-1\/3{top:33.3333%}.top-2{top:calc(var(--spacing) * 2)}.right-2{right:calc(var(--spacing) * 2)}.bottom-0{bottom:calc(var(--spacing) * 0)}.bottom-full{bottom:100%}.left-0{left:calc(var(--spacing) * 0)}.left-1\/2{left:50%}.left-3{left:calc(var(--spacing) * 3)}.z-10{z-index:10}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.mx-auto{margin-inline:auto}.my-2{margin-block:calc(var(--spacing) * 2)}.my-3{margin-block:calc(var(--spacing) * 3)}.my-4{margin-block:calc(var(--spacing) * 4)}.-mt-1{margin-top:calc(var(--spacing) * -1)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mt-10{margin-top:calc(var(--spacing) * 10)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.ml-0\.5{margin-left:calc(var(--spacing) * .5)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-12{height:calc(var(--spacing) * 12)}.h-14{height:calc(var(--spacing) * 14)}.h-auto{height:auto}.h-full{height:100%}.h-screen{height:100vh}.max-h-60{max-height:calc(var(--spacing) * 60)}.max-h-\[200px\]{max-height:200px}.min-h-\[44px\]{min-height:44px}.min-h-screen{min-height:100vh}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2{width:calc(var(--spacing) * 2)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-12{width:calc(var(--spacing) * 12)}.w-14{width:calc(var(--spacing) * 14)}.w-72{width:calc(var(--spacing) * 72)}.w-\[272px\]{width:272px}.w-\[280px\]{width:280px}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-\[80\%\]{max-width:80%}.max-w-\[90px\]{max-width:90px}.max-w-\[100px\]{max-width:100px}.max-w-\[130px\]{max-width:130px}.max-w-\[240px\]{max-width:240px}.max-w-full{max-width:100%}.max-w-sm{max-width:var(--container-sm)}.min-w-0{min-width:calc(var(--spacing) * 0)}.flex-1{flex:1}.flex-shrink-0{flex-shrink:0}.-translate-x-1\/2{--tw-translate-x: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-full{--tw-translate-x:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-0{--tw-translate-x:calc(var(--spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-pointer{cursor:pointer}.resize{resize:both}.resize-none{resize:none}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-row{flex-direction:row}.flex-row-reverse{flex-direction:row-reverse}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-start{justify-content:flex-start}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-visible{overflow:visible}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-t-2xl{border-top-left-radius:var(--radius-2xl);border-top-right-radius:var(--radius-2xl)}.rounded-tl-md{border-top-left-radius:var(--radius-md)}.rounded-tl-sm{border-top-left-radius:var(--radius-sm)}.rounded-r{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-tr-sm{border-top-right-radius:var(--radius-sm)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-none{--tw-border-style:none;border-style:none}.border-teal-500{border-color:var(--color-teal-500)}.border-teal-500\/30{border-color:#14b8a64d}@supports (color:color-mix(in lab,red,red)){.border-teal-500\/30{border-color:color-mix(in oklab,var(--color-teal-500) 30%,transparent)}}.border-teal-500\/40{border-color:#14b8a666}@supports (color:color-mix(in lab,red,red)){.border-teal-500\/40{border-color:color-mix(in oklab,var(--color-teal-500) 40%,transparent)}}.border-teal-500\/50{border-color:#14b8a680}@supports (color:color-mix(in lab,red,red)){.border-teal-500\/50{border-color:color-mix(in oklab,var(--color-teal-500) 50%,transparent)}}.border-transparent{border-color:#0000}.border-zinc-700{border-color:var(--color-zinc-700)}.border-zinc-800{border-color:var(--color-zinc-800)}.border-zinc-800\/60{border-color:#27272a99}@supports (color:color-mix(in lab,red,red)){.border-zinc-800\/60{border-color:color-mix(in oklab,var(--color-zinc-800) 60%,transparent)}}.bg-black\/50{background-color:#00000080}@supports (color:color-mix(in lab,red,red)){.bg-black\/50{background-color:color-mix(in oklab,var(--color-black) 50%,transparent)}}.bg-black\/60{background-color:#0009}@supports (color:color-mix(in lab,red,red)){.bg-black\/60{background-color:color-mix(in oklab,var(--color-black) 60%,transparent)}}.bg-red-900\/30{background-color:#82181a4d}@supports (color:color-mix(in lab,red,red)){.bg-red-900\/30{background-color:color-mix(in oklab,var(--color-red-900) 30%,transparent)}}.bg-teal-500{background-color:var(--color-teal-500)}.bg-teal-500\/5{background-color:#14b8a60d}@supports (color:color-mix(in lab,red,red)){.bg-teal-500\/5{background-color:color-mix(in oklab,var(--color-teal-500) 5%,transparent)}}.bg-teal-500\/10{background-color:#14b8a61a}@supports (color:color-mix(in lab,red,red)){.bg-teal-500\/10{background-color:color-mix(in oklab,var(--color-teal-500) 10%,transparent)}}.bg-teal-500\/15{background-color:#14b8a626}@supports (color:color-mix(in lab,red,red)){.bg-teal-500\/15{background-color:color-mix(in oklab,var(--color-teal-500) 15%,transparent)}}.bg-teal-600{background-color:var(--color-teal-600)}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.bg-zinc-700{background-color:var(--color-zinc-700)}.bg-zinc-800{background-color:var(--color-zinc-800)}.bg-zinc-800\/40{background-color:#27272a66}@supports (color:color-mix(in lab,red,red)){.bg-zinc-800\/40{background-color:color-mix(in oklab,var(--color-zinc-800) 40%,transparent)}}.bg-zinc-800\/60{background-color:#27272a99}@supports (color:color-mix(in lab,red,red)){.bg-zinc-800\/60{background-color:color-mix(in oklab,var(--color-zinc-800) 60%,transparent)}}.bg-zinc-800\/80{background-color:#27272acc}@supports (color:color-mix(in lab,red,red)){.bg-zinc-800\/80{background-color:color-mix(in oklab,var(--color-zinc-800) 80%,transparent)}}.bg-zinc-900{background-color:var(--color-zinc-900)}.bg-zinc-900\/50{background-color:#18181b80}@supports (color:color-mix(in lab,red,red)){.bg-zinc-900\/50{background-color:color-mix(in oklab,var(--color-zinc-900) 50%,transparent)}}.bg-zinc-950{background-color:var(--color-zinc-950)}.p-0\.5{padding:calc(var(--spacing) * .5)}.p-1{padding:calc(var(--spacing) * 1)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-16{padding-block:calc(var(--spacing) * 16)}.py-20{padding-block:calc(var(--spacing) * 20)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-6{padding-left:calc(var(--spacing) * 6)}.pl-10{padding-left:calc(var(--spacing) * 10)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[5px\]{font-size:5px}.text-\[8px\]{font-size:8px}.text-\[9px\]{font-size:9px}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[13px\]{font-size:13px}.leading-\[1\.15\]{--tw-leading:1.15;line-height:1.15}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.text-balance{text-wrap:balance}.whitespace-pre-wrap{white-space:pre-wrap}.text-amber-400{color:var(--color-amber-400)}.text-red-400{color:var(--color-red-400)}.text-teal-300{color:var(--color-teal-300)}.text-teal-400{color:var(--color-teal-400)}.text-teal-500{color:var(--color-teal-500)}.text-white{color:var(--color-white)}.text-zinc-50{color:var(--color-zinc-50)}.text-zinc-100{color:var(--color-zinc-100)}.text-zinc-200{color:var(--color-zinc-200)}.text-zinc-300{color:var(--color-zinc-300)}.text-zinc-400{color:var(--color-zinc-400)}.text-zinc-500{color:var(--color-zinc-500)}.text-zinc-600{color:var(--color-zinc-600)}.uppercase{text-transform:uppercase}.italic{font-style:italic}.placeholder-zinc-500::placeholder{color:var(--color-zinc-500)}.opacity-0{opacity:0}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-black\/40{--tw-shadow-color:#0006}@supports (color:color-mix(in lab,red,red)){.shadow-black\/40{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 40%, transparent) var(--tw-shadow-alpha), transparent)}}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.drop-shadow{--tw-drop-shadow-size:drop-shadow(0 1px 2px var(--tw-drop-shadow-color,#0000001a)) drop-shadow(0 1px 1px var(--tw-drop-shadow-color,#0000000f));--tw-drop-shadow:drop-shadow(0 1px 2px #0000001a) drop-shadow(0 1px 1px #0000000f);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-100{--tw-duration:.1s;transition-duration:.1s}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.ease-\[cubic-bezier\(0\.25\,0\.4\,0\.25\,1\)\]{--tw-ease:cubic-bezier(.25,.4,.25,1);transition-timing-function:cubic-bezier(.25,.4,.25,1)}.ease-in{--tw-ease:var(--ease-in);transition-timing-function:var(--ease-in)}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.outline-none{--tw-outline-style:none;outline-style:none}@media(hover:hover){.group-hover\:block:is(:where(.group):hover *){display:block}.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}.group-hover\/src\:inline-flex:is(:where(.group\/src):hover *){display:inline-flex}}.first\:mt-0:first-child{margin-top:calc(var(--spacing) * 0)}.first\:mt-1:first-child{margin-top:calc(var(--spacing) * 1)}.focus-within\:border-zinc-700:focus-within{border-color:var(--color-zinc-700)}@media(hover:hover){.hover\:border-teal-600:hover{border-color:var(--color-teal-600)}.hover\:border-zinc-700:hover{border-color:var(--color-zinc-700)}.hover\:bg-teal-500:hover{background-color:var(--color-teal-500)}.hover\:bg-zinc-600:hover{background-color:var(--color-zinc-600)}.hover\:bg-zinc-700\/80:hover{background-color:#3f3f46cc}@supports (color:color-mix(in lab,red,red)){.hover\:bg-zinc-700\/80:hover{background-color:color-mix(in oklab,var(--color-zinc-700) 80%,transparent)}}.hover\:bg-zinc-800:hover{background-color:var(--color-zinc-800)}.hover\:bg-zinc-800\/50:hover{background-color:#27272a80}@supports (color:color-mix(in lab,red,red)){.hover\:bg-zinc-800\/50:hover{background-color:color-mix(in oklab,var(--color-zinc-800) 50%,transparent)}}.hover\:bg-zinc-800\/60:hover{background-color:#27272a99}@supports (color:color-mix(in lab,red,red)){.hover\:bg-zinc-800\/60:hover{background-color:color-mix(in oklab,var(--color-zinc-800) 60%,transparent)}}.hover\:bg-zinc-900:hover{background-color:var(--color-zinc-900)}.hover\:text-red-400:hover{color:var(--color-red-400)}.hover\:text-teal-300:hover{color:var(--color-teal-300)}.hover\:text-zinc-200:hover{color:var(--color-zinc-200)}.hover\:text-zinc-300:hover{color:var(--color-zinc-300)}}.focus\:border-teal-500:focus{border-color:var(--color-teal-500)}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-teal-500:focus{--tw-ring-color:var(--color-teal-500)}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}@media(min-width:40rem){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.sm\:text-\[6px\]{font-size:6px}}@media(min-width:48rem){.md\:relative{position:relative}.md\:z-auto{z-index:auto}.md\:hidden{display:none}.md\:w-0{width:calc(var(--spacing) * 0)}.md\:-translate-x-full{--tw-translate-x:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.md\:translate-x-0{--tw-translate-x:calc(var(--spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.md\:text-\[46px\]{font-size:46px}}}:root{--surface-0:#0a0a0c;--surface-1:#111114;--surface-2:#18181c;--surface-3:#1f1f24;--border:#25252b;--border-subtle:#1c1c22;--text-primary:#ececf0;--text-secondary:#8b8b98;--text-tertiary:#5a5a68;--accent:#14b8a6;--accent-soft:#14b8a614;--accent-border:#14b8a633}body{background:var(--surface-0);color:var(--text-primary);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;min-height:100vh;margin:0;font-family:DM Sans,ui-sans-serif,system-ui,-apple-system,sans-serif}body:after{content:"";z-index:9999;pointer-events:none;opacity:.018;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");background-repeat:repeat;background-size:256px 256px;position:fixed;top:0;right:0;bottom:0;left:0}::-webkit-scrollbar{width:4px}::-webkit-scrollbar-track{background:0 0}::-webkit-scrollbar-thumb{background:var(--border);border-radius:9999px}::-webkit-scrollbar-thumb:hover{background:#3f3f46}textarea{field-sizing:content}@keyframes pulse-dot{0%,80%,to{opacity:.3}40%{opacity:1}}.loading-dot:first-child{animation:1.4s ease-in-out infinite pulse-dot}.loading-dot:nth-child(2){animation:1.4s ease-in-out .2s infinite pulse-dot}.loading-dot:nth-child(3){animation:1.4s ease-in-out .4s infinite pulse-dot}@keyframes glow-pulse{0%,to{opacity:.4}50%{opacity:.7}}.glow-ring{box-shadow:0 0 0 1px var(--accent-border),0 0 20px -4px #14b8a626}.prose-grail{line-height:1.75}.prose-grail h1,.prose-grail h2,.prose-grail h3,.prose-grail h4{color:var(--text-primary);letter-spacing:-.01em;margin-top:1.25em;margin-bottom:.5em;font-weight:600}.prose-grail h1{font-size:1.5em}.prose-grail h2{font-size:1.25em}.prose-grail h3{font-size:1.1em}.prose-grail p{margin-top:.5em;margin-bottom:.5em}.prose-grail ul,.prose-grail ol{margin-top:.5em;margin-bottom:.5em;padding-left:1.5em}.prose-grail li{margin-top:.25em;margin-bottom:.25em}.prose-grail ul{list-style-type:disc}.prose-grail ol{list-style-type:decimal}.prose-grail code{background:var(--surface-2);color:#2dd4bf;border-radius:5px;padding:.15em .4em;font-family:JetBrains Mono,monospace;font-size:.85em}.prose-grail pre{background:var(--surface-1);border:1px solid var(--border);border-radius:10px;margin-top:.75em;margin-bottom:.75em;padding:1em;overflow-x:auto}.prose-grail pre code{color:#d4d4d4;background:0 0;padding:0}.prose-grail blockquote{border-left:2px solid var(--accent);color:var(--text-secondary);margin-top:.75em;margin-bottom:.75em;padding-left:1em}.prose-grail table{border-collapse:collapse;width:100%;margin-top:.75em;margin-bottom:.75em}.prose-grail th,.prose-grail td{border:1px solid var(--border);text-align:left;padding:.5em .75em}.prose-grail th{background:var(--surface-2);text-transform:uppercase;letter-spacing:.04em;color:var(--text-secondary);font-size:.85em;font-weight:600}.prose-grail a{color:#2dd4bf;text-decoration:none}.prose-grail a:hover{color:#5eead4;text-decoration:underline}.prose-grail hr{border-color:var(--border);margin-top:1.5em;margin-bottom:1.5em}.prose-grail strong{color:var(--text-primary);font-weight:600}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}}