- cannot reach the server: {err}. Is `gcontext up` running? -
- )} - {section === "overview" &&diff --git a/README.md b/README.md
index 3c13b76..9ae3367 100644
--- a/README.md
+++ b/README.md
@@ -46,7 +46,7 @@ my-agent/
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.
-Connected clients get six tools: `overview`, `read_file`, `write_file`, `list_dir`, `grep`, `run_script`.
+Connected clients get five tools: `read_file`, `write_file`, `list_dir`, `grep`, `run_script`.
`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.
diff --git a/docs/design.md b/docs/design.md
index 530a21e..a5dba7f 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -54,11 +54,11 @@ The accepted tradeoff: something must be running.
## The context ledger: everything pushed is declared
-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`, in `overview()`, after `connect`, and in the dashboard.
+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.
-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 (via `overview()`) 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 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.
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.
diff --git a/docs/modules.md b/docs/modules.md
index f93eeaa..0108177 100644
--- a/docs/modules.md
+++ b/docs/modules.md
@@ -50,13 +50,13 @@ There is no enforced schema beyond `index.md`. Different modules have different
1. Download the module folder (or copy it)
2. Drop it into `modules/` in your agent folder
-3. That's it. The agent discovers it via `overview()` and can read all its files.
+3. That's it. The agent discovers it with `list_dir("modules")` and can read all its files.
No installation step, no config to edit, no dependencies to resolve. It's just files.
## How the agent interacts with a module
-The agent sees modules listed in `overview()`. When a task matches a module's purpose, the agent:
+The agent sees modules with `list_dir("modules")`. When a task matches a module's purpose, the agent:
1. Reads `index.md` to understand what the module does
2. Reads any additional files (steps, playbooks, references)
diff --git a/examples/ops-agent/archive/modules/legacy-audit/index.md b/examples/ops-agent/archive/modules/legacy-audit/index.md
index 748a4e2..2cb35fb 100644
--- a/examples/ops-agent/archive/modules/legacy-audit/index.md
+++ b/examples/ops-agent/archive/modules/legacy-audit/index.md
@@ -1,7 +1,7 @@
# Legacy audit module (archived)
Example of an archived module. It sits under archive/modules/, so it is never
-scanned into overview(), status, or the ledger counts. It stays readable by
+scanned into status, the dashboard, or the ledger counts. It stays readable by
path: read_file("archive/modules/legacy-audit/index.md").
To bring it back, move the folder to modules/legacy-audit/. Archiving is a
diff --git a/src/gcontext/cli.py b/src/gcontext/cli.py
index 790e82f..ec94482 100644
--- a/src/gcontext/cli.py
+++ b/src/gcontext/cli.py
@@ -51,7 +51,8 @@ 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.
-- Call overview() first to see connections, modules, and the context ledger.
+- 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/
diff --git a/src/gcontext/ledger.py b/src/gcontext/ledger.py
index 95f7510..e981172 100644
--- a/src/gcontext/ledger.py
+++ b/src/gcontext/ledger.py
@@ -25,8 +25,7 @@ def build(root: Path) -> list[dict]:
else:
ledger.append({"id": "G0", "label": "instructions.md", "detail": "file missing, nothing pushed at connect", "status": "skipped"})
- ledger.append({"id": "G1", "label": "tool descriptions", "detail": "6 gcontext tools, pushed at connect", "status": "loaded"})
- ledger.append({"id": "G2", "label": "overview()", "detail": "project map, secret status", "status": "on demand"})
+ ledger.append({"id": "G1", "label": "tool descriptions", "detail": "5 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"
diff --git a/src/gcontext/prompts/tools/overview.md b/src/gcontext/prompts/tools/overview.md
deleted file mode 100644
index 53550f4..0000000
--- a/src/gcontext/prompts/tools/overview.md
+++ /dev/null
@@ -1 +0,0 @@
-Show project info, all connections with per-secret fill status, and all modules with descriptions.
diff --git a/src/gcontext/prompts/tools/read_file.md b/src/gcontext/prompts/tools/read_file.md
index 7a6c98b..34dab99 100644
--- a/src/gcontext/prompts/tools/read_file.md
+++ b/src/gcontext/prompts/tools/read_file.md
@@ -1,3 +1,3 @@
-Read a file from the project. Use overview() or list_dir first to see available files.
+Read a file from the project. Use list_dir or grep first to find the file.
Cannot read secrets.env: secret values never enter the context window.
diff --git a/src/gcontext/server.py b/src/gcontext/server.py
index cb1e062..75cf3cd 100644
--- a/src/gcontext/server.py
+++ b/src/gcontext/server.py
@@ -1,6 +1,6 @@
"""The MCP surface: everything an attached agent can reach, in one file.
-Six tools (defined below, their agent-facing text in prompts/tools/*.md),
+Five tools (defined below, their agent-facing text in prompts/tools/*.md),
commands registered as prompts, a /status route, and session tracking.
The actual work lives in the per-concern modules:
@@ -30,7 +30,6 @@ from starlette.responses import JSONResponse
from . import commands as commands_mod
from . import exec as exec_mod
from . import fs
-from . import ledger as ledger_mod
from . import secrets as secrets_mod
from . import state
@@ -194,72 +193,6 @@ def load_instructions() -> int:
return len(text.splitlines())
-@mcp.tool(description=_tool_doc("overview"))
-def overview() -> str:
- root = PROJECT_DIR
- config = state.load_gcontext_yaml(root)
- connections = state.load_connections(root)
- secrets = secrets_mod.load(root)
- modules = state.discover_modules(root)
-
- lines = []
- name = config.get("name", root.name)
- desc = config.get("description", "")
- lines.append(f"# {name}")
- if desc:
- lines.append(desc)
- lines.append("")
-
- lines.append("## Context ledger")
- lines.append("Everything that enters your context from this server, and how:")
- lines.extend(ledger_mod.render_plain(root))
- lines.append("")
-
- instructions = root / "instructions.md"
- if instructions.exists():
- lines.append(f"System prompt: instructions.md ({len(instructions.read_text().splitlines())} lines)")
- lines.append("")
-
- lines.append("## Connections")
- if not connections:
- lines.append("No connections defined.")
- for cname, conn in connections.items():
- filled = sum(1 for s in conn.secrets if s in secrets and secrets[s])
- total = len(conn.secrets)
- status = "ready" if filled == total else f"missing {total - filled} secret(s)"
- lines.append(f"- **{cname}**: {status}")
- if conn.description:
- lines.append(f" {conn.description}")
- for s in conn.secrets:
- has_value = s in secrets and bool(secrets[s])
- lines.append(f" - {s}: {'filled' if has_value else 'MISSING'}")
- if conn.deps:
- lines.append(f" Deps: {', '.join(conn.deps)}")
- for f in state.connection_files(root, cname):
- lines.append(f" - {f}")
- lines.append("")
-
- if modules:
- lines.append("## Modules")
- for mname, mod in modules.items():
- tag_str = f" [{', '.join(mod.tags)}]" if mod.tags else ""
- lines.append(f"- **{mname}** (v{mod.version}){tag_str}")
- if mod.description:
- lines.append(f" {mod.description}")
- for f in state.module_files(root, mname):
- lines.append(f" - {f}")
- lines.append("")
-
- archived = state.archived(root)
- if archived:
- lines.append("## Archive")
- for cat, items in archived.items():
- lines.append(f"- archive/{cat}/: {', '.join(items)}")
- lines.append("Archived items are never scanned or listed above; read them by path if needed.")
-
- return "\n".join(lines).rstrip()
-
-
@mcp.tool(description=_tool_doc("read_file"))
def read_file(path: str) -> str:
return fs.read_file(PROJECT_DIR, path)
diff --git a/src/gcontext/state.py b/src/gcontext/state.py
index 0de9124..e66e44c 100644
--- a/src/gcontext/state.py
+++ b/src/gcontext/state.py
@@ -82,7 +82,7 @@ def module_files(root: Path, name: str) -> list[str]:
def archived(root: Path) -> dict[str, list[str]]:
"""Names of archived items per category, from archive/{connections,modules}/.
- Anything under archive/ is never scanned into overview or the ledger
+ Anything under archive/ is never scanned into the dashboard or the ledger
counts. It stays readable by path via read_file. Archiving is a plain
folder move; there is no metadata and no automatic behavior.
"""
diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py
index bcc230f..4a7f3cc 100644
--- a/tests/test_dashboard.py
+++ b/tests/test_dashboard.py
@@ -130,7 +130,7 @@ def test_middleware_records_scrubbed_tool_event(project):
def test_middleware_records_error_and_reraises(project):
class Msg:
- name = "overview"
+ name = "read_file"
arguments = {}
class Ctx:
diff --git a/tests/test_server.py b/tests/test_server.py
index d7f3971..b4a6167 100644
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -42,6 +42,52 @@ def test_write_then_read_roundtrip(project):
assert server.read_file("modules/notes/index.md") == "hello"
+def test_list_dir_lists_entries_and_blocks_traversal(project):
+ (project / "modules" / "notes").mkdir(parents=True)
+ (project / "modules" / "notes" / "index.md").write_text("x")
+ out = server.list_dir("modules")
+ assert "notes/" in out
+ out = server.list_dir("modules/notes")
+ assert "index.md" in out
+ assert "outside the project" in server.list_dir("..")
+
+
+def test_list_dir_hides_machine_folders(project):
+ (project / ".git").mkdir()
+ (project / "kept.md").write_text("x")
+ out = server.list_dir(".")
+ assert ".git" not in out
+ assert "kept.md" in out
+
+
+def test_grep_finds_lines_and_respects_glob(project):
+ (project / "modules" / "m").mkdir(parents=True)
+ (project / "modules" / "m" / "index.md").write_text("refund policy\nother\n")
+ (project / "modules" / "m" / "notes.txt").write_text("refund notes\n")
+ out = server.grep("refund")
+ assert "modules/m/index.md:1: refund policy" in out
+ assert "notes.txt" in out
+ out = server.grep("refund", glob="*.md")
+ assert "index.md" in out
+ assert "notes.txt" not in out
+
+
+def test_grep_never_reads_secrets_env(project):
+ (project / "secrets.env").write_text("API_KEY=sk-verysecret\n")
+ out = server.grep("verysecret")
+ assert "sk-verysecret" not in out
+ assert "No matches" in out
+
+
+def test_grep_invalid_regex(project):
+ assert "invalid regex" in server.grep("[unclosed")
+
+
+def test_removed_tools_are_gone():
+ assert not hasattr(server, "list_connections")
+ assert not hasattr(server, "overview")
+
+
def test_archive_not_scanned_but_reported(project):
(project / "modules" / "active").mkdir(parents=True)
(project / "modules" / "active" / "index.md").write_text("x")
@@ -52,9 +98,7 @@ def test_archive_not_scanned_but_reported(project):
assert "active" in modules and "old" not in modules
assert state.archived(project) == {"modules": ["old"]}
- overview = server.overview()
- assert "## Archive" in overview
- assert "old" in overview
+ assert "archive/" in server.list_dir(".")
def test_archive_readable_by_path(project):
diff --git a/web/src/Activity.jsx b/web/src/Activity.jsx
index cc64ddb..79c6bc2 100644
--- a/web/src/Activity.jsx
+++ b/web/src/Activity.jsx
@@ -1,51 +1,224 @@
-import React, { useEffect, useRef, useState } from "react";
-import { getJSON } from "./lib.js";
-import { C, mono, label } from "./ui.jsx";
+import React, { useEffect, useMemo, useRef, useState } from "react";
+import { getJSON, filePrompt } from "./lib.js";
+import { C, mono, Chip, GhostBtn, sectionLabel, useHover, useIsMobile, pageTitle, EmptyState } from "./ui.jsx";
+import CopyPrompt from "./Copy.jsx";
-// Activity = what crossed from gcontext into the agent, newest first.
-// A flat feed from /api/events (in-memory ring buffer, empties on restart);
-// a `connect` event starts a session, drawn as a separator. Click a row to
-// expand the recorded preview inline. Polls every 3s while visible.
+// Activity = "what crossed from gcontext into my agent, and when". The flat
+// /api/events feed (an in-memory ring buffer on the server, newest last) is
+// grouped into SESSIONS (a `connect` event opens each one); pick a session on
+// the left, skim its crossings, click any to read the recorded preview.
+// Polls every 3s while the tab is visible; the buffer empties on restart.
+const nfmt = (v) => (v || 0).toLocaleString();
+const kfmt = (v) => (v >= 1000 ? (v / 1000).toFixed(1).replace(/\.0$/, "") + "k" : String(v));
const fmtTime = (ts) => new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
+const fmtHM = (ts) => new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
function dayLabel(ts) {
const d = new Date(ts), now = new Date();
const day = (a) => new Date(a.getFullYear(), a.getMonth(), a.getDate()).getTime();
const diff = Math.round((day(now) - day(d)) / 86400000);
- if (diff === 0) return "today";
- if (diff === 1) return "yesterday";
+ if (diff === 0) return "Today";
+ if (diff === 1) return "Yesterday";
return d.toLocaleDateString([], { month: "short", day: "numeric" });
}
-function Row({ e, open, onToggle }) {
- const kindColor = e.error ? C.danger : e.kind === "prompt" ? C.ok : C.t3;
+// The detail's first token, when it reads as a project-relative file path,
+// e.g. "connections/x/index.md" or "a.md (12 bytes)" -> "a.md".
+function pathRef(detail) {
+ const first = (detail || "").split(" ")[0];
+ return /^[\w.\-/]+\.\w+$/.test(first) || /^[\w.\-]+\/[\w.\-/]*$/.test(first) ? first : null;
+}
+
+// One tier chip per crossing origin: pushed (connect), agent pulled, user pulled.
+const TIERS = {
+ 0: { label: "pushed", color: "#a8492a", bg: "rgba(194,96,58,.10)" },
+ 1: { label: "agent", color: "#8f7c5f", bg: "rgba(176,154,125,.14)" },
+ 2: { label: "you", color: "#4a7c59", bg: "rgba(74,124,89,.10)" },
+};
+const tierOf = (t) => TIERS[t] || TIERS[1];
+
+// Bucket "how much context this call added" so heavy calls are skimmable.
+function weight(tokensOut) {
+ if (tokensOut >= 5200) return { key: "heavy", color: C.accent, frac: 1, label: "heavy" };
+ if (tokensOut >= 2000) return { key: "med", color: "#cf8a63", frac: 0.6, label: "medium" };
+ if (tokensOut >= 420) return { key: "light", color: "#c3b7a0", frac: 0.32, label: "light" };
+ return { key: "tiny", color: "#d8cfbd", frac: 0.14, label: "minimal" };
+}
+
+const label = sectionLabel;
+const monoNum = { fontFamily: mono, fontVariantNumeric: "tabular-nums" };
+
+function TierChip({ tier, style }) {
+ const t = tierOf(tier);
+ return
- {e.preview}{e.preview.length >= 400 ? "\n┅ first 400 chars, the agent received the rest too" : ""}
-
- )}
+
+
+
+ );
+}
+
+// The one reading surface: light background, ink text, comfortable line height.
+function Reader({ children, maxHeight }) {
+ return (
+
+ {children}
+
+ );
+}
+
+// --- session rail item ------------------------------------------------------
+function SessionItem({ session, active, selected, onSelect, maxTk }) {
+ const [h, hp] = useHover();
+ const st = session.startTs;
+ return (
+
+ );
+}
+
+// --- one crossing row -------------------------------------------------------
+function Row({ e, first, onOpen }) {
+ const [h, hp] = useHover();
+ const w = weight(e.tokens_out);
+ const heavy = e.tokens_out >= 1200;
+ const ref = pathRef(e.detail);
+ return (
+ {err}
; - if (!events) returnloading…
; - if (events.length === 0) { - returnno activity yet. Events appear here as harnesses connect and call tools. The feed empties on restart.
; - } + // Escape closes the modal. + useEffect(() => { + const onKey = (e) => { if (e.key === "Escape") setModal(null); }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + + // Split the flat, newest-first feed into sessions (a `connect` event closes one). + const sessions = useMemo(() => { + const startTs = (g) => { const c = g.find((e) => e.kind === "connect"); return c ? c.ts : g[g.length - 1].ts; }; + const groups = []; let cur = []; + for (const e of flow || []) { cur.push(e); if (e.kind === "connect") { groups.push(cur); cur = []; } } + if (cur.length) groups.push(cur); + return groups.map((events) => ({ events, startTs: startTs(events), tk: events.reduce((n, e) => n + e.tokens_in + e.tokens_out, 0) })); + }, [flow]); + + if (err) return+ Everything that crossed from gcontext into your agent, grouped by session. The feed lives in server memory and empties on restart. +
+ + {!flow || flow.length === 0 ? ( +- cannot reach the server: {err}. Is `gcontext up` running? -
- )} - {section === "overview" &&Malformed frontmatter: {cmd.error}
+ ) : cmd.description ? ( +{cmd.description}
+ ) : ( +No description yet.
+ )} + {(cmd.args || []).length > 0 && ( +{inv}
+ + Files under commands/ folders, served as MCP prompts. New files appear after a server restart. +
+ {cmds.length === 0 ? ( ++ Drop a .md (prompt) or .py (script) file into connections/<name>/commands/ or modules/<name>/commands/ and restart the server. +
+{conn.description}
} + {conn.secrets.length > 0 && ( ++ Services the agent can reach. Each declares the secret names and Python deps it needs; secret values stay in secrets.env on this machine. +
+ {conns.length === 0 ? ( ++ Add one under connections/<service>/connection.yaml with the secret names and deps, plus an index.md describing the API in your words. +
+pick a file on the left
; - if (err) return{path}: {err}
; - if (!file) returnloading…
; + if (!path) { + return ( +{file.content}
+ )}
{file.content}
- )}
{err}
; - if (!entries) returnloading…
; + if (err) return+ The project folder as the agent sees it. Hover a row to copy a prompt that points your agent at that file or folder. secrets.env and machine folders never appear here. +
+{mod.description}
} + {mod.files.length > 0 && ( ++ Reusable folders of knowledge and scripts under modules/, read by the agent on demand. +
+ {mods.length === 0 ? ( ++ Create a folder under modules/<name>/ with the docs or scripts the agent should keep. +
+loading…
; - - const url = `${location.origin}/mcp`; - const connectCmd = `claude mcp add --transport http ${project.name} ${url}`; - const archived = Object.entries(project.archived || {}).map(([cat, items]) => `${items.length} ${cat}`).join(", "); - - return ( -- {project.description || "No description in gcontext.yaml."} -
-{project.project_dir}
- -none. Attach a harness with the command below
} - {sessions.map((s, i) => ( -{connectCmd}
- any MCP client: {url}
- + ))} +none. Add connections/<service>/connection.yaml
} - {conns.map((c, i) => ( -{s.text}
+ none
} - {mods.map((m, i) => ( -none. Drop .md or .py files into a commands/ folder
} - {cmds.map((c, i) => ( -+ {project.description || "No description in gcontext.yaml yet."} +
+- {project.has_instructions ? `instructions.md · ${project.instructions_lines} lines` : "no instructions.md"} - {archived ? ` · archive: ${archived}` : ""} - {` · gcontext ${project.version}`} +
+ Everything that enters the agent's context from this server, and how.
+{message}
+