diff --git a/gcontext/fs.py b/gcontext/fs.py index 27b80d9..80e59ff 100644 --- a/gcontext/fs.py +++ b/gcontext/fs.py @@ -121,6 +121,33 @@ def _index_warning(root: Path, target: Path, content: str, existed: bool) -> str return "" +def _restart_note(root: Path, target: Path) -> str: + """Note text for files that only load at server start, or '' otherwise. + + agent.md is pushed in the MCP handshake and command files register as + prompts at startup; a write through this tool takes effect only after a + restart. Advisory only, same contract as _index_warning. + """ + parts = target.relative_to(root.resolve()).parts + if parts == ("agent.md",): + return ( + " Note: agent.md is pushed at connect; this change reaches clients " + "only after a restart (stop the server, gcontext up, reconnect the client)." + ) + if ( + len(parts) == 4 + and parts[0] in ("connections", "modules") + and parts[2] == "commands" + and target.suffix in (".md", ".py") + ): + return ( + " Note: commands are registered at server start; this command appears " + "(or updates) only after a restart (stop the server, gcontext up, " + "reconnect the client)." + ) + return "" + + DIFF_MAX_LINES = 200 @@ -163,6 +190,7 @@ def write_file(root: Path, path: str, content: str) -> str: return ( line + _index_warning(root, target, content, existed) + + _restart_note(root, target) + (_write_diff(path, before, content) if existed else "") ) diff --git a/gcontext/server.py b/gcontext/server.py index b8cac6c..b778eeb 100644 --- a/gcontext/server.py +++ b/gcontext/server.py @@ -51,6 +51,61 @@ def _tool_doc(name: str) -> str: # Live MCP sessions, keyed by session id: {"client": ..., "connected": ..., "last_seen": ...} SESSIONS: dict[str, dict] = {} +# Two file classes load only at server start: agent.md (pushed in the MCP +# handshake) and command files (registered as prompts). No watchers, per the +# no-background-behavior design: a startup snapshot of their mtimes, compared +# lazily on tool calls, with one stderr line per class per server lifetime. +STARTUP_SNAPSHOT: dict = {"agent_md": None, "commands": {}} +_STALE = {"agent_md": False, "commands": False} +_STALE_WARNED = {"agent_md": False, "commands": False} +_STALE_CHECK_INTERVAL = 5.0 +_last_stale_check = 0.0 + + +def _mtime(path: Path) -> float | None: + try: + return path.stat().st_mtime + except OSError: + return None + + +def snapshot_startup_files(): + """Record the state of the start-time-loaded files. Call once, after + load_instructions() and register_commands() have run.""" + STARTUP_SNAPSHOT["agent_md"] = _mtime(PROJECT_DIR / "agent.md") + STARTUP_SNAPSHOT["commands"] = { + str(p): _mtime(p) for p in commands_mod.discover(PROJECT_DIR) + } + _STALE.update(agent_md=False, commands=False) + _STALE_WARNED.update(agent_md=False, commands=False) + + +def check_staleness(force: bool = False) -> dict: + """Compare the current files against the startup snapshot. + + Once a class is stale it stays stale until restart, so the comparison for + it stops. Throttled to one filesystem check per few seconds unless forced. + """ + global _last_stale_check + now = time.monotonic() + if not force and now - _last_stale_check < _STALE_CHECK_INTERVAL: + return dict(_STALE) + _last_stale_check = now + if not _STALE["agent_md"]: + _STALE["agent_md"] = _mtime(PROJECT_DIR / "agent.md") != STARTUP_SNAPSHOT["agent_md"] + if not _STALE["commands"]: + current = {str(p): _mtime(p) for p in commands_mod.discover(PROJECT_DIR)} + _STALE["commands"] = current != STARTUP_SNAPSHOT["commands"] + if _STALE["agent_md"] and not _STALE_WARNED["agent_md"]: + _STALE_WARNED["agent_md"] = True + print(" ! agent.md changed since start; restart to push the new version " + "(stop, gcontext up, reconnect the client)", file=sys.stderr) + if _STALE["commands"] and not _STALE_WARNED["commands"]: + _STALE_WARNED["commands"] = True + print(" ! commands changed since start; restart to re-register them", + file=sys.stderr) + return dict(_STALE) + # Activity feed for the dashboard: in-memory ring buffer, gone on restart. EVENTS: deque = deque(maxlen=300) _EVENT_SEQ = itertools.count(1) @@ -125,6 +180,7 @@ class ConnectionTracker(Middleware): return await call_next(context) async def on_call_tool(self, context, call_next): + check_staleness() name = getattr(context.message, "name", "?") arguments = getattr(context.message, "arguments", None) or {} detail = _event_detail(name, arguments) @@ -166,18 +222,32 @@ class ConnectionTracker(Middleware): name=agent_name, mime_type="text/markdown", )) - for name in state.discover_modules(PROJECT_DIR): + modules = state.discover_modules(PROJECT_DIR) + if modules: result.append(Resource( - uri=f"agent://{agent_name}/modules/{name}", - name=f"modules / {name}", + uri=f"agent://{agent_name}/modules", + name="modules", mime_type="text/markdown", )) - for name in state.load_connections(PROJECT_DIR): + for name in modules: + result.append(Resource( + uri=f"agent://{agent_name}/modules/{name}", + name=f"modules / {name}", + mime_type="text/markdown", + )) + connections = state.load_connections(PROJECT_DIR) + if connections: result.append(Resource( - uri=f"agent://{agent_name}/connections/{name}", - name=f"connections / {name}", + uri=f"agent://{agent_name}/connections", + name="connections", mime_type="text/markdown", )) + for name in connections: + result.append(Resource( + uri=f"agent://{agent_name}/connections/{name}", + name=f"connections / {name}", + mime_type="text/markdown", + )) return result async def on_read_resource(self, context, call_next): @@ -210,6 +280,7 @@ async def status_route(request: Request) -> JSONResponse: "name": config.get("name", PROJECT_DIR.name), "project_dir": str(PROJECT_DIR.resolve()), "sessions": list(SESSIONS.values()), + "stale": check_staleness(force=True), }) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 3235a00..70c46da 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -83,6 +83,26 @@ def test_api_tree_excludes_machine_and_secret_files(client): assert not any(p.startswith(".venv") for p in paths) +def test_status_reports_staleness(client, project): + import os + + server.snapshot_startup_files() + stale = client.get("/status").json()["stale"] + assert stale == {"agent_md": False, "commands": False} + + agent_md = project / "agent.md" + os.utime(agent_md, (agent_md.stat().st_mtime + 10,) * 2) + stale = client.get("/status").json()["stale"] + assert stale["agent_md"] is True + assert stale["commands"] is False + + cmd = project / "modules" / "notes" / "commands" / "report.md" + cmd.parent.mkdir(parents=True) + cmd.write_text("---\ndescription: d\n---\nbody\n") + stale = client.get("/status").json()["stale"] + assert stale["commands"] is True + + def test_api_events_limit_since_and_ring_cap(client): for i in range(350): server.record_event("s", "tool", f"tool{i}") diff --git a/tests/test_server.py b/tests/test_server.py index 4c21e35..e4243af 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -98,6 +98,32 @@ def test_new_file_without_parent_index_does_not_warn(project): assert "Warning" not in out +def test_agent_md_write_notes_restart(project): + out = server.write_file("agent.md", "# Agent\nbe useful\n") + assert "Note: agent.md is pushed at connect" in out + assert "restart" in out + assert "Warning" not in out # agent.md stays exempt from the index check + + +def test_command_write_notes_restart(project): + out = server.write_file( + "connections/gmail/commands/send.md", "---\ndescription: d\n---\nbody\n" + ) + assert "Note: commands are registered at server start" in out + out = server.write_file( + "modules/notes/commands/report.py", "# ---\n# description: d\n# ---\n" + ) + assert "Note: commands are registered at server start" in out + # Not a command file: wrong folder or wrong extension. + assert "Note:" not in server.write_file("modules/notes/scripts/report.py", "x") + assert "Note:" not in server.write_file("modules/notes/commands/notes.txt", "x") + + +def test_ordinary_write_has_no_restart_note(project): + out = server.write_file("modules/notes/index.md", "summary") + assert "Note:" not in out + + def test_write_new_file_reports_size_and_lines(project): out = server.write_file("modules/notes/note.md", "one\ntwo\n") assert out.startswith("Created: modules/notes/note.md")