diff --git a/.gitignore b/.gitignore index d639c7f..c1f872a 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,4 @@ secrets.env # dashboard build artifacts web/node_modules/ -src/gcontext/web_dist/ +gcontext/web_dist/ diff --git a/README.md b/README.md index 9ae3367..aea4a9e 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ claude mcp add --transport http my-agent http://127.0.0.1:4242/mcp ``` my-agent/ gcontext.yaml # name, description, optional port - instructions.md # pushed to every agent at connect: what it starts with + agent.md # your agent's definition, pushed to every agent at connect secrets.env # secret values, gitignored connections/ # services the agent can use @@ -44,11 +44,13 @@ my-agent/ archive/ # excluded from scanning, still readable ``` -Markdown holds the context, YAML holds the config. Edit any of it with a text editor; the server reads the files on demand, so changes apply immediately. Two exceptions load at server start and need a restart to pick up edits: `instructions.md` (pushed in the MCP handshake) and command files. +Markdown holds the context, YAML holds the config. Edit any of it with a text editor; the server reads the files on demand, so changes apply immediately. Two exceptions load at server start and need a restart to pick up edits: `agent.md` (pushed in the MCP handshake) and command files. -Connected clients get five tools: `read_file`, `write_file`, `list_dir`, `grep`, `run_script`. +At connect, every agent receives two layers of instructions through the handshake: first gcontext's own fixed instructions (shipped with the package, they explain the tools and the folder conventions), then your `agent.md` (what this particular agent is). You only ever write the second layer. -`run_script` runs either ad-hoc code or a saved script by path (`scripts/` folders hold proven procedures, so they are reused instead of rewritten). Files under `connections/*/commands/` and `modules/*/commands/` register as MCP prompts, which Claude Code shows as slash commands; see "Commands" below. +Connected clients get six tools: `read_file`, `write_file`, `list_dir`, `grep`, `run_script`, `run_adhoc_script`. Every state file is also exposed as an MCP resource at `gcontext://` (a folder URI returns its listing), so runtimes that support resource mentions can attach a file directly, e.g. `@my-agent:gcontext://modules/topic/index.md`. The dashboard's copy buttons copy exactly these references. + +`run_script` runs a saved script by path (`scripts/` folders hold proven procedures, so they are reused instead of rewritten); `run_adhoc_script` runs ad-hoc code, which keeps a script call short and readable in the runtime's tool display. Both return readable text: a status line (exit code, duration, timed out / truncated flags), then stdout and stderr. Files under `connections/*/commands/` and `modules/*/commands/` register as MCP prompts, which Claude Code shows as slash commands; see "Commands" below. ## Your first connection @@ -77,7 +79,7 @@ echo 'STRIPE_API_KEY=sk_test_...' >> my-agent/secrets.env And write `connections/stripe/index.md`: what the service is for, which endpoints matter, any usage patterns worth remembering. The agent reads this before writing scripts, and updates it as it learns. -That's it. The server picks the connection up on the next tool call (no restart), `gcontext status` shows whether every declared secret has a value, and the agent can now call the API through `run_script` without ever seeing the key. +That's it. The server picks the connection up on the next tool call (no restart), `gcontext status` shows whether every declared secret has a value, and the agent can now call the API through `run_adhoc_script` and `run_script` without ever seeing the key. ## Context ledger @@ -93,13 +95,13 @@ claude --mcp-config '{"mcpServers":{"gcontext":{"type":"http","url":"http://127. --setting-sources "" ``` -`--strict-mcp-config` ignores every other configured MCP server, and `--setting-sources ""` skips CLAUDE.md files and user settings. Your `instructions.md` still arrives through the MCP handshake, like in any session. Adjust the URL to your project's port. +`--strict-mcp-config` ignores every other configured MCP server, and `--setting-sources ""` skips CLAUDE.md files and user settings. Your `agent.md` still arrives through the MCP handshake, like in any session. Adjust the URL to your project's port. ## Secrets -`connection.yaml` declares secret names; `secrets.env` holds the values. When the agent calls `run_script`, the values are injected as environment variables and scrubbed from the script's output. The agent can know that `STRIPE_API_KEY` exists and use it in a script, but never reads the value. `secrets.env` is gitignored by `init` and the `write_file` tool refuses to touch it. +`connection.yaml` declares secret names; `secrets.env` holds the values. When the agent calls `run_script` or `run_adhoc_script`, the values are injected as environment variables and scrubbed from the script's output. The agent can know that `STRIPE_API_KEY` exists and use it in a script, but never reads the value. `secrets.env` is gitignored by `init` and the `write_file` tool refuses to touch it. -`run_script` executes Python in a per-project venv with each connection's declared deps preinstalled (via uv). +Both tools execute Python in a per-project venv with each connection's declared deps preinstalled (via uv). ## Archiving diff --git a/docs/design.md b/docs/design.md index a5dba7f..f78f02b 100644 --- a/docs/design.md +++ b/docs/design.md @@ -8,7 +8,7 @@ Claude Code, Codex, Cursor: these are runtimes. They run the loop, stream tokens gcontext never competes with runtimes, it feeds them. Anything that looks like a message loop, a streaming handler, or a session manager belongs to the runtime. Runtimes are a competitive, fast-moving space owned by large companies; state is not. Runtimes are interchangeable; the state is not. -This principle removed two features in sequence. An early version shipped a ~230 line custom chat REPL wrapping `claude -p`; deleted, because a homemade REPL is a runtime. Its replacement, `gcontext chat`, was a launcher that execed the real `claude` with lockdown flags; also deleted, once the handshake started delivering `instructions.md` to every client and the launcher's only remaining job was passing claude-specific flags gcontext has no business owning. What survives is a documented claude invocation in the README ("Controlled session") for anyone who wants those pipes closed. Each step moved the same direction: gcontext feeds runtimes and launches none. +This principle removed two features in sequence. An early version shipped a ~230 line custom chat REPL wrapping `claude -p`; deleted, because a homemade REPL is a runtime. Its replacement, `gcontext chat`, was a launcher that execed the real `claude` with lockdown flags; also deleted, once the handshake started delivering the instructions to every client and the launcher's only remaining job was passing claude-specific flags gcontext has no business owning. What survives is a documented claude invocation in the README ("Controlled session") for anyone who wants those pipes closed. Each step moved the same direction: gcontext feeds runtimes and launches none. ## A folder is the agent's state @@ -56,9 +56,9 @@ The accepted tradeoff: something must be running. Every pipe that inserts context into the agent is enumerated in one ledger, computed live from the folder so it cannot go stale. Each pipe is marked `loaded` (pushed at connect), `on demand` (agent pulls it via a visible tool call), `skipped` (nothing to push), or `uncontrolled` (runtime-owned, outside gcontext's view). The ledger appears in `gcontext context`, after `connect`, and in the dashboard. -The one thing gcontext pushes at connect is `instructions.md`, through the MCP handshake's `instructions` field, declared as ledger pipe G0. This is the design's answer to "what does the agent receive when it attaches": one file, in the folder, versioned with git, and nothing else. Edit it and you have edited what every future session starts with. +What gcontext pushes at connect, through the MCP handshake's `instructions` field, is two layers with two owners: the framework's own instructions (`prompts/framework-instructions.md` inside the package, ledger pipe G0) followed by the project's `agent.md` (ledger pipe G1). The framework layer explains gcontext itself: the tools, connections, modules, scripts, archive. It ships with the package, so users cannot edit it and it never goes stale in old projects. The project layer is the agent's definition, one file in the folder, versioned with git. Edit it and you have edited what every future session starts with. -This position was reached in two steps. The handshake push was first rejected outright, on the argument that content arriving through a side channel is invisible in the conversation. Living with the alternative showed the real cost: the agent started blind, had to be told to read its own instructions, and a runtime that never asked never saw them. The rejection was aimed at the wrong target. The problem was never pushing at connect; it was pushing without declaring. So the invariant is: everything pushed is declared in the ledger, and everything declared is a file you control. +This position was reached in three steps. The handshake push was first rejected outright, on the argument that content arriving through a side channel is invisible in the conversation. Living with the alternative showed the real cost: the agent started blind, had to be told to read its own instructions, and a runtime that never asked never saw them. The rejection was aimed at the wrong target. The problem was never pushing at connect; it was pushing without declaring. So the invariant is: everything pushed is declared in the ledger, and everything declared is enumerable. The third step separated the owners. Originally `init` seeded each project's instructions file with the framework mechanics, which mixed two voices in one user-editable file and froze framework text at whatever version `init` ran; splitting the layers keeps the user file pure agent definition (the seed is a three-line placeholder) and the framework text current with the installed package. The ledger is also honest about its limits. When a runtime keeps pipes gcontext cannot close (its own system prompt, its config files, other MCP servers), the ledger marks them UNCONTROLLED instead of pretending the session is cleaner than it is. @@ -72,9 +72,13 @@ So visibility is a function of file location. Move a folder into `archive/` and Two features share one idea: when the agent produces something that works, keep it as a file next to the knowledge it belongs to, and reuse it instead of regenerating it. -A **saved script** is a proven procedure under a `scripts/` folder, run by path through `run_script` (with `args` and named `params` that arrive as `PARAM_` env vars). Writing it is an ordinary `write_file` call, visible like every other state change. +A **saved script** is a proven procedure under a `scripts/` folder, run by path through `run_script` (with `args` and named `params` that arrive as `PARAM_` env vars). Ad-hoc source goes through the separate `run_adhoc_script` tool; the split keeps a script invocation short and readable in the runtime's tool display, where inline source renders as an escaped blob. Both tools return readable text: a status line carrying the structured facts (exit code, duration, timed out / truncated), then stdout, stderr, and a hint on a missing import. Internally the execution layer produces a dict; the server renders it to text and deliberately does not declare it as MCP structured content, because runtimes that receive structured content display the JSON instead of the text block, and script output then renders with escaped newlines. Writing a script is an ordinary `write_file` call, visible like every other state change. -A **command** is a user-invokable entry point under a `commands/` folder, registered as an MCP prompt named `__` and surfaced by Claude Code as a slash command. Commands are prompts, not tools, on purpose: a tool's schema is pushed into context at connect time for every session, while a prompt is only listed, and its text enters the conversation exactly when the user invokes it. That keeps the tool list at five and honors the no-invisible-push rule: the injection is user-triggered and the ledger lists commands as their own pipe. +A **write** is always user-approved and auditable. The framework instructions require the agent to present every `write_file` call before making it (target path, one-line reason, the content or the changed lines), through the runtime's interactive question tool when it has one, otherwise as a plain-text approval frame. Updating an existing file returns a unified diff of the change (capped at 200 lines) so the write is auditable in the transcript afterwards; creating a file returns its size and line count. The approval lives in the instructions, not in the server: gcontext has no interaction channel of its own, so the server's contribution is the diff, and the asking is agent behavior. + +A **command** is a user-invokable entry point under a `commands/` folder, registered as an MCP prompt named `__` and surfaced by Claude Code as a slash command. Commands are prompts, not tools, on purpose: a tool's schema is pushed into context at connect time for every session, while a prompt is only listed, and its text enters the conversation exactly when the user invokes it. That keeps the tool list at six and honors the no-invisible-push rule: the injection is user-triggered and the ledger lists commands as their own pipe. + +One command is framework-owned: `setup`, shipped in the package's `prompts/` and registered in every instance. Setup is a prompt rather than a CLI command because its work is a conversation, not a procedure: the agent inspects the state, asks the user what they want (a new connection, a new module, a health check), and does the work through the ordinary tools, where every write lands in the event feed. The CLI keeps only what must exist before an agent is connected: `init`, `up`, `connect`. ## Deferred, deliberately diff --git a/docs/modules.md b/docs/modules.md index 0108177..afafdd9 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -46,6 +46,18 @@ modules/company/ There is no enforced schema beyond `index.md`. Different modules have different structures depending on what they do. When `index.md` gets long, split it into more files and link them from `index.md`. +## How a module grows + +Nothing is enforced in code; these are the conventions the framework instructions push to every connected agent. Retrieval in gcontext is `list_dir` and `grep`, no index and no search, so the tree itself is the index. The rules keep it navigable: + +- One topic per module. When a second topic appears, it is a second module, not a subfolder. +- A folder's `index.md` is its map: what the folder holds, plus one line per child file or subfolder. A reader (human or agent) should know where to go after reading only the `index.md`. +- Stay flat until several files share a clear sub-topic. Then make one subfolder per sub-topic (`playbooks/`, `logs/`, `scripts/`), and give it its own `index.md` if it holds more than a handful of files. Never create folders for dates or counts; `logs/2026/08/` hides content that one append-only file with a stated format holds better. +- Soft limits, not caps: keep a single listing under a couple dozen entries, and nesting within about three levels below `modules/`. Passing them is a signal to reorganize, not an error. +- Split a file when it stops being readable in one pass, not before. Many small fragments cost more round-trips than one coherent file. + +The soft limits deliberately replace harder rules from an earlier design (a fixed maximum of files per level): agents follow numeric caps literally and produce premature subfolders. Judgment plus a self-describing `index.md` scales further. + ## How someone uses a module 1. Download the module folder (or copy it) diff --git a/examples/ops-agent/instructions.md b/examples/ops-agent/agent.md similarity index 100% rename from examples/ops-agent/instructions.md rename to examples/ops-agent/agent.md diff --git a/gcontext/__init__.py b/gcontext/__init__.py new file mode 100644 index 0000000..83e040c --- /dev/null +++ b/gcontext/__init__.py @@ -0,0 +1,6 @@ +from importlib.metadata import PackageNotFoundError, version + +try: + __version__ = version("gcontext-ai") +except PackageNotFoundError: # running from a checkout without an install + __version__ = "unknown" diff --git a/src/gcontext/cli.py b/gcontext/cli.py similarity index 91% rename from src/gcontext/cli.py rename to gcontext/cli.py index ec94482..aef2af4 100644 --- a/src/gcontext/cli.py +++ b/gcontext/cli.py @@ -8,6 +8,7 @@ import urllib.error import urllib.request from pathlib import Path +from . import __version__ from . import exec as exec_mod from . import ledger as ledger_mod from . import secrets as secrets_mod @@ -45,20 +46,12 @@ description: Describe what this agent is for. """ INIT_INSTRUCTIONS = """\ -# Instructions +# Agent -You are the agent for this gcontext project. Your state lives in this folder: -read it with read_file, keep it current with write_file, find things with -list_dir and grep. - -- Start with list_dir(".") to see connections and modules, and read the - index.md of whatever you are about to use. -- Use run_script for anything that needs an API: secrets are injected as env - vars (you only ever see their names), deps are preinstalled. -- When a script proves itself, save it with write_file under a scripts/ - folder and run it by path from then on, instead of rewriting it. -- Record what you learn: update the relevant index.md or module so the next - session starts smarter than this one. +Describe what this agent is for and how it should behave. This file is yours; +gcontext pushes it to every runtime that connects, right after its own fixed +framework instructions (which already cover the tools, connections, and +modules). """ INIT_SECRETS = """\ @@ -81,7 +74,7 @@ def cmd_init(args): name = target.name files = { "gcontext.yaml": INIT_GCONTEXT_YAML.format(name=name), - "instructions.md": INIT_INSTRUCTIONS, + "agent.md": INIT_INSTRUCTIONS, "secrets.env": INIT_SECRETS, ".gitignore": INIT_AGENT_GITIGNORE, "connections/.gitkeep": "", @@ -201,10 +194,11 @@ def cmd_up(args): url = server_url(port) exec_mod.ensure_venv(project_dir) + n_framework_prompts = server.register_framework_prompts() n_commands = server.register_commands() - n_instruction_lines = server.load_instructions() + n_base_lines, n_instruction_lines = server.load_instructions() - print(f"{BOLD}gcontext{RESET} {DIM}-{RESET} {name}") + print(f"{BOLD}gcontext{RESET} {DIM}{__version__} -{RESET} {name}") print(f"{DIM}State: {project_dir}{RESET}") print() print(f"Serving at {BOLD}{url}{RESET}") @@ -218,11 +212,13 @@ def cmd_up(args): print(" Details: gcontext connect") print() if n_instruction_lines: - print(f"Instructions: instructions.md ({n_instruction_lines} lines) is pushed to every agent at connect.") + print(f"Instructions: framework ({n_base_lines} lines) + agent.md ({n_instruction_lines} lines), pushed to every agent at connect.") else: - print(f"{YELLOW}Instructions: no instructions.md, agents receive nothing at connect.{RESET}") + print(f"{YELLOW}Instructions: no agent.md, agents receive only the framework instructions ({n_base_lines} lines) at connect.{RESET}") + prompt_bits = [f"{n_framework_prompts} built-in (setup)"] if n_commands: - print(f"Commands: {n_commands} registered as MCP prompts (slash commands in Claude Code).") + prompt_bits.append(f"{n_commands} project command(s)") + print(f"Prompts: {' + '.join(prompt_bits)} as MCP prompts (slash commands in Claude Code).") print() print("Connections appear below as harnesses attach. Ctrl+C stops the server,") print("and every harness cleanly loses access.") @@ -266,10 +262,10 @@ def cmd_status(args): print(f" {GREEN}{s['client']}{RESET} {DIM}{s['version']}{RESET} connected {s['connected']} last activity {s['last_seen']}") print() - instructions = project_dir / "instructions.md" + instructions = project_dir / "agent.md" if instructions.exists(): lines = len(instructions.read_text().splitlines()) - print(f"Instructions: instructions.md ({lines} lines)") + print(f"Instructions: agent.md ({lines} lines)") print() print("Connections:") @@ -375,6 +371,7 @@ def main(): prog="gcontext", description="Agent state in a folder, served at a URL. Bring your own runtime.", ) + parser.add_argument("--version", action="version", version=f"gcontext {__version__}") subparsers = parser.add_subparsers(dest="command") init_parser = subparsers.add_parser("init", help="Scaffold a new agent state folder") diff --git a/src/gcontext/commands.py b/gcontext/commands.py similarity index 85% rename from src/gcontext/commands.py rename to gcontext/commands.py index 4b2bb2e..7731959 100644 --- a/src/gcontext/commands.py +++ b/gcontext/commands.py @@ -123,6 +123,30 @@ def discover(root: Path) -> list[Path]: ) +def register_framework_prompts(mcp) -> int: + """Register the framework's own prompts, shipped in the package. + + Same file format as project commands, but framework-owned: they update + with the package and exist in every instance. Currently one: `setup`, + the guided add-a-connection / add-a-module / health-check flow. + """ + from fastmcp.prompts.prompt import Prompt + + prompts_dir = Path(__file__).parent / "prompts" + count = 0 + for path in sorted(prompts_dir.glob("*.md")): + if path.stem in ("framework-instructions", "resources", "README"): + continue + meta, body = parse_command(path.read_text(encoding="utf-8")) + fn = _render_fn(body, meta.get("parameters") or []) + fn.__name__ = path.stem + mcp.add_prompt( + Prompt.from_function(fn, name=path.stem, description=meta.get("description", "")) + ) + count += 1 + return count + + def register_commands(mcp, root: Path) -> int: """Scan connection and module `commands/` folders and register each file as a prompt named `__`.""" diff --git a/src/gcontext/dashboard.py b/gcontext/dashboard.py similarity index 98% rename from src/gcontext/dashboard.py rename to gcontext/dashboard.py index e41b306..a7b207c 100644 --- a/src/gcontext/dashboard.py +++ b/gcontext/dashboard.py @@ -46,7 +46,7 @@ def _version() -> str: async def api_project(request: Request) -> JSONResponse: root = _root() config = state.load_gcontext_yaml(root) - instructions = root / "instructions.md" + instructions = root / "agent.md" return JSONResponse({ "name": config.get("name", root.name), "description": config.get("description", ""), @@ -200,7 +200,7 @@ async def api_events(request: Request) -> JSONResponse: _DIST_CANDIDATES = [ Path(__file__).parent / "web_dist", - Path(__file__).parents[2] / "web" / "dist", + Path(__file__).parents[1] / "web" / "dist", ] diff --git a/gcontext/exec.py b/gcontext/exec.py new file mode 100644 index 0000000..74de998 --- /dev/null +++ b/gcontext/exec.py @@ -0,0 +1,176 @@ +"""Script execution: saved scripts by path (run_script) and ad-hoc agent +code (run_adhoc_script), in the project venv. + +The venv lives at /.venv and syncs the deps declared across all +connection.yaml files on every run (uv makes the satisfied case near-instant). +Secrets are injected as env vars and scrubbed from the output. Both paths +share _run, so cwd, env, timeout, capping and scrubbing behave identically. +Results are structured dicts (stdout, stderr, exit_code, timed_out, +truncated, duration_ms, plus hint on a missing import); argument problems +raise ValueError, which the MCP layer surfaces as a tool error. +""" + +import os +import re +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +from . import secrets as secrets_mod +from . import state + +SCRIPT_TIMEOUT = 60 +MAX_OUTPUT = 100_000 # chars per stream; beyond this the stream is capped + + +def venv_dir(root: Path) -> Path: + return root.resolve() / ".venv" + + +def venv_python(root: Path) -> Path: + venv = venv_dir(root) + if sys.platform == "win32": + return venv / "Scripts" / "python.exe" + return venv / "bin" / "python" + + +def collect_deps(root: Path) -> set[str]: + all_deps = set() + for conn in state.load_connections(root).values(): + all_deps.update(conn.deps) + return all_deps + + +def ensure_venv(root: Path) -> None: + """Create the project venv if missing and sync connection deps into it.""" + if not venv_dir(root).is_dir(): + subprocess.run( + ["uv", "venv", str(venv_dir(root)), "--quiet"], + check=True, + cwd=str(root), + ) + + all_deps = collect_deps(root) + if all_deps: + subprocess.run( + ["uv", "pip", "install", "--quiet", "--python", str(venv_python(root))] + + sorted(all_deps), + check=True, + cwd=str(root), + ) + + +_MISSING_MODULE_RE = re.compile( + r"ModuleNotFoundError: No module named ['\"]([^'\"]+)['\"]" +) + + +def missing_module_hint(root: Path, stderr: str) -> str | None: + """A hint shown only when a run fails on a missing import.""" + match = _MISSING_MODULE_RE.search(stderr) + if not match: + return None + module = match.group(1).split(".")[0] + declared = sorted(collect_deps(root)) + declared_line = f" Currently declared: {', '.join(declared)}." if declared else "" + return ( + f"Package '{module}' is not installed in the project venv. Declare it " + f"under deps: in the relevant connection.yaml (ask the user, that file " + f"is human-edited), then rerun: the venv syncs on the next call." + f"{declared_line} Note the pip name can differ from the import name." + ) + + +def _cap(text: str) -> tuple[str, bool]: + if len(text) <= MAX_OUTPUT: + return text, False + dropped = len(text) - MAX_OUTPUT + return text[:MAX_OUTPUT] + f"\n[truncated, {dropped} more chars]", True + + +def _run( + root: Path, + script_path: str, + args: list[str] | None, + params: dict[str, str] | None, +) -> dict: + secrets = secrets_mod.load(root) + ensure_venv(root) + + env = os.environ.copy() + env.update(secrets) + for k, v in (params or {}).items(): + env[f"PARAM_{k.upper()}"] = str(v) + + start = time.perf_counter() + try: + proc = subprocess.run( + [str(venv_python(root)), script_path, *(args or [])], + capture_output=True, + text=True, + timeout=SCRIPT_TIMEOUT, + env=env, + cwd=str(root), + ) + stdout, stderr = proc.stdout, proc.stderr + exit_code, timed_out = proc.returncode, False + except subprocess.TimeoutExpired as exc: + stdout = exc.stdout or "" + stderr = (exc.stderr or "") + f"\n[timed out after {SCRIPT_TIMEOUT}s]" + exit_code, timed_out = -1, True + duration_ms = round((time.perf_counter() - start) * 1000) + + if isinstance(stdout, bytes): + stdout = stdout.decode(errors="replace") + if isinstance(stderr, bytes): + stderr = stderr.decode(errors="replace") + out, out_truncated = _cap(secrets_mod.scrub(stdout, secrets)) + err, err_truncated = _cap(secrets_mod.scrub(stderr, secrets)) + + result = { + "stdout": out, + "stderr": err, + "exit_code": exit_code, + "timed_out": timed_out, + "truncated": out_truncated or err_truncated, + "duration_ms": duration_ms, + } + hint = missing_module_hint(root, err) + if hint: + result["hint"] = hint + return result + + +def run_script( + root: Path, + path: str, + args: list[str] | None = None, + params: dict[str, str] | None = None, +) -> dict: + if not path: + raise ValueError("path is required") + target = (root / path).resolve() + if not target.is_relative_to(root.resolve()): + raise ValueError(f"path {path} is outside the project directory") + if not target.is_file(): + raise ValueError(f"{path} is not a file") + return _run(root, str(target), args, params) + + +def run_adhoc_script( + root: Path, + code: str, + params: dict[str, str] | None = None, +) -> dict: + if not code: + raise ValueError("code is required") + with tempfile.NamedTemporaryFile( + mode="w", suffix=".py", delete=False, dir=root + ) as f: + f.write(code) + try: + return _run(root, f.name, None, params) + finally: + Path(f.name).unlink(missing_ok=True) diff --git a/src/gcontext/fs.py b/gcontext/fs.py similarity index 54% rename from src/gcontext/fs.py rename to gcontext/fs.py index 4b56f23..27b80d9 100644 --- a/src/gcontext/fs.py +++ b/gcontext/fs.py @@ -1,11 +1,12 @@ """File access for the read_file, write_file, list_dir and grep tools. Every path is resolved and confined to the project root; secrets.env is -unreadable and unwritable, connection.yaml is unwritable (the secret grant -stays human-edited). Errors come back as strings because tool results are -strings the agent reads. +unreadable and unwritable (secret values never enter the context window). +Errors come back as strings because tool results are strings the agent +reads. """ +import difflib import fnmatch import re from pathlib import Path @@ -47,6 +48,26 @@ def resolve_browser_path(root: Path, path: str) -> tuple[Path | None, str | None return target, None +def walk_files(root: Path) -> list[str]: + """Relative paths of every listable state file, sorted. + + Same visibility as the scanning surface: machine folders and secrets.env + never appear, archive/ is not scanned (still readable by path). + """ + resolved = root.resolve() + out = [] + for f in sorted(resolved.rglob("*")): + if not f.is_file(): + continue + parts = f.relative_to(resolved).parts + if (SKIP_DIRS | {"archive"}) & set(parts): + continue + if f.name == "secrets.env": + continue + out.append("/".join(parts)) + return out + + def read_file(root: Path, path: str) -> str: target, error = resolve_path(root, path) if error: @@ -58,16 +79,92 @@ def read_file(root: Path, path: str) -> str: return target.read_text() +def _index_siblings(folder: Path) -> list[str]: + """Names an index.md in this folder must reference: every visible sibling. + + Machine folders, secrets.env and archive/ (retired state, not part of the + map) are exempt. Directory names come without the trailing slash so a + plain-name mention in the index counts. + """ + names = [] + for entry in sorted(folder.iterdir(), key=lambda e: e.name): + if entry.name in SKIP_DIRS | {"index.md", "secrets.env", "archive"}: + continue + names.append(entry.name) + return names + + +def _index_warning(root: Path, target: Path, content: str, existed: bool) -> str: + """Warning text for the index.md map convention, or '' when the write is fine. + + Writing an index.md: warn about siblings the content never mentions. + Creating any other file: warn when the parent's index.md does not mention it. + Advisory only, the write itself always goes through. + """ + if target.name == "index.md": + missing = [n for n in _index_siblings(target.parent) if n not in content] + if missing: + return ( + f" Warning: this index.md does not reference: {', '.join(missing)}. " + "An index.md must link every sibling with one line." + ) + return "" + if existed or target.name == "agent.md": + return "" + index = target.parent / "index.md" + if index.is_file() and target.name not in index.read_text(): + rel = "/".join(index.relative_to(root.resolve()).parts) + return ( + f" Warning: {rel} does not mention {target.name}. " + "Add a one-line link for it there." + ) + return "" + + +DIFF_MAX_LINES = 200 + + +def _write_diff(path: str, before: str, after: str) -> str: + """Unified diff of a write, capped at DIFF_MAX_LINES, '' when identical.""" + lines = list( + difflib.unified_diff( + before.splitlines(keepends=True), + after.splitlines(keepends=True), + fromfile=f"a/{path}", + tofile=f"b/{path}", + ) + ) + if not lines: + return "" + if len(lines) > DIFF_MAX_LINES: + lines = lines[:DIFF_MAX_LINES] + [f"... diff truncated at {DIFF_MAX_LINES} lines\n"] + diff = "".join(lines) + if not diff.endswith("\n"): + diff += "\n" + return "\n" + diff + + def write_file(root: Path, path: str, content: str) -> str: target, error = resolve_path(root, path) if error: return f"Error: {error}." - if target.name == "connection.yaml": - return "Error: cannot write to connection.yaml through the agent." - + existed = target.exists() + before = "" + if existed and target.is_file(): + before = target.read_text(errors="replace") target.parent.mkdir(parents=True, exist_ok=True) target.write_text(content) - return f"Written: {path} ({len(content)} bytes)" + if existed: + line = f"Updated: {path} ({len(content)} bytes)." + if before == content: + line = f"Unchanged: {path} (content identical)." + else: + line = f"Created: {path} ({len(content)} bytes, {len(content.splitlines())} lines)." + return ( + line + + _index_warning(root, target, content, existed) + + (_write_diff(path, before, content) if existed else "") + ) def list_dir(root: Path, path: str = ".") -> str: diff --git a/src/gcontext/ledger.py b/gcontext/ledger.py similarity index 58% rename from src/gcontext/ledger.py rename to gcontext/ledger.py index e981172..a7ed2b7 100644 --- a/src/gcontext/ledger.py +++ b/gcontext/ledger.py @@ -7,11 +7,12 @@ tool call), skipped (nothing to push), uncontrolled (runtime-owned). from pathlib import Path from . import commands as commands_mod +from . import fs from . import state def build(root: Path) -> list[dict]: - instructions = root / "instructions.md" + instructions = root / "agent.md" connections = state.load_connections(root) modules = state.discover_modules(root) n_files = sum(len(state.connection_files(root, c)) for c in connections) @@ -19,21 +20,27 @@ def build(root: Path) -> list[dict]: ledger = [] + base = Path(__file__).parent / "prompts" / "framework-instructions.md" + n_base = len(base.read_text().splitlines()) + ledger.append({"id": "G0", "label": "framework instructions", "detail": f"framework-owned, pushed at connect in the MCP handshake ({n_base} lines)", "status": "loaded"}) + if instructions.exists(): n = len(instructions.read_text().splitlines()) - ledger.append({"id": "G0", "label": "instructions.md", "detail": f"pushed at connect in the MCP handshake ({n} lines)", "status": "loaded"}) + ledger.append({"id": "G1", "label": "agent.md", "detail": f"pushed at connect in the MCP handshake ({n} lines)", "status": "loaded"}) else: - ledger.append({"id": "G0", "label": "instructions.md", "detail": "file missing, nothing pushed at connect", "status": "skipped"}) + ledger.append({"id": "G1", "label": "agent.md", "detail": "file missing, only the framework instructions pushed at connect", "status": "skipped"}) - ledger.append({"id": "G1", "label": "tool descriptions", "detail": "5 gcontext tools, pushed at connect", "status": "loaded"}) + ledger.append({"id": "G2", "label": "tool descriptions", "detail": "6 gcontext tools, pushed at connect", "status": "loaded"}) g3_detail = f"{n_files} files in connections/ + modules/" if state.archived(root): g3_detail += "; archive/ not scanned, readable by path" ledger.append({"id": "G3", "label": "read_file()", "detail": g3_detail, "status": "on demand"}) ledger.append({"id": "G4", "label": "list_dir() / grep()", "detail": "tree navigation and search, matches only", "status": "on demand"}) - ledger.append({"id": "G5", "label": "run_script() output", "detail": "secret values scrubbed", "status": "on demand"}) + ledger.append({"id": "G5", "label": "run_script() / run_adhoc_script() output", "detail": "secret values scrubbed", "status": "on demand"}) n_commands = len(commands_mod.discover(root)) - ledger.append({"id": "G6", "label": "commands", "detail": f"{n_commands} command(s) as MCP prompts; a command's text enters context only when the user invokes it", "status": "on demand"}) + ledger.append({"id": "G6", "label": "commands", "detail": f"1 built-in (setup) + {n_commands} project command(s) as MCP prompts; a command's text enters context only when the user invokes it", "status": "on demand"}) + n_resources = len(fs.walk_files(root)) + ledger.append({"id": "G7", "label": "resources", "detail": f"{n_resources} state files as MCP resources (gcontext://); one enters context only when attached", "status": "on demand"}) ledger.append({"id": "R1", "label": "runtime system prompt", "detail": "runtime-owned", "status": "uncontrolled"}) ledger.append({"id": "R2", "label": "user/project CLAUDE.md", "detail": "runtime-owned", "status": "uncontrolled"}) diff --git a/src/gcontext/models.py b/gcontext/models.py similarity index 100% rename from src/gcontext/models.py rename to gcontext/models.py diff --git a/gcontext/prompts/README.md b/gcontext/prompts/README.md new file mode 100644 index 0000000..e16acf5 --- /dev/null +++ b/gcontext/prompts/README.md @@ -0,0 +1,33 @@ +# prompts/ + +Everything gcontext itself says to an attached agent lives in this folder, +as markdown, not in Python strings. + +- `framework-instructions.md`: the framework's own instructions, always pushed first in + the MCP handshake (ledger pipe G0). What gcontext is, the tools, and how + connections/modules/scripts/archive work. Framework-owned: users cannot + edit it, and it updates with the package, so it never goes stale in old + projects. +- `tools/*.md`: one file per tool. These are the tool descriptions pushed to + every client at connect time (ledger pipe G2). Edit a file, restart the + server, and every session sees the new text. +- `resources.md`: the description of the `gcontext://` resource + template, which exposes every state file as an MCP resource (ledger pipe + G7). +- `setup.md`: the built-in `setup` prompt, registered as an MCP prompt in + every instance (part of ledger pipe G6, `/mcp____setup` in Claude + Code). Same frontmatter format as project commands, but framework-owned + and shipped with the package. It guides the agent through adding a + connection, adding a module, or health-checking the state, conversationally + and through the normal tools. Its text enters context only when invoked. + +The agent's own definition is NOT here: it is the served project's +`agent.md`, appended after the framework instructions in the same +handshake and declared as ledger pipe G1. That file belongs to the agent +folder (versioned with its state) and holds only the user's voice; `init` +seeds it with a three-line placeholder. + +History: earlier versions deliberately had no server-side instructions file +and seeded all mechanics into each project's agent file. That mixed two +owners in one file and let framework text go stale per project, so the split +above replaced it (2026-08-01). diff --git a/gcontext/prompts/framework-instructions.md b/gcontext/prompts/framework-instructions.md new file mode 100644 index 0000000..1ee8385 --- /dev/null +++ b/gcontext/prompts/framework-instructions.md @@ -0,0 +1,74 @@ +# gcontext + +The folder this server exposes is your state. Everything you know and learn +lives there as plain files; the runtime you run in forgets between sessions, +the folder does not. + +Your tools are read_file, write_file, list_dir, grep, run_script, and +run_adhoc_script. All paths are relative to the state folder; nothing +outside it is reachable. +Every state file is also an MCP resource at gcontext://, so runtimes +can attach one directly instead of calling read_file. + +How the folder is organized: + +- connections//: a service you can use. Its connection.yaml declares + the secret NAMEs and Python deps it needs; its index.md explains the API in + practice. Read the index.md before writing a script against a service, and + update it when you learn something worth keeping. +- modules//: accumulated knowledge on a topic, entry point index.md. +- scripts/ folders (inside connections and modules): proven procedures. Run + them by path with run_script instead of rewriting them, and save a script + there once it has proven itself. +- archive/: retired state. Not scanned or listed, still readable by path. + +How state grows: one topic per module. A folder's index.md is its map and +only its map. Fixed format: a summary of at most two or three sentences, +then one line per sibling file or subfolder, naming it exactly and saying +in one line what it holds. Every sibling must appear; content beyond the +summary belongs in the sibling files, never in the index. write_file warns +when an index.md misses a sibling or a new file is missing from its +folder's index.md; fix the index in the same session. Stay flat +until several files share a clear sub-topic, then make one subfolder per +sub-topic (playbooks/, logs/), never folders for dates or counts. Keep a +listing under a couple dozen entries and nesting within about three levels; +split a file when it stops being readable in one pass, not before. + +run_script runs a saved script by path; run_adhoc_script runs ad-hoc +source. Both execute Python with the declared deps preinstalled and secret +values injected as environment variables: you see secret names, never +values, and values are scrubbed from all output. Explore with +run_adhoc_script; keep what works as a script and call it with run_script. + +Every write needs the user's approval. Before any write_file call, show +three things and wait for agreement: the target path, one line on why this +write, and the exact content or the changed lines. This holds for every +write, also when the write is your own idea (recording a lesson, updating +an index). If the runtime has an interactive question tool, use it; +otherwise show this exact frame in plain text and wait for a yes: + ++==============================================+ +| >> APPROVAL NEEDED : UPDATE FILE << | ++==============================================+ +Target : +Reason : +---------------------------------------------- + +---------------------------------------------- +Write this? (yes / no) + +Use the header ">> APPROVAL NEEDED : CREATE FILE <<" when the file does not +exist yet. One approval can cover several files when they belong to one +change; list every path. Do not call write_file before the user has +approved, even when the runtime would allow the call. + +Learn from errors. When a call fails and you then make it work, record the +lesson before you move on: what failed, why, and the form that works. A +service-level lesson (an auth trap, an API gotcha) goes in that connection's +index.md; a process lesson goes in the module's files. Do not wait for the +end of the session; the moment the fix works is the moment to write it down, +so the next session does not pay for the same error twice. + +Start a session with list_dir(".") to see what state exists. Record what you +learn in the relevant index.md so the next session starts smarter than this +one. diff --git a/gcontext/prompts/resources.md b/gcontext/prompts/resources.md new file mode 100644 index 0000000..5515b9c --- /dev/null +++ b/gcontext/prompts/resources.md @@ -0,0 +1 @@ +A state file, addressed directly. `gcontext://` returns the file's content (a folder URI returns its listing), same confinement as read_file: paths stay inside the project, secrets.env is unreachable. Runtimes that support MCP resources can attach one without a tool call, e.g. `@:gcontext://modules//index.md`. diff --git a/gcontext/prompts/setup.md b/gcontext/prompts/setup.md new file mode 100644 index 0000000..de2af56 --- /dev/null +++ b/gcontext/prompts/setup.md @@ -0,0 +1,159 @@ +--- +description: Guided setup - describe what the agent should do, and build the state for it +parameters: + - name: request + description: What you want, in your words (e.g. "an agent for our support team"). Leave empty to be asked. + required: false +--- +You are running gcontext setup for this agent. Setup is a conversation: the +user describes what they want in their own words, you translate that into +state (connections and modules), and you build and verify it through the +gcontext tools. + +The user's request, possibly empty: "$request" + +## Ground rules + +- The user does not need to know gcontext's concepts. Never ask "do you want + a connection or a module?". They describe goals; YOU decide what each goal + needs and explain your plan in one plain line per item. +- Inspect before you ask. Never ask the user something the state folder can + answer. Start with list_dir(".") and read what you need from there. +- When your runtime has an interactive question tool (AskUserQuestion in + Claude Code), use it for every choice point in this procedure: fixed + options render as a picker and the user can still type a free answer. + Without such a tool, ask in plain text. Open-ended questions ("what should + this agent do?") stay plain text either way. +- Never ask for secret VALUES. Secret values go into secrets.env, which the + user edits themselves outside this conversation. You only ever handle secret + NAMEs. If the user pastes a secret value into the chat, tell them not to, + and tell them to rotate it if the chat leaves their machine. +- Verify at the end. A connection is done when a smoke test passes, a module + is done when its index.md reads back correctly, never before. + +## Step 1: Inspect + +Call list_dir(".") and list_dir on connections/ and modules/ if they exist. +Note what is already there: which connections, which modules, whether +agent.md is still the init placeholder ("Describe what this agent is +for..."). This is your map, not the user's briefing. + +## Step 2: Understand what the user wants + +If "$request" already describes it, work from that. Otherwise ask, in plain +text and adapted to what you found: + +- Fresh instance (nothing there yet): "What should this agent do for you? + Describe it like you would to a new hire: what it should know, which + services and tools it should be able to use, what you want to ask of it." +- Instance with existing state: say in one or two lines what the agent + already has (in plain words, not folder names) and ask what they want to + add or change. If something looks broken or half-finished, mention it and + offer to fix it as part of the work. + +Let them answer in one messy paragraph. That is the expected input, not a +special case. Ask at most one or two follow-ups if the answer leaves you +unable to plan; do not interrogate. + +## Step 3: Propose the plan + +Translate the description into a plan. The mapping is yours to make: + +- A service the agent must reach (Stripe, Slack, GitHub, an internal API): + a connection each. +- Knowledge the agent must hold (how the company works, a product, a process, + a team's rules): a module each. Prefer a few broad modules over many thin + ones; a module can grow files later. +- If agent.md is still the placeholder, writing it from the user's + description is always part of the plan. + +Show the plan as a short list, one plain line per item ("stripe: so the agent +can look up payments and refunds"), and confirm it as a choice question: +build all of it, or let the user deselect items (multi-select when the tool +supports it). First-time setups with many items are the normal case; do not +talk the user out of a big plan, but order it so something useful exists +early. + +## Step 4: Build, one item at a time + +Work through the confirmed plan. Finish each item before starting the next, +and say briefly where you are ("2 of 5"). Suggested order: agent.md +first, then modules (they only need conversation), then connections (each +needs the user to place secrets). + +**Add a connection:** + +1. If anything is unclear, ask what they mainly want to do with the service; + it shapes the index.md and the smoke test. Decide the auth model and + secret NAMEs (e.g. SLACK_BOT_TOKEN) and the Python deps (e.g. requests). + Prefer plain HTTPS via requests over heavy SDKs unless the user wants the + SDK. When the service has more than one auth model (token vs OAuth app, + cloud vs self-hosted), present the options as a choice question. +2. If a connection with that name already exists, stop and ask: extend it or + leave it alone. Never overwrite silently. +3. Write connections//connection.yaml: + + name: + description: + secrets: + - + deps: + - + +4. Write connections//index.md: what the service is used for here, + base URL, auth style (header name, token type), the endpoints that matter + for the user's stated goal, and known quirks. Write what a fresh session + needs to use the API, not marketing. +5. Tell the user to add the secret VALUES to secrets.env in the agent folder, + one NAME=value per line, and to say "done" when they have. When the plan + has several connections, offer to list all needed NAMEs at once so they + can fill secrets.env in one sitting. +6. Smoke test with run_adhoc_script: first check the secrets are injected + (os.environ.get("") is set; print present/missing, never the + value), then make one harmless authenticated call (whoami, list, or + similar). If it fails, read the error, fix connection.yaml or the script, + and retry. Common causes: missing value in secrets.env (the server reads + it live, no restart needed), wrong header format, wrong base URL. +7. Once the call works, save it as the first proven script under + connections//scripts/ and record in index.md anything the test + taught you (rate limits, response shapes, error formats). + +**Add a module:** + +1. From the user's description (plus at most one follow-up), agree on a short + kebab-case folder name. If the module already exists, extend its index.md + instead. +2. Write modules//index.md: what the module covers, what belongs in it, + and any starting knowledge from this conversation. Seed real content the + user gave you, not empty headings. +3. If the module will hold an append-only log (decisions, incidents), create + that file too, with its format stated at the top. +4. Read the index.md back and confirm with the user it says what they meant. + +**Health check** (when the user asks for it, or Step 1 found problems): + +Work through these, report findings, and offer the fixes as a choice +question (multi-select when the tool supports it): + +- Connections without an index.md, or with a connection.yaml that does not + parse (name missing, bad YAML). +- Declared secret NAMEs that are not set: check via run_adhoc_script with + os.environ.get(name), print present/missing only. +- Modules without an index.md, or with an index.md that is empty. +- agent.md still the init placeholder: offer to write it from the + user's description. +- Stale index.md claims: if an index.md documents scripts that do not exist, + or scripts exist that no index.md mentions, flag the drift. + +Fixes go through write_file, run_adhoc_script and run_script like any other work. +Anything that +requires deleting or moving files is out of your reach: name the paths and +tell the user to do it by hand. + +## Step 5: Close + +Update the relevant index.md files with what this setup added or changed, so +the next session starts smarter. Then summarize for the user in plain words: +what the agent can now do, what was skipped or is still pending (e.g. secrets +never provided), and one example of something they can ask the agent right +now. diff --git a/src/gcontext/prompts/tools/grep.md b/gcontext/prompts/tools/grep.md similarity index 100% rename from src/gcontext/prompts/tools/grep.md rename to gcontext/prompts/tools/grep.md diff --git a/src/gcontext/prompts/tools/list_dir.md b/gcontext/prompts/tools/list_dir.md similarity index 100% rename from src/gcontext/prompts/tools/list_dir.md rename to gcontext/prompts/tools/list_dir.md diff --git a/src/gcontext/prompts/tools/read_file.md b/gcontext/prompts/tools/read_file.md similarity index 100% rename from src/gcontext/prompts/tools/read_file.md rename to gcontext/prompts/tools/read_file.md diff --git a/gcontext/prompts/tools/run_adhoc_script.md b/gcontext/prompts/tools/run_adhoc_script.md new file mode 100644 index 0000000..ffe7d73 --- /dev/null +++ b/gcontext/prompts/tools/run_adhoc_script.md @@ -0,0 +1,16 @@ +Run ad-hoc Python source in the project's .venv with secrets as env vars. +Use this for one-off exploration and smoke tests. When the code has proven +itself, save it with write_file under a scripts/ folder and run it with +run_script by path instead of pasting it again. + +The .venv has all connection deps pre-installed. Access secrets with +os.environ["SECRET_NAME"]; secret values are scrubbed from the output. + +The result starts with a status line `[exit N | M ms]` (plus `timed out` or +`truncated` when they apply), then stdout, then `[stderr]` when present, and +`[hint]` when a required package is missing. + +Args: + code: Python source code to execute. + params: Optional named parameters; each becomes a PARAM_ env var + (e.g. {"email": "x@y.z"} -> PARAM_EMAIL). diff --git a/gcontext/prompts/tools/run_script.md b/gcontext/prompts/tools/run_script.md new file mode 100644 index 0000000..6ea75a9 --- /dev/null +++ b/gcontext/prompts/tools/run_script.md @@ -0,0 +1,18 @@ +Run a saved Python script from the project by path, in the project's .venv +with secrets as env vars (e.g. 'connections/stripe/scripts/refund.py'). +For ad-hoc code use run_adhoc_script instead; once code has proven itself, save it +with write_file under a scripts/ folder and run it here by path so it is +reused instead of rewritten. + +The .venv has all connection deps pre-installed. Scripts access secrets with +os.environ["SECRET_NAME"]; secret values are scrubbed from the output. + +The result starts with a status line `[exit N | M ms]` (plus `timed out` or +`truncated` when they apply), then stdout, then `[stderr]` when present, and +`[hint]` when a required package is missing. + +Args: + path: Project-relative path of a saved .py script to run. + args: Optional argv list passed to the script. + params: Optional named parameters; each becomes a PARAM_ env var + (e.g. {"email": "x@y.z"} -> PARAM_EMAIL). diff --git a/gcontext/prompts/tools/write_file.md b/gcontext/prompts/tools/write_file.md new file mode 100644 index 0000000..1ae9a2f --- /dev/null +++ b/gcontext/prompts/tools/write_file.md @@ -0,0 +1,16 @@ +Write or update a file in the project. Creates parent directories if needed. + +Use this to update connection context docs, create playbooks, write logs, etc. +Cannot write to secrets.env: secret values never leave the user's machine. + +Updating an existing file returns a unified diff of the change (capped at +200 lines), so every write is auditable in the transcript. Creating a file +returns its size and line count. + +The result can carry a warning: an index.md that does not reference every +sibling, or a new file the folder's index.md does not mention. The write +still happens; update the index right away so the map stays complete. + +Args: + path: Relative path within the project (e.g. 'modules/support-workflow/playbooks/refund.md') + content: The full file content to write. diff --git a/src/gcontext/secrets.py b/gcontext/secrets.py similarity index 81% rename from src/gcontext/secrets.py rename to gcontext/secrets.py index 9b23c7b..d9d14bc 100644 --- a/src/gcontext/secrets.py +++ b/gcontext/secrets.py @@ -18,7 +18,10 @@ def load(root: Path) -> dict[str, str]: continue if "=" in line: key, _, value = line.partition("=") - pairs[key.strip()] = value.strip() + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": + value = value[1:-1] + pairs[key.strip()] = value return pairs diff --git a/src/gcontext/server.py b/gcontext/server.py similarity index 59% rename from src/gcontext/server.py rename to gcontext/server.py index 75cf3cd..1e2e408 100644 --- a/src/gcontext/server.py +++ b/gcontext/server.py @@ -1,11 +1,12 @@ """The MCP surface: everything an attached agent can reach, in one file. -Five tools (defined below, their agent-facing text in prompts/tools/*.md), -commands registered as prompts, a /status route, and session tracking. +Six tools (defined below, their agent-facing text in prompts/tools/*.md), +state files as MCP resources (gcontext://, listed live), commands +registered as prompts, a /status route, and session tracking. The actual work lives in the per-concern modules: fs.py read_file / write_file / list_dir / grep (path confinement, guards) - exec.py run_script (venv, secrets injection, output scrubbing) + exec.py run_script / run_adhoc_script (venv, secrets injection, output scrubbing) state.py connections / modules / archive scanning secrets.py secrets.env parsing and output scrubbing ledger.py the context ledger @@ -23,6 +24,7 @@ from datetime import datetime from pathlib import Path from fastmcp import FastMCP +from fastmcp.resources import Resource from fastmcp.server.middleware import Middleware from starlette.requests import Request from starlette.responses import JSONResponse @@ -89,8 +91,8 @@ def _event_detail(name: str, arguments: dict) -> str: path = arguments.get("path") or "." return f"{pattern!r} in {path}" if name == "run_script": - if arguments.get("path"): - return str(arguments["path"]) + return str(arguments.get("path", "?")) + if name == "run_adhoc_script": return f"inline code ({len(arguments.get('code') or '')} chars)" if arguments.get("path"): return str(arguments["path"]) @@ -151,6 +153,23 @@ class ConnectionTracker(Middleware): tier=2) return await call_next(context) + async def on_list_resources(self, context, call_next): + """The resource list is the state folder, scanned live: every listable + file at gcontext://, so runtimes can offer them for attachment.""" + result = await call_next(context) + for rel in fs.walk_files(PROJECT_DIR): + mime = "text/markdown" if rel.endswith(".md") else "text/plain" + result.append(Resource(uri=f"gcontext://{rel}", name=rel, mime_type=mime)) + return result + + async def on_read_resource(self, context, call_next): + uri = str(getattr(context.message, "uri", "?")) + start = time.perf_counter() + result = await call_next(context) + record_event(_session_id(context), "resource", "resource", detail=uri, + duration_ms=round((time.perf_counter() - start) * 1000)) + return result + async def on_message(self, context, call_next): session = SESSIONS.get(_session_id(context)) if session: @@ -176,51 +195,107 @@ def register_commands() -> int: return commands_mod.register_commands(mcp, PROJECT_DIR) -def load_instructions() -> int: - """Serve the project's instructions.md in the MCP handshake. +def register_framework_prompts() -> int: + """Register the package's own prompts (setup). Call once at startup.""" + return commands_mod.register_framework_prompts(mcp) - This is THE file pushed to every agent at connect: what it says is exactly - what the agent starts with, the ledger declares it as G0, and editing the - file (plus a restart) changes what every future session receives. Returns - the line count, 0 if the file does not exist (nothing is pushed then). + +def load_instructions() -> tuple[int, int]: + """Serve instructions in the MCP handshake: gcontext's own, then the project's. + + Two files, two owners. prompts/framework-instructions.md ships with the + framework (what gcontext is, how the tools and the folder work; ledger + pipe G0) and is always pushed. The project's agent.md defines the + particular agent (ledger pipe G1) and is appended when it exists. Editing + the project file (plus a restart) changes what every future session + receives. Returns (base_lines, project_lines); project_lines is 0 when + the file is missing. """ - instructions = PROJECT_DIR / "instructions.md" + base = (_PROMPTS_DIR / "framework-instructions.md").read_text() + instructions = PROJECT_DIR / "agent.md" if not instructions.exists(): - mcp.instructions = None - return 0 + mcp.instructions = base + return len(base.splitlines()), 0 text = instructions.read_text() - mcp.instructions = text - return len(text.splitlines()) + mcp.instructions = f"{base}\n{text}" + return len(base.splitlines()), len(text.splitlines()) -@mcp.tool(description=_tool_doc("read_file")) +@mcp.resource("gcontext://{path*}", + description=(_PROMPTS_DIR / "resources.md").read_text().strip()) +def state_resource(path: str) -> str: + rel = path.rstrip("/") + target, error = fs.resolve_path(PROJECT_DIR, rel) + if error: + return f"Error: {error}." + if target.is_dir(): + return fs.list_dir(PROJECT_DIR, rel or ".") + return fs.read_file(PROJECT_DIR, rel) + + +# output_schema=None on every tool: with a schema, FastMCP wraps the string +# result as structured content {"result": ...} and runtimes like Claude Code +# display that JSON (newlines escaped) instead of the readable text block. +@mcp.tool(description=_tool_doc("read_file"), output_schema=None) def read_file(path: str) -> str: return fs.read_file(PROJECT_DIR, path) -@mcp.tool(description=_tool_doc("write_file")) +@mcp.tool(description=_tool_doc("write_file"), output_schema=None) def write_file(path: str, content: str) -> str: return fs.write_file(PROJECT_DIR, path, content) -@mcp.tool(description=_tool_doc("list_dir")) +@mcp.tool(description=_tool_doc("list_dir"), output_schema=None) def list_dir(path: str = ".") -> str: return fs.list_dir(PROJECT_DIR, path) -@mcp.tool(description=_tool_doc("grep")) +@mcp.tool(description=_tool_doc("grep"), output_schema=None) def grep(pattern: str, path: str = ".", glob: str = "") -> str: return fs.grep(PROJECT_DIR, pattern, path=path, glob=glob) -@mcp.tool(description=_tool_doc("run_script")) +def _exec_result(result: dict) -> str: + """Render an exec dict as readable text: status line, stdout, stderr, hint. + + Text only, no structured content: when a tool declares structured content, + Claude Code displays that JSON instead of the text block, and stdout + renders with escaped newlines. The status line keeps the structured facts + (exit code, duration, timed out / truncated). + """ + status = f"exit {result['exit_code']} | {result['duration_ms']} ms" + if result["timed_out"]: + status += " | timed out" + if result["truncated"]: + status += " | truncated" + parts = [f"[{status}]"] + if result["stdout"].strip(): + parts.append(result["stdout"].rstrip()) + if result["stderr"].strip(): + parts.append(f"[stderr]\n{result['stderr'].rstrip()}") + if not result["stdout"].strip() and not result["stderr"].strip(): + parts.append("(no output)") + if result.get("hint"): + parts.append(f"[hint] {result['hint']}") + return "\n".join(parts) + + +@mcp.tool(description=_tool_doc("run_script"), output_schema=None) def run_script( - code: str = "", - path: str = "", + path: str, args: list[str] | None = None, params: dict[str, str] | None = None, ) -> str: - return exec_mod.run(PROJECT_DIR, code=code, path=path, args=args, params=params) + return _exec_result(exec_mod.run_script(PROJECT_DIR, path, args=args, params=params)) + + +@mcp.tool(description=_tool_doc("run_adhoc_script"), output_schema=None) +def run_adhoc_script( + code: str, + params: dict[str, str] | None = None, +) -> str: + return _exec_result(exec_mod.run_adhoc_script(PROJECT_DIR, code, params=params)) diff --git a/src/gcontext/state.py b/gcontext/state.py similarity index 100% rename from src/gcontext/state.py rename to gcontext/state.py diff --git a/pyproject.toml b/pyproject.toml index d8981db..508d251 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["src/gcontext"] +packages = ["gcontext"] # The built dashboard rides inside the wheel; hatchling fails the build if # web/dist is missing, so `make build` (vite first) is the only build path. @@ -40,4 +40,4 @@ artifacts = ["web/dist"] "web/dist" = "gcontext/web_dist" [tool.hatch.build.targets.sdist] -only-include = ["src", "web/dist", "tests", "README.md", "LICENSE"] +only-include = ["gcontext", "web/dist", "tests", "README.md", "LICENSE"] diff --git a/src/gcontext/__init__.py b/src/gcontext/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/gcontext/exec.py b/src/gcontext/exec.py deleted file mode 100644 index 90897b4..0000000 --- a/src/gcontext/exec.py +++ /dev/null @@ -1,147 +0,0 @@ -"""Script execution: ad-hoc agent code and saved scripts, in the project venv. - -The venv lives at /.venv and syncs the deps declared across all -connection.yaml files on every run (uv makes the satisfied case near-instant). -Secrets are injected as env vars and scrubbed from the output; results are -plain text starting with a status line the agent and the user can both read. -""" - -import os -import re -import subprocess -import sys -import tempfile -import time -from pathlib import Path - -from . import secrets as secrets_mod -from . import state - -SCRIPT_TIMEOUT = 60 - - -def venv_dir(root: Path) -> Path: - return root.resolve() / ".venv" - - -def venv_python(root: Path) -> Path: - venv = venv_dir(root) - if sys.platform == "win32": - return venv / "Scripts" / "python.exe" - return venv / "bin" / "python" - - -def collect_deps(root: Path) -> set[str]: - all_deps = set() - for conn in state.load_connections(root).values(): - all_deps.update(conn.deps) - return all_deps - - -def ensure_venv(root: Path) -> None: - """Create the project venv if missing and sync connection deps into it.""" - if not venv_dir(root).is_dir(): - subprocess.run( - ["uv", "venv", str(venv_dir(root)), "--quiet"], - check=True, - cwd=str(root), - ) - - all_deps = collect_deps(root) - if all_deps: - subprocess.run( - ["uv", "pip", "install", "--quiet", "--python", str(venv_python(root))] - + sorted(all_deps), - check=True, - cwd=str(root), - ) - - -_MISSING_MODULE_RE = re.compile( - r"ModuleNotFoundError: No module named ['\"]([^'\"]+)['\"]" -) - - -def missing_module_hint(root: Path, stderr: str) -> str | None: - """A hint shown only when a run fails on a missing import.""" - match = _MISSING_MODULE_RE.search(stderr) - if not match: - return None - module = match.group(1).split(".")[0] - declared = sorted(collect_deps(root)) - declared_line = f" Currently declared: {', '.join(declared)}." if declared else "" - return ( - f"Package '{module}' is not installed in the project venv. Declare it " - f"under deps: in the relevant connection.yaml (ask the user, that file " - f"is human-edited), then rerun: the venv syncs on the next call." - f"{declared_line} Note the pip name can differ from the import name." - ) - - -def run( - root: Path, - code: str = "", - path: str = "", - args: list[str] | None = None, - params: dict[str, str] | None = None, -) -> str: - if bool(code) == bool(path): - return "Error: pass exactly one of code or path." - - secrets = secrets_mod.load(root) - - if path: - target = (root / path).resolve() - if not target.is_relative_to(root.resolve()): - return f"Error: path {path} is outside the project directory." - if not target.is_file(): - return f"Error: {path} is not a file." - script_path = str(target) - cleanup = False - label = path - else: - with tempfile.NamedTemporaryFile( - mode="w", suffix=".py", delete=False, dir=root - ) as f: - f.write(code) - script_path = f.name - cleanup = True - label = "code" - - ensure_venv(root) - - try: - env = os.environ.copy() - env.update(secrets) - for k, v in (params or {}).items(): - env[f"PARAM_{k.upper()}"] = str(v) - - start = time.perf_counter() - result = subprocess.run( - [str(venv_python(root)), script_path, *(args or [])], - capture_output=True, - text=True, - timeout=SCRIPT_TIMEOUT, - env=env, - cwd=str(root), - ) - duration_ms = round((time.perf_counter() - start) * 1000) - - output_parts = [f"[{label} | exit {result.returncode} | {duration_ms} ms]"] - if result.stdout.strip(): - output_parts.append(result.stdout.strip()) - if result.stderr.strip(): - output_parts.append(f"[stderr]\n{result.stderr.strip()}") - if not result.stdout.strip() and not result.stderr.strip(): - output_parts.append("(no output)") - hint = missing_module_hint(root, result.stderr) - if hint: - output_parts.append(f"[hint] {hint}") - - return secrets_mod.scrub("\n".join(output_parts), secrets) - - except subprocess.TimeoutExpired: - return f"Error: script timed out after {SCRIPT_TIMEOUT} seconds." - finally: - if cleanup: - Path(script_path).unlink(missing_ok=True) diff --git a/src/gcontext/prompts/README.md b/src/gcontext/prompts/README.md deleted file mode 100644 index 0fb32dd..0000000 --- a/src/gcontext/prompts/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# prompts/ - -Everything gcontext itself says to an attached agent lives in this folder, -as markdown, not in Python strings. - -- `tools/*.md`: one file per tool. These are the tool descriptions pushed to - every client at connect time (ledger pipe G1). Edit a file, restart the - server, and every session sees the new text. - -The instructions an agent receives at connect are NOT here: they are the -served project's own `instructions.md`, pushed through the MCP handshake and -declared as ledger pipe G0. That file belongs to the agent folder (versioned -with its state), not to the framework; this folder only holds the fixed -framework text. diff --git a/src/gcontext/prompts/tools/run_script.md b/src/gcontext/prompts/tools/run_script.md deleted file mode 100644 index d64ed47..0000000 --- a/src/gcontext/prompts/tools/run_script.md +++ /dev/null @@ -1,19 +0,0 @@ -Run Python in the project's .venv with secrets as env vars. - -Two modes, pass exactly one of `code` or `path`: -- code: ad-hoc Python source, written to a temp file and executed. -- path: a saved script inside the project (e.g. 'connections/stripe/scripts/refund.py'). - Save proven procedures with write_file under a scripts/ folder, then run - them by path so they are reused instead of rewritten. - -The .venv has all connection deps pre-installed. Access secrets with -os.environ["SECRET_NAME"]. Secret values are scrubbed from stdout/stderr -before returning. The result starts with a status line: mode, exit code, -duration. - -Args: - code: Python source code to execute (ad-hoc mode). - path: Project-relative path of a saved .py script to run. - args: Optional argv list passed to the script. - params: Optional named parameters; each becomes a PARAM_ env var - (e.g. {"email": "x@y.z"} -> PARAM_EMAIL). diff --git a/src/gcontext/prompts/tools/write_file.md b/src/gcontext/prompts/tools/write_file.md deleted file mode 100644 index b947b2f..0000000 --- a/src/gcontext/prompts/tools/write_file.md +++ /dev/null @@ -1,8 +0,0 @@ -Write or update a file in the project. Creates parent directories if needed. - -Use this to update connection context docs, create playbooks, write logs, etc. -Cannot write to secrets.env or connection.yaml files. - -Args: - path: Relative path within the project (e.g. 'modules/support-workflow/playbooks/refund.md') - content: The full file content to write. diff --git a/tests/test_commands.py b/tests/test_commands.py index e3071ce..0a8c721 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -108,4 +108,26 @@ def test_prompt_rejects_missing_required_argument(tmp_path): def test_commands_ledger_pipe(project): _write_commands(project) g6 = [p for p in ledger.build(project) if p["id"] == "G6"] - assert g6 and "2 command(s)" in g6[0]["detail"] + assert g6 and "1 built-in (setup) + 2 project command(s)" in g6[0]["detail"] + + +def test_register_framework_prompts_setup(): + mcp = FastMCP("t") + assert commands.register_framework_prompts(mcp) == 1 + + async def go(): + async with Client(mcp) as c: + listed = await c.list_prompts() + empty = await c.get_prompt("setup", {}) + filled = await c.get_prompt("setup", {"request": "add a slack connection"}) + return listed, empty, filled + + listed, empty, filled = asyncio.run(go()) + setup = next(p for p in listed if p.name == "setup") + assert setup.description + empty_text = empty.messages[0].content.text + assert '""' in empty_text and "$request" not in empty_text + filled_text = filled.messages[0].content.text + assert "add a slack connection" in filled_text + for step in ("Add a connection", "Add a module", "Health check", "Propose the plan"): + assert step in filled_text diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 4a7f3cc..3235a00 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -10,7 +10,7 @@ from gcontext import dashboard, server @pytest.fixture def project(tmp_path, monkeypatch): (tmp_path / "gcontext.yaml").write_text("name: t\ndescription: test agent\n") - (tmp_path / "instructions.md").write_text("# Instructions\nbe useful\n") + (tmp_path / "agent.md").write_text("# Agent\nbe useful\n") (tmp_path / "secrets.env").write_text("API_KEY=sk-verysecret\nEMPTY=\n") conn = tmp_path / "connections" / "gmail" conn.mkdir(parents=True) diff --git a/tests/test_init.py b/tests/test_init.py index c88579a..0f4d097 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -16,7 +16,7 @@ def test_init_scaffolds_agent(tmp_path): agent = tmp_path / "my-agent" for rel in [ "gcontext.yaml", - "instructions.md", + "agent.md", "secrets.env", ".gitignore", ]: @@ -39,7 +39,7 @@ def test_scaffolded_agent_works_with_cli(tmp_path): run_cli("init", "a", cwd=tmp_path) result = run_cli("context", "a", cwd=tmp_path) assert result.returncode == 0, result.stderr - assert "instructions.md" in result.stdout + assert "agent.md" in result.stdout assert "commands" in result.stdout diff --git a/tests/test_server.py b/tests/test_server.py index b4a6167..4badd82 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1,7 +1,7 @@ import pytest from gcontext import ledger, server, state -from gcontext.secrets import scrub +from gcontext.secrets import load, scrub @pytest.fixture @@ -19,6 +19,17 @@ def test_scrub_output(): assert "ab kept" in out # values of length <= 3 are not scrubbed +def test_load_strips_surrounding_quotes(tmp_path): + (tmp_path / "secrets.env").write_text( + 'A=plain\nB="double quoted"\nC=\'single quoted\'\nD="unbalanced\n' + ) + pairs = load(tmp_path) + assert pairs["A"] == "plain" + assert pairs["B"] == "double quoted" + assert pairs["C"] == "single quoted" + assert pairs["D"] == '"unbalanced' + + def test_read_file_blocks_traversal(project): assert "outside the project" in server.read_file("../gcontext.yaml") assert "outside the project" in server.read_file("/etc/hosts") @@ -34,7 +45,6 @@ def test_read_file_refuses_secrets_env(project): def test_write_file_blocks_traversal_and_protected_files(project): assert "outside the project" in server.write_file("../x.md", "hi") assert "Error" in server.write_file("secrets.env", "STOLEN=1") - assert "Error" in server.write_file("connections/a/connection.yaml", "nope") def test_write_then_read_roundtrip(project): @@ -42,6 +52,86 @@ def test_write_then_read_roundtrip(project): assert server.read_file("modules/notes/index.md") == "hello" +def test_index_write_warns_about_unreferenced_siblings(project): + notes = project / "modules" / "notes" + notes.mkdir(parents=True) + (notes / "decisions.md").write_text("log") + (notes / "playbooks").mkdir() + out = server.write_file("modules/notes/index.md", "summary only") + assert "Warning" in out + assert "decisions.md" in out + assert "playbooks" in out + out = server.write_file( + "modules/notes/index.md", + "Summary.\n- [decisions.md](decisions.md): log\n- playbooks/: procedures\n", + ) + assert "Warning" not in out + + +def test_index_check_ignores_machine_and_exempt_entries(project): + notes = project / "modules" / "notes" + (notes / "archive").mkdir(parents=True) + (notes / "__pycache__").mkdir() + (notes / "secrets.env").write_text("X=1") + out = server.write_file("modules/notes/index.md", "nothing linked") + assert "Warning" not in out + + +def test_new_file_warns_when_parent_index_misses_it(project): + notes = project / "modules" / "notes" + notes.mkdir(parents=True) + (notes / "index.md").write_text("Summary, no links.") + out = server.write_file("modules/notes/decisions.md", "log") + assert "Warning" in out + assert "modules/notes/index.md" in out + # Overwriting an existing file does not re-warn. + out = server.write_file("modules/notes/decisions.md", "log v2") + assert "Warning" not in out + # A file the index already mentions is fine. + (notes / "index.md").write_text("Summary.\n- notes.md: things\n") + out = server.write_file("modules/notes/notes.md", "things") + assert "Warning" not in out + + +def test_new_file_without_parent_index_does_not_warn(project): + out = server.write_file("modules/fresh/first.md", "hi") + assert "Warning" 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") + assert "2 lines" in out + assert "---" not in out # no diff for a new file + + +def test_write_update_returns_unified_diff(project): + server.write_file("modules/notes/note.md", "one\ntwo\n") + out = server.write_file("modules/notes/note.md", "one\nthree\n") + assert out.startswith("Updated: modules/notes/note.md") + assert "-two" in out + assert "+three" in out + assert "a/modules/notes/note.md" in out + + +def test_write_identical_content_reports_unchanged(project): + server.write_file("modules/notes/note.md", "same\n") + out = server.write_file("modules/notes/note.md", "same\n") + assert out.startswith("Unchanged: modules/notes/note.md") + assert "+same" not in out + + +def test_write_diff_is_capped(project): + from gcontext import fs + + before = "\n".join(f"line {i}" for i in range(400)) + "\n" + after = "\n".join(f"LINE {i}" for i in range(400)) + "\n" + server.write_file("modules/notes/big.md", before) + out = server.write_file("modules/notes/big.md", after) + assert f"diff truncated at {fs.DIFF_MAX_LINES} lines" in out + assert len(out.splitlines()) <= fs.DIFF_MAX_LINES + 5 + + def test_list_dir_lists_entries_and_blocks_traversal(project): (project / "modules" / "notes").mkdir(parents=True) (project / "modules" / "notes" / "index.md").write_text("x") @@ -107,15 +197,31 @@ def test_archive_readable_by_path(project): assert server.read_file("archive/note.md") == "kept" -def test_run_script_requires_exactly_one_mode(project): - assert "exactly one" in server.run_script() - assert "exactly one" in server.run_script(code="print(1)", path="x.py") +def test_run_adhoc_script_returns_readable_text(project): + out = server.run_adhoc_script(code="print('hi')") + assert out.startswith("[exit 0 | ") + assert out.endswith(" ms]\nhi") -def test_run_script_code_mode_header(project): - out = server.run_script(code="print('hi')") - assert out.splitlines()[0].startswith("[code | exit 0 | ") - assert "hi" in out +def test_exec_dict_has_all_fields(project): + from gcontext import exec as exec_mod + + out = exec_mod.run_adhoc_script(project, "print('hi')") + assert out["stdout"] == "hi\n" + assert out["stderr"] == "" + assert out["exit_code"] == 0 + assert out["timed_out"] is False + assert out["truncated"] is False + assert out["duration_ms"] >= 0 + assert "hint" not in out + + +def test_run_adhoc_script_params(project): + out = server.run_adhoc_script( + code="import os\nprint(os.environ['PARAM_EMAIL'])", params={"email": "x@y.z"} + ) + assert out.startswith("[exit 0 | ") + assert "x@y.z" in out def test_run_script_path_mode_with_args_and_params(project): @@ -127,28 +233,60 @@ def test_run_script_path_mode_with_args_and_params(project): out = server.run_script( path="modules/m/scripts/s.py", args=["a1"], params={"email": "x@y.z"} ) - assert out.splitlines()[0].startswith("[modules/m/scripts/s.py | exit 0 | ") + assert out.startswith("[exit 0 | ") assert "a1 x@y.z" in out -def test_run_script_path_blocks_traversal(project): - assert "outside the project" in server.run_script(path="../evil.py") +def test_run_script_rejects_bad_paths(project): + with pytest.raises(ValueError, match="outside the project"): + server.run_script(path="../evil.py") + with pytest.raises(ValueError, match="not a file"): + server.run_script(path="missing.py") -def test_run_script_missing_module_hint(project): +def test_run_adhoc_script_missing_module_hint(project): (project / "connections" / "c").mkdir(parents=True) (project / "connections" / "c" / "connection.yaml").write_text( "name: c\ndeps: [pyyaml]\n" ) - out = server.run_script(code="import definitely_not_a_module") + out = server.run_adhoc_script(code="import definitely_not_a_module") + assert not out.startswith("[exit 0 | ") assert "[hint]" in out assert "definitely_not_a_module" in out assert "connection.yaml" in out +def test_run_adhoc_script_scrubs_secrets(project): + (project / "secrets.env").write_text("API_KEY=sk-verysecret\n") + out = server.run_adhoc_script(code="import os\nprint(os.environ['API_KEY'])") + assert "sk-verysecret" not in out + assert "***" in out + + +def test_run_adhoc_script_truncates_long_output(project, monkeypatch): + from gcontext import exec as exec_mod + + monkeypatch.setattr(exec_mod, "MAX_OUTPUT", 50) + out = server.run_adhoc_script(code="print('x' * 200)") + assert "| truncated]" in out + assert "[truncated," in out + + +def test_run_adhoc_script_timeout(project, monkeypatch): + from gcontext import exec as exec_mod + + monkeypatch.setattr(exec_mod, "SCRIPT_TIMEOUT", 1) + out = server.run_adhoc_script(code="import time\ntime.sleep(5)") + assert "| timed out]" in out + assert out.startswith("[exit -1 | ") + assert "timed out after 1s" in out + + def test_instructions_pushed_in_handshake(project): - (project / "instructions.md").write_text("line one\nline two\n") - assert server.load_instructions() == 2 + (project / "agent.md").write_text("line one\nline two\n") + n_base, n_project = server.load_instructions() + assert n_base > 0 + assert n_project == 2 import asyncio @@ -158,18 +296,63 @@ def test_instructions_pushed_in_handshake(project): async with Client(server.mcp) as c: return c.initialize_result.instructions - assert asyncio.run(go()) == "line one\nline two\n" + pushed = asyncio.run(go()) + assert pushed.startswith("# gcontext") + assert pushed.endswith("line one\nline two\n") - g0 = [p for p in ledger.build(project) if p["id"] == "G0"] - assert g0[0]["status"] == "loaded" - assert "pushed at connect" in g0[0]["detail"] + pipes = {p["id"]: p for p in ledger.build(project)} + assert pipes["G0"]["status"] == "loaded" + assert "framework-owned" in pipes["G0"]["detail"] + assert pipes["G1"]["status"] == "loaded" + assert "pushed at connect" in pipes["G1"]["detail"] -def test_no_instructions_file_pushes_nothing(project): - assert server.load_instructions() == 0 - assert server.mcp.instructions is None - g0 = [p for p in ledger.build(project) if p["id"] == "G0"] - assert g0[0]["status"] == "skipped" +def test_no_instructions_file_pushes_only_base(project): + n_base, n_project = server.load_instructions() + assert n_base > 0 + assert n_project == 0 + assert server.mcp.instructions.startswith("# gcontext") + pipes = {p["id"]: p for p in ledger.build(project)} + assert pipes["G0"]["status"] == "loaded" + assert pipes["G1"]["status"] == "skipped" + + +def test_state_files_are_resources(project): + (project / "modules" / "m").mkdir(parents=True) + (project / "modules" / "m" / "index.md").write_text("topic notes") + (project / "secrets.env").write_text("API_KEY=sk-verysecret\n") + (project / "archive").mkdir() + (project / "archive" / "old.md").write_text("kept") + + import asyncio + + from fastmcp import Client + + async def go(): + async with Client(server.mcp) as c: + listed = [str(r.uri) for r in await c.list_resources()] + file = await c.read_resource("gcontext://modules/m/index.md") + folder = await c.read_resource("gcontext://modules/m/") + archived = await c.read_resource("gcontext://archive/old.md") + blocked = await c.read_resource("gcontext://secrets.env") + return listed, file[0].text, folder[0].text, archived[0].text, blocked[0].text + + listed, file_text, folder_text, archived_text, blocked_text = asyncio.run(go()) + assert "gcontext://modules/m/index.md" in listed + assert not any("secrets.env" in u for u in listed) + assert not any(u.startswith("gcontext://archive/") for u in listed) + assert file_text == "topic notes" + assert "index.md" in folder_text + assert archived_text == "kept" + assert "Error" in blocked_text and "sk-verysecret" not in blocked_text + + +def test_ledger_has_resources_pipe(project): + (project / "modules" / "m").mkdir(parents=True) + (project / "modules" / "m" / "index.md").write_text("x") + pipes = {p["id"]: p for p in ledger.build(project)} + assert pipes["G7"]["status"] == "on demand" + assert "gcontext://" in pipes["G7"]["detail"] def test_ledger_has_no_flow_pipe(project): diff --git a/web/src/Activity.jsx b/web/src/Activity.jsx index 79c6bc2..47280c1 100644 --- a/web/src/Activity.jsx +++ b/web/src/Activity.jsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; -import { getJSON, filePrompt } from "./lib.js"; +import { getJSON, fileRef } from "./lib.js"; import { C, mono, Chip, GhostBtn, sectionLabel, useHover, useIsMobile, pageTitle, EmptyState } from "./ui.jsx"; import CopyPrompt from "./Copy.jsx"; @@ -107,7 +107,7 @@ function Row({ e, first, onOpen }) { {e.name} {e.detail}{e.error ? " · failed" : ""} - {ref && h && } + {ref && h && } {nfmt(e.tokens_out)} tk @@ -175,7 +175,7 @@ function EventModal({ e, onClose }) {
What it was about
{e.detail} - {ref && } + {ref && }
)} diff --git a/web/src/App.jsx b/web/src/App.jsx index 372aa9d..8272806 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from "react"; -import { getJSON, relSeen } from "./lib.js"; +import { getJSON, relSeen, setServerName } from "./lib.js"; import { C, mono, UiProvider, useHover, useIsMobile } from "./ui.jsx"; import Overview from "./Overview.jsx"; import Connections from "./Connections.jsx"; @@ -81,7 +81,7 @@ export default function App() { useEffect(() => { localStorage.setItem("gc.section", section); }, [section]); const refresh = () => { - getJSON("/api/project").then((p) => { setProject(p); setErr(null); }).catch((e) => setErr(e.message)); + getJSON("/api/project").then((p) => { setProject(p); setServerName(p.name); setErr(null); }).catch((e) => setErr(e.message)); getJSON("/api/sessions").then((d) => setSessions(d.sessions)).catch(() => {}); }; useEffect(() => { diff --git a/web/src/Commands.jsx b/web/src/Commands.jsx index a50d5ef..a7e4494 100644 --- a/web/src/Commands.jsx +++ b/web/src/Commands.jsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from "react"; -import { getJSON, filePrompt } from "./lib.js"; +import { getJSON, fileRef } from "./lib.js"; import { C, mono, Chip, cardBase, cardHover, pageTitle, sectionLabel, EmptyState, useHover } from "./ui.jsx"; import CopyPrompt from "./Copy.jsx"; @@ -33,7 +33,7 @@ function CommandCard({ cmd }) { )}
{cmd.path} - +
{!cmd.error && (
diff --git a/web/src/Connections.jsx b/web/src/Connections.jsx index 5f05fe1..d6183e7 100644 --- a/web/src/Connections.jsx +++ b/web/src/Connections.jsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from "react"; -import { getJSON, filePrompt, folderPrompt } from "./lib.js"; +import { getJSON, fileRef, folderRef } from "./lib.js"; import { C, mono, Chip, cardBase, cardHover, cardGrid, pageTitle, sectionLabel, EmptyState, useHover } from "./ui.jsx"; import CopyPrompt from "./Copy.jsx"; @@ -13,7 +13,7 @@ function FileRow({ path }) { return (
{path} - +
); } @@ -48,7 +48,7 @@ function ConnectionCard({ conn }) {
)}
- +
); diff --git a/web/src/Copy.jsx b/web/src/Copy.jsx index 9cc4e0c..e0bfbff 100644 --- a/web/src/Copy.jsx +++ b/web/src/Copy.jsx @@ -2,12 +2,12 @@ import React from "react"; import { C, mono, useHover, useUi } from "./ui.jsx"; import { copyText } from "./lib.js"; -// The one action surface in the app: copy a prompt for the agent, fire a -// toast. The dashboard SEES the project; the agent (via MCP) USES it, so -// every action hands an agent-ready prompt to the clipboard. -// full pill -> (⧉ Copy prompt, terracotta) +// The one action surface in the app: copy a resource reference for the agent, +// fire a toast. The dashboard SEES the project; the agent (via MCP) USES it, +// so every action hands an @server:gcontext://path reference to the clipboard. +// full pill -> (⧉ Copy reference, terracotta) // icon only -> (26x26 ⧉, list rows) -export default function CopyPrompt({ text, label = "Copy prompt", toast = "Copied, paste it into your agent", title, icon, style }) { +export default function CopyPrompt({ text, label = "Copy reference", toast = "Copied, paste it into your agent", title, icon, style }) { const ui = useUi(); const [h, hp] = useHover(); const copy = (e) => { diff --git a/web/src/Files.jsx b/web/src/Files.jsx index e861769..955b5a6 100644 --- a/web/src/Files.jsx +++ b/web/src/Files.jsx @@ -46,7 +46,7 @@ function TreeRow({ node, depth, selected, open, onToggle, onSelect }) { : } {node.name}{node.dir ? "/" : ""} - {h && } + {h && } {node.dir && open.has(node.path) && node.children.map((c) => ( @@ -82,7 +82,7 @@ function Viewer({ path }) { {file.path} {fileLabel(path)} · {file.size} B - +
{isMd ? ( diff --git a/web/src/Modules.jsx b/web/src/Modules.jsx index 1883efe..4adad41 100644 --- a/web/src/Modules.jsx +++ b/web/src/Modules.jsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from "react"; -import { getJSON, filePrompt, folderPrompt } from "./lib.js"; +import { getJSON, fileRef, folderRef } from "./lib.js"; import { C, mono, Chip, cardBase, cardHover, cardGrid, pageTitle, sectionLabel, EmptyState, useHover } from "./ui.jsx"; import CopyPrompt from "./Copy.jsx"; @@ -12,7 +12,7 @@ function FileRow({ path }) { return (
{path} - +
); } @@ -35,7 +35,7 @@ function ModuleCard({ mod }) {
)}
- +
); diff --git a/web/src/Overview.jsx b/web/src/Overview.jsx index 3a6011c..31b8172 100644 --- a/web/src/Overview.jsx +++ b/web/src/Overview.jsx @@ -92,7 +92,7 @@ export default function Overview({ project, sessions }) { {(project.has_instructions || archivedParts.length > 0) && (
{project.has_instructions && ( - System prompt: instructions.md ({project.instructions_lines} lines) + System prompt: agent.md ({project.instructions_lines} lines) )} {archivedParts.length > 0 && ( archive/: {archivedParts.join(", ")} (not scanned, readable by path) diff --git a/web/src/lib.js b/web/src/lib.js index 587430a..59b7fea 100644 --- a/web/src/lib.js +++ b/web/src/lib.js @@ -21,14 +21,15 @@ export function copyText(text) { ta.remove(); } -// Agent-ready prompts for a file or folder reference. The copied text names -// the gcontext MCP server, so any agent can locate the path without guessing -// which server or tool it belongs to. -export const filePrompt = (path) => - `From the gcontext MCP server, read "${path}" and use it as context for this task.`; -export const folderPrompt = (path) => - `From the gcontext MCP server, explore the folder "${path.replace(/\/$/, "")}/": check its files and read the relevant ones.`; -export const refPrompt = (path, isDir) => (isDir ? folderPrompt(path) : filePrompt(path)); +// Direct MCP resource references: @:gcontext://. Runtimes that +// support resource mentions (Claude Code et al.) resolve these mechanically; +// the server exposes every state file at gcontext://. The server name +// is set once by App.jsx from /api/project. +let serverName = "gcontext"; +export const setServerName = (name) => { if (name) serverName = name; }; +export const fileRef = (path) => `@${serverName}:gcontext://${path}`; +export const folderRef = (path) => `@${serverName}:gcontext://${path.replace(/\/$/, "")}/`; +export const refPrompt = (path, isDir) => (isDir ? folderRef(path) : fileRef(path)); // File-card label: "notes.md" -> "md", extensionless -> "file". Dotfiles (".env") // stay "file" (lastIndexOf > 0), so the label never repeats the whole name.