Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,8 @@ The optimizer is identical everywhere; what differs is reach:
| Cursor | ✅ | ✅ deny + follow-up loop (`.cursor/hooks.json`) | hooks + `knitbrain_read` | — |
| Gemini CLI | ✅ | ✅ deny + AfterAgent loop (`.gemini/settings.json`) | hooks + `knitbrain_read` | — |
| VS Code Copilot | ✅ | ✅ full (reads `.claude/settings.json` natively) | hooks + `knitbrain_read` | — |
| Windsurf · Cline · any MCP client | ✅ | — (advisory; hooks planned where APIs allow) | via `knitbrain_read` | — |
| Windsurf | ✅ | ✅ deny-only (exit-2) (`.windsurf/hooks.json`) | hooks + `knitbrain_read` | — |
| Cline · any other MCP client | ✅ | — (advisory; hooks planned where APIs allow) | via `knitbrain_read` | — |
| Any agent, API key | ✅ | — | ✅ proxy (full wire) | — |

One hook binary serves every row: it detects the calling platform from the payload and answers in
Expand Down Expand Up @@ -230,7 +231,7 @@ Gated by tests and CI, not promised:
- **Answers survive** — error lines, result summaries, and top-level declarations are never elided (`knitbrain evals`, 100% on real transcripts).
- **Machine contracts hold** — JSON tool responses are never skeletonized.
- **No false green** — the loop marks a task done only after a real verify passes; hooks block premature stops.
- **Honest receipt** — savings are counted only when a raw output was actually replaced or redirected; estimates are labeled; zero is reported as zero.
- **Honest receipt** — savings are counted only when a raw output was actually replaced or redirected; estimates are labeled; zero is reported as zero. Subagent burn (Claude Code Task subagents, Codex CLI's alias) is attributed to the activity ledger via `SubagentStart`/`SubagentStop`, so nested-agent token spend isn't invisible to the receipt.
- **Local-first** — proxy, hub, and dashboard bind `127.0.0.1`; credentials are read locally, sent only to the provider's own endpoint, never logged or stored.
- **Reproducible** — every number in this README comes from a command you can run on your own data.
- **Self-audited** — `knitbrain_self_check` runs seven invariants (anti-stale ×2, anti-drift ×2, anti-sycophancy, adherence, context-hygiene) in one pass.
Expand Down
8 changes: 7 additions & 1 deletion src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export interface DashboardDeps {
wiki?: WikiStore;
/** Optional: G1 activity-ledger X-ray — SAME math as the stop-hook receipt. */
xray?: () => XrayState | null;
/** Optional: active run_loop state (goal/iter/maxIters) for the X-ray card badge. */
loop?: () => { goal: string; iter: number; maxIters?: number } | null;
}

/** Per-source rollup of the current session's activity events, plus the exact
Expand Down Expand Up @@ -179,6 +181,7 @@ export function dashboardState(deps: DashboardDeps): Record<string, unknown> {
knowledge: deps.knowledge ? knowledgeSummary(deps.knowledge) : null,
wiki: deps.wiki ? wikiState(deps.wiki) : null,
xray: deps.xray?.() ?? null,
loop: deps.loop?.() ?? null,
skills: deps.skills
? deps.skills.list().map((s) => ({ name: s.name, uses: s.uses, triggers: s.triggers.slice(0, 6), updatedAt: s.updatedAt }))
: null,
Expand Down Expand Up @@ -229,7 +232,7 @@ const PAGE = `<!doctype html>
<div class="card" style="margin-top:.8rem"><div class="label">Knowledge graph (top blast radius)</div><div class="advice" id="kfiles"></div><table id="kg"></table></div>
<div class="card" style="margin-top:.8rem"><div class="label">Skills</div><table id="skills"></table></div>
<div class="card" style="margin-top:.8rem"><div class="label">Recent learnings</div><table id="recent"></table></div>
<div class="card" style="margin-top:.8rem"><div class="label">X-ray — G1 activity ledger (same math as the stop receipt)</div>
<div class="card" style="margin-top:.8rem"><div class="label">X-ray — G1 activity ledger (same math as the stop receipt) <span id="loop-badge"></span></div>
<div class="advice" id="xray-empty">no session data</div>
<div id="xray-wrap" style="display:none">
<table id="xray"></table>
Expand Down Expand Up @@ -336,6 +339,9 @@ async function tick() {
(s.recentLearnings.length ? s.recentLearnings.map(l => \`<tr><td>\${esc(l.date)}</td><td>\${esc(l.summary)}</td></tr>\`).join("") : "<tr><td colspan=2>—</td></tr>");
document.getElementById("board").innerHTML = "<tr><th>who</th><th>when</th><th>finding</th></tr>" +
(s.board.length ? s.board.map(b => \`<tr><td>\${esc(b.author)}</td><td>\${esc(b.ts.slice(11,19))}</td><td>\${esc(b.summary)}</td></tr>\`).join("") : "<tr><td colspan=3>—</td></tr>");
document.getElementById("loop-badge").textContent = s.loop
? \`◎ \${s.loop.goal} iter \${s.loop.iter}\${s.loop.maxIters ? \`/\${s.loop.maxIters}\` : ""}\`
: "";
if (s.xray) {
document.getElementById("xray-empty").style.display = "none";
document.getElementById("xray-wrap").style.display = "";
Expand Down
90 changes: 87 additions & 3 deletions src/engine/host-scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,78 @@ export interface HostHook {
source: HostSource;
}

/** An MCP connector declared in a project's or the user's global MCP config —
* name + how it's launched, SECURITY-scrubbed to command/origin only (no env,
* headers, url query/fragment, or auth tokens ever leave readMcpServers). */
export interface HostConnector {
name: string;
command: string;
source: "project" | "global";
}

interface McpServerEntry {
command?: string;
args?: string[];
url?: string;
}

/** Read one MCP config file's `mcpServers` object, guarded — missing/corrupt/
* non-object files return {} rather than throwing (scan must never crash on a
* hand-edited config). */
function readMcpServers(path: string): Record<string, McpServerEntry> {
if (!existsSync(path)) return {};
try {
const parsed = JSON.parse(readFileSync(path, "utf8")) as { mcpServers?: unknown };
const servers = parsed?.mcpServers;
return servers && typeof servers === "object" ? (servers as Record<string, McpServerEntry>) : {};
} catch {
return {};
}
}

/** Derive the connector's launch command, SECURITY-scrubbed: a `url` server
* yields only its origin (new URL().origin — no query/fragment/token), never
* headers/env. Returns null if the entry has neither command nor url. */
function connectorCommand(entry: McpServerEntry): string | null {
if (entry.url) {
try {
return new URL(entry.url).origin;
} catch {
return null;
}
}
if (entry.command) {
return `${entry.command} ${(entry.args ?? []).join(" ")}`.trim();
}
return null;
}

/** Scan project + global MCP configs for declared connectors (name + launch
* command only — never env/headers/tokens). Project configs win over global on
* name collision; any server whose name or command mentions "knitbrain" is
* excluded (this tool doesn't list itself in its own inventory). */
export function scanHostConnectors(cwd: string, home: string = homedir()): HostConnector[] {
const projectFiles = [join(cwd, ".mcp.json"), join(cwd, ".cursor", "mcp.json"), join(cwd, ".vscode", "mcp.json")];
const globalFiles = [join(home, ".claude.json"), join(home, ".cursor", "mcp.json")];

const byName = new Map<string, HostConnector>();
const addFrom = (files: string[], source: "project" | "global") => {
for (const file of files) {
const servers = readMcpServers(file);
for (const [name, entry] of Object.entries(servers)) {
const command = connectorCommand(entry);
if (!command) continue;
if (name.toLowerCase().includes("knitbrain") || command.toLowerCase().includes("knitbrain")) continue;
if (byName.has(name)) continue; // first writer wins — project pass runs before global
byName.set(name, { name, command, source });
}
}
};
addFrom(projectFiles, "project");
addFrom(globalFiles, "global");
return Array.from(byName.values());
}

/** A frontmatter value is a scalar or a list (`key: [a, b]` / `key: a, b`). */
type FmValue = string | string[];

Expand Down Expand Up @@ -513,7 +585,7 @@ export function scanHostAll(
projectClaudeDir: string,
home: string = homedir(),
io: HostIO = realIO,
): { skills: HostSkill[]; agents: HostAgent[]; commands: HostCommand[]; hooks: HostHook[]; style: StyleProfile } {
): { skills: HostSkill[]; agents: HostAgent[]; commands: HostCommand[]; hooks: HostHook[]; connectors: HostConnector[]; style: StyleProfile } {
const skills: HostSkill[] = [];
const agents: HostAgent[] = [];
const commands: HostCommand[] = [];
Expand Down Expand Up @@ -551,7 +623,10 @@ export function scanHostAll(
hooks.push(h);
}
}
return { skills, agents, commands, hooks, style: inferStyle(skills, agents) };
// projectClaudeDir is always `<projectRoot>/.claude` (see call sites) — the
// connector configs (.mcp.json etc.) live at the project root, one level up.
const connectors = scanHostConnectors(dirname(projectClaudeDir), home);
return { skills, agents, commands, hooks, connectors, style: inferStyle(skills, agents) };
}

/**
Expand All @@ -564,15 +639,23 @@ export interface HostIndex {
agents: Array<{ name: string; description: string; source: HostSource; tools: string[]; model: string }>;
commands: Array<{ name: string; description: string; source: HostSource }>;
hooks: Array<{ event: string; matcher: string; command: string; source: HostSource }>;
connectors: HostConnector[];
updatedAt: string;
}

export function buildHostIndex(scan: { skills: HostSkill[]; agents: HostAgent[]; commands?: HostCommand[]; hooks?: HostHook[] }): HostIndex {
export function buildHostIndex(scan: {
skills: HostSkill[];
agents: HostAgent[];
commands?: HostCommand[];
hooks?: HostHook[];
connectors?: HostConnector[];
}): HostIndex {
return {
skills: scan.skills.map((s) => ({ name: s.name, description: s.description, source: s.source, triggers: s.triggers })),
agents: scan.agents.map((a) => ({ name: a.name, description: a.description, source: a.source, tools: a.tools, model: a.model })),
commands: (scan.commands ?? []).map((c) => ({ name: c.name, description: c.description, source: c.source })),
hooks: (scan.hooks ?? []).map((h) => ({ event: h.event, matcher: h.matcher, command: h.command, source: h.source })),
connectors: scan.connectors ?? [],
updatedAt: new Date().toISOString(),
};
}
Expand All @@ -599,6 +682,7 @@ export function loadHostIndex(path: string): HostIndex | null {
agents: idx.agents ?? [],
commands: idx.commands ?? [],
hooks: idx.hooks ?? [],
connectors: idx.connectors ?? [],
updatedAt: idx.updatedAt ?? "",
};
} catch {
Expand Down
58 changes: 42 additions & 16 deletions src/engine/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,28 @@ const empty = (): PlatformUsage => ({
messages: 0,
});

/** Accumulate one transcript file's `usage` lines into `u` in place — the
* shared inner loop for both readUsageFromDir (many files) and
* readTranscriptUsage (one file), so the field accumulation lives once. */
function accumulateFile(content: string, u: PlatformUsage): void {
for (const line of content.split("\n")) {
if (!line.includes('"usage"')) continue;
let msg: { message?: { usage?: Record<string, number> } };
try {
msg = JSON.parse(line);
} catch {
continue;
}
const us = msg.message?.usage;
if (!us) continue;
u.inputTokens += us["input_tokens"] ?? 0;
u.outputTokens += us["output_tokens"] ?? 0;
u.cacheReadTokens += us["cache_read_input_tokens"] ?? 0;
u.cacheCreationTokens += us["cache_creation_input_tokens"] ?? 0;
u.messages += 1;
}
}

/** Sum real usage across a transcript directory's .jsonl files. */
export function readUsageFromDir(dir: string): PlatformUsage | null {
if (!existsSync(dir)) return null;
Expand All @@ -46,28 +68,32 @@ export function readUsageFromDir(dir: string): PlatformUsage | null {
} catch {
continue;
}
for (const line of content.split("\n")) {
if (!line.includes('"usage"')) continue;
let msg: { message?: { usage?: Record<string, number> } };
try {
msg = JSON.parse(line);
} catch {
continue;
}
const us = msg.message?.usage;
if (!us) continue;
u.inputTokens += us["input_tokens"] ?? 0;
u.outputTokens += us["output_tokens"] ?? 0;
u.cacheReadTokens += us["cache_read_input_tokens"] ?? 0;
u.cacheCreationTokens += us["cache_creation_input_tokens"] ?? 0;
u.messages += 1;
}
accumulateFile(content, u);
}
if (u.messages === 0) return null;
u.totalTokens = u.inputTokens + u.outputTokens + u.cacheReadTokens + u.cacheCreationTokens;
return u;
}

/** Real usage from ONE transcript file (same field accumulation as
* readUsageFromDir, scoped to a single .jsonl) — null if missing/unreadable/
* has no usage lines. Lets callers meter a single session's transcript
* without needing its whole project directory. */
export function readTranscriptUsage(file: string): PlatformUsage | null {
if (!existsSync(file)) return null;
let content: string;
try {
content = readFileSync(file, "utf8");
} catch {
return null;
}
const u = empty();
accumulateFile(content, u);
if (u.messages === 0) return null;
u.totalTokens = u.inputTokens + u.outputTokens + u.cacheReadTokens + u.cacheCreationTokens;
return u;
}

/** Real usage for the project rooted at `cwd` (null if no transcripts yet). */
export function readProjectUsage(cwd: string, home: string = homedir()): PlatformUsage | null {
return readUsageFromDir(projectTranscriptDir(cwd, home));
Expand Down
8 changes: 6 additions & 2 deletions src/engine/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export interface WorkflowDoc {
/** The user's scanned toolkit (skills + agents across project/global/plugin) —
* baked into the standing workflow so the loop starts every session knowing
* its arsenal instead of rediscovering it per task. */
toolkit?: { skillCount: number; agentCount: number; agentNames: string[]; skillNames: string[] };
toolkit?: { skillCount: number; agentCount: number; agentNames: string[]; skillNames: string[]; connectorNames?: string[] };
/** Per-domain routing: every detected part of the project mapped to its
* owning agent + matching skill (or marked uncovered) — the closed loop
* follows this instead of guessing ownership per task. */
Expand All @@ -130,9 +130,13 @@ export function composeWorkflow(w: WorkflowDoc): string {
const style = `${w.style.terse ? "terse" : "standard"}${model}`;
// Toolkit block: cap the name lists so the workflow stays a driver, not an
// inventory dump — the full index lives in host-index.json; run() routes per task.
const connectorNames = w.toolkit?.connectorNames ?? [];
const connectorSuffix = connectorNames.length
? ` · ${connectorNames.length} connector(s): ${connectorNames.slice(0, 6).join(", ")}`
: "";
const toolkit = w.toolkit
? [
`TOOLKIT: ${w.toolkit.skillCount} skill(s) · ${w.toolkit.agentCount} agent(s) (full index: host-index.json · knitbrain_run routes per task)`,
`TOOLKIT: ${w.toolkit.skillCount} skill(s) · ${w.toolkit.agentCount} agent(s) (full index: host-index.json · knitbrain_run routes per task)${connectorSuffix}`,
w.toolkit.agentNames.length ? `AGENTS: ${w.toolkit.agentNames.slice(0, 8).join(", ")}${w.toolkit.agentNames.length > 8 ? ", …" : ""}` : "",
w.toolkit.skillNames.length ? `SKILLS: ${w.toolkit.skillNames.slice(0, 10).join(", ")}${w.toolkit.skillNames.length > 10 ? ", …" : ""}` : "",
].filter((l) => l !== "")
Expand Down
Loading
Loading