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.
@@ -0,0 +1,15 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .venv/
8
+ *.db
9
+ .pytest_cache/
10
+ working-scope.md # local task scoping file (worktree-specific, not committed)
11
+ node_modules/
12
+ .DS_Store
13
+ .env
14
+ .env.*
15
+ !data/dev.db
@@ -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,4 @@
1
+ """Allow running as python -m chatnut."""
2
+ from chatnut.cli import main
3
+
4
+ main()
@@ -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()
@@ -0,0 +1,5 @@
1
+ """Shared configuration constants."""
2
+
3
+ import os
4
+
5
+ DB_PATH = os.path.expanduser(os.environ.get("CHAT_DB_PATH", "~/.chatnut/chatnut.db"))