chatnut 0.3.0__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.
- chatnut-0.3.0/.gitignore +15 -0
- chatnut-0.3.0/.python-version +1 -0
- chatnut-0.3.0/PKG-INFO +27 -0
- chatnut-0.3.0/chatnut/__init__.py +0 -0
- chatnut-0.3.0/chatnut/__main__.py +4 -0
- chatnut-0.3.0/chatnut/app.py +103 -0
- chatnut-0.3.0/chatnut/cli.py +182 -0
- chatnut-0.3.0/chatnut/config.py +5 -0
- chatnut-0.3.0/chatnut/db.py +422 -0
- chatnut-0.3.0/chatnut/mcp.py +272 -0
- chatnut-0.3.0/chatnut/migrate.py +119 -0
- chatnut-0.3.0/chatnut/models.py +33 -0
- chatnut-0.3.0/chatnut/routes.py +219 -0
- chatnut-0.3.0/chatnut/service.py +203 -0
- chatnut-0.3.0/chatnut/static/.gitkeep +0 -0
- chatnut-0.3.0/chatnut/static/index.html +97 -0
- chatnut-0.3.0/migrations/001_initial.sql +25 -0
- chatnut-0.3.0/migrations/002_read_cursors.sql +7 -0
- chatnut-0.3.0/pyproject.toml +49 -0
- chatnut-0.3.0/tests/__init__.py +0 -0
- chatnut-0.3.0/tests/conftest.py +12 -0
- chatnut-0.3.0/tests/test_app_config.py +107 -0
- chatnut-0.3.0/tests/test_cli.py +228 -0
- chatnut-0.3.0/tests/test_db.py +444 -0
- chatnut-0.3.0/tests/test_integration.py +180 -0
- chatnut-0.3.0/tests/test_mcp.py +63 -0
- chatnut-0.3.0/tests/test_mcp_e2e.py +397 -0
- chatnut-0.3.0/tests/test_migrate.py +276 -0
- chatnut-0.3.0/tests/test_models.py +77 -0
- chatnut-0.3.0/tests/test_routes.py +466 -0
- chatnut-0.3.0/tests/test_service.py +552 -0
- chatnut-0.3.0/tests/test_wait_for_messages.py +340 -0
- chatnut-0.3.0/uv.lock +1383 -0
chatnut-0.3.0/.gitignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.12
|
chatnut-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chatnut
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: ChatNut — shared chatrooms for AI agent teams
|
|
5
|
+
Project-URL: Repository, https://github.com/runno-ai/chatnut
|
|
6
|
+
Project-URL: Homepage, https://github.com/runno-ai/chatnut
|
|
7
|
+
Author-email: Runno AI <hi@runno.dev>
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Framework :: FastAPI
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Communications :: Chat
|
|
16
|
+
Requires-Python: >=3.12
|
|
17
|
+
Requires-Dist: anyio>=4.0
|
|
18
|
+
Requires-Dist: fastapi>=0.115.0
|
|
19
|
+
Requires-Dist: fastmcp<4,>=3.0.0
|
|
20
|
+
Requires-Dist: httpx>=0.28.0
|
|
21
|
+
Requires-Dist: sse-starlette>=2.0.0
|
|
22
|
+
Requires-Dist: uvicorn[standard]>=0.34.0
|
|
23
|
+
Provides-Extra: test
|
|
24
|
+
Requires-Dist: httpx>=0.28.0; extra == 'test'
|
|
25
|
+
Requires-Dist: pytest-anyio>=0.0.0; extra == 'test'
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'test'
|
|
27
|
+
Requires-Dist: pytest>=8.0; extra == 'test'
|
|
File without changes
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# chatnut/app.py
|
|
2
|
+
"""FastAPI application — mounts MCP + REST/SSE routes + static file serving."""
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import AsyncIterator
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from fastapi import FastAPI
|
|
13
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
14
|
+
from fastmcp.utilities.lifespan import combine_lifespans
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
AUTO_ARCHIVE_INTERVAL = 300 # check every 5 minutes
|
|
19
|
+
AUTO_ARCHIVE_INACTIVE_SECONDS = 7200 # archive after 2 hours of inactivity
|
|
20
|
+
|
|
21
|
+
from chatnut.config import DB_PATH
|
|
22
|
+
from chatnut.db import init_db
|
|
23
|
+
from chatnut.service import ChatService
|
|
24
|
+
from chatnut import mcp as mcp_module
|
|
25
|
+
from chatnut.mcp import mcp
|
|
26
|
+
from chatnut.routes import create_router
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _default_static_dir() -> str:
|
|
30
|
+
"""Return the package-internal static/ directory path."""
|
|
31
|
+
return str(Path(__file__).parent / "static")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
STATIC_DIR = os.environ.get("STATIC_DIR", _default_static_dir())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@lru_cache(maxsize=1)
|
|
38
|
+
def _get_service() -> ChatService:
|
|
39
|
+
db_conn = init_db(DB_PATH)
|
|
40
|
+
return ChatService(db_conn)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Wire MCP tools to use the same service instance as REST routes
|
|
44
|
+
mcp_module.set_service_factory(_get_service)
|
|
45
|
+
|
|
46
|
+
# Get MCP ASGI sub-app — path="/" so mount("/mcp") serves at /mcp (not /mcp/mcp)
|
|
47
|
+
mcp_app = mcp.http_app(path="/", transport="streamable-http")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def _auto_archive_loop() -> None:
|
|
51
|
+
"""Periodically archive stale live rooms."""
|
|
52
|
+
while True:
|
|
53
|
+
await asyncio.sleep(AUTO_ARCHIVE_INTERVAL)
|
|
54
|
+
try:
|
|
55
|
+
archived = _get_service().auto_archive_stale_rooms(AUTO_ARCHIVE_INACTIVE_SECONDS)
|
|
56
|
+
if archived:
|
|
57
|
+
names = [r["name"] for r in archived]
|
|
58
|
+
logger.info("Auto-archived %d stale rooms: %s", len(archived), names)
|
|
59
|
+
except Exception:
|
|
60
|
+
logger.exception("Auto-archive failed")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@asynccontextmanager
|
|
64
|
+
async def app_lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
|
65
|
+
# Ensure service is initialized at startup
|
|
66
|
+
_get_service()
|
|
67
|
+
mcp_module.set_event_loop(asyncio.get_running_loop())
|
|
68
|
+
task = asyncio.create_task(_auto_archive_loop())
|
|
69
|
+
yield
|
|
70
|
+
task.cancel()
|
|
71
|
+
try:
|
|
72
|
+
await task
|
|
73
|
+
except asyncio.CancelledError:
|
|
74
|
+
pass
|
|
75
|
+
mcp_module.set_event_loop(None) # Clear stale reference; _notify_waiters guards against closed loop
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
app = FastAPI(
|
|
79
|
+
title="ChatNut",
|
|
80
|
+
lifespan=combine_lifespans(app_lifespan, mcp_app.lifespan),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Mount MCP at /mcp — path="/" in http_app() + mount("/mcp") = /mcp
|
|
84
|
+
app.mount("/mcp", mcp_app)
|
|
85
|
+
|
|
86
|
+
# Mount API routes
|
|
87
|
+
api_router = create_router(_get_service)
|
|
88
|
+
app.include_router(api_router)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# Serve React SPA — static files + fallback to index.html
|
|
92
|
+
@app.get("/{full_path:path}")
|
|
93
|
+
async def serve_spa(full_path: str):
|
|
94
|
+
static_dir = Path(STATIC_DIR).resolve()
|
|
95
|
+
file_path = (static_dir / full_path).resolve()
|
|
96
|
+
if not file_path.is_relative_to(static_dir):
|
|
97
|
+
return JSONResponse(status_code=404, content={"error": "Not found"})
|
|
98
|
+
if file_path.is_file():
|
|
99
|
+
return FileResponse(file_path)
|
|
100
|
+
index_path = static_dir / "index.html"
|
|
101
|
+
if index_path.is_file():
|
|
102
|
+
return FileResponse(index_path)
|
|
103
|
+
return JSONResponse(status_code=503, content={"error": "Frontend not built. Run: cd app/fe && bun run build"})
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""CLI entry point for chatnut.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
chatnut # stdio mode (default) — proxy to HTTP server
|
|
5
|
+
chatnut serve # run HTTP server in foreground
|
|
6
|
+
"""
|
|
7
|
+
import argparse
|
|
8
|
+
import atexit
|
|
9
|
+
import os
|
|
10
|
+
import signal
|
|
11
|
+
import socket
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _get_run_dir() -> Path:
|
|
21
|
+
"""Return the runtime directory for PID/port files."""
|
|
22
|
+
return Path(os.environ.get("CHATNUT_RUN_DIR", Path.home() / ".chatnut"))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _find_free_port() -> int:
|
|
26
|
+
"""Find a free high port by binding to port 0."""
|
|
27
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
28
|
+
s.bind(("127.0.0.1", 0))
|
|
29
|
+
return s.getsockname()[1]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _cleanup_files(run_dir: Path) -> None:
|
|
33
|
+
"""Remove PID and port files."""
|
|
34
|
+
for f in ("server.pid", "server.port"):
|
|
35
|
+
(run_dir / f).unlink(missing_ok=True)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cmd_serve(args: argparse.Namespace) -> None:
|
|
39
|
+
"""Run the full FastAPI server in foreground."""
|
|
40
|
+
import uvicorn
|
|
41
|
+
|
|
42
|
+
run_dir = _get_run_dir()
|
|
43
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
|
|
45
|
+
port = args.port if args.port else _find_free_port()
|
|
46
|
+
|
|
47
|
+
pid_file = run_dir / "server.pid"
|
|
48
|
+
pid_file.write_text(str(os.getpid()))
|
|
49
|
+
|
|
50
|
+
def cleanup(*_args):
|
|
51
|
+
_cleanup_files(run_dir)
|
|
52
|
+
sys.exit(0)
|
|
53
|
+
|
|
54
|
+
signal.signal(signal.SIGTERM, cleanup)
|
|
55
|
+
signal.signal(signal.SIGINT, cleanup)
|
|
56
|
+
atexit.register(_cleanup_files, run_dir)
|
|
57
|
+
|
|
58
|
+
port_file = run_dir / "server.port"
|
|
59
|
+
|
|
60
|
+
class _ReadyServer(uvicorn.Server):
|
|
61
|
+
"""Uvicorn Server subclass that writes the port file after startup."""
|
|
62
|
+
|
|
63
|
+
async def startup(self, sockets=None):
|
|
64
|
+
await super().startup(sockets=sockets)
|
|
65
|
+
if self.started:
|
|
66
|
+
port_file.write_text(str(port))
|
|
67
|
+
|
|
68
|
+
config = uvicorn.Config(
|
|
69
|
+
"chatnut.app:app",
|
|
70
|
+
host="127.0.0.1",
|
|
71
|
+
port=port,
|
|
72
|
+
log_level="info",
|
|
73
|
+
)
|
|
74
|
+
server = _ReadyServer(config)
|
|
75
|
+
server.run()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _is_server_running() -> bool:
|
|
79
|
+
"""Check if the HTTP server is alive (PID exists + health check passes)."""
|
|
80
|
+
run_dir = _get_run_dir()
|
|
81
|
+
pid_file = run_dir / "server.pid"
|
|
82
|
+
port_file = run_dir / "server.port"
|
|
83
|
+
|
|
84
|
+
if not pid_file.exists() or not port_file.exists():
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
pid = int(pid_file.read_text().strip())
|
|
89
|
+
os.kill(pid, 0)
|
|
90
|
+
except (OSError, ValueError):
|
|
91
|
+
_cleanup_files(run_dir)
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
port = int(port_file.read_text().strip())
|
|
96
|
+
resp = httpx.get(f"http://127.0.0.1:{port}/api/status", timeout=2)
|
|
97
|
+
return resp.status_code == 200
|
|
98
|
+
except Exception:
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _get_server_url() -> str | None:
|
|
103
|
+
"""Read server URL from port file."""
|
|
104
|
+
port_file = _get_run_dir() / "server.port"
|
|
105
|
+
if not port_file.exists():
|
|
106
|
+
return None
|
|
107
|
+
try:
|
|
108
|
+
port = int(port_file.read_text().strip())
|
|
109
|
+
return f"http://127.0.0.1:{port}"
|
|
110
|
+
except (ValueError, OSError):
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _ensure_server() -> str:
|
|
115
|
+
"""Ensure the HTTP server is running. Returns the server URL."""
|
|
116
|
+
if _is_server_running():
|
|
117
|
+
url = _get_server_url()
|
|
118
|
+
if url:
|
|
119
|
+
return url
|
|
120
|
+
|
|
121
|
+
run_dir = _get_run_dir()
|
|
122
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
|
|
124
|
+
# Redirect server output to a log file for debugging
|
|
125
|
+
log_file = run_dir / "server.log"
|
|
126
|
+
log_fh = open(log_file, "a") # noqa: SIM115
|
|
127
|
+
subprocess.Popen(
|
|
128
|
+
[sys.executable, "-m", "chatnut.cli", "serve"],
|
|
129
|
+
stdout=log_fh,
|
|
130
|
+
stderr=log_fh,
|
|
131
|
+
start_new_session=True,
|
|
132
|
+
)
|
|
133
|
+
log_fh.close()
|
|
134
|
+
|
|
135
|
+
port_file = run_dir / "server.port"
|
|
136
|
+
for _ in range(20):
|
|
137
|
+
time.sleep(0.5)
|
|
138
|
+
if port_file.exists():
|
|
139
|
+
url = _get_server_url()
|
|
140
|
+
if url:
|
|
141
|
+
try:
|
|
142
|
+
resp = httpx.get(f"{url}/api/status", timeout=2)
|
|
143
|
+
if resp.status_code == 200:
|
|
144
|
+
return url
|
|
145
|
+
except Exception:
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
raise RuntimeError("Failed to start chatnut server within 10 seconds")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def cmd_stdio(args: argparse.Namespace) -> None:
|
|
152
|
+
"""Run as stdio MCP proxy — auto-starts HTTP server if needed."""
|
|
153
|
+
import asyncio
|
|
154
|
+
|
|
155
|
+
from fastmcp import FastMCP
|
|
156
|
+
from fastmcp.server.providers.proxy import ProxyClient, ProxyProvider
|
|
157
|
+
|
|
158
|
+
server_url = _ensure_server()
|
|
159
|
+
mcp_url = f"{server_url}/mcp/"
|
|
160
|
+
|
|
161
|
+
proxy = FastMCP("agents-chat")
|
|
162
|
+
proxy.add_provider(ProxyProvider(lambda: ProxyClient(mcp_url)))
|
|
163
|
+
|
|
164
|
+
asyncio.run(proxy.run_stdio_async())
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def main() -> None:
|
|
168
|
+
parser = argparse.ArgumentParser(prog="chatnut")
|
|
169
|
+
sub = parser.add_subparsers(dest="command")
|
|
170
|
+
|
|
171
|
+
serve_parser = sub.add_parser("serve", help="Run HTTP server in foreground")
|
|
172
|
+
serve_parser.add_argument("--port", type=int, default=0, help="Port to bind (0 = auto)")
|
|
173
|
+
serve_parser.set_defaults(func=cmd_serve)
|
|
174
|
+
|
|
175
|
+
parser.set_defaults(func=cmd_stdio)
|
|
176
|
+
|
|
177
|
+
args = parser.parse_args()
|
|
178
|
+
args.func(args)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
if __name__ == "__main__":
|
|
182
|
+
main()
|