Add web search/fetch, sub-agents, MCP support, git tools, session persistence, and context compaction

Substantially expands locode's tool loop toward feature parity with Claude Code:

- web_search / web_fetch: DuckDuckGo-backed search and URL fetching (HTML stripped to text)
- git_status / git_commit: structured git tools with real diff/status/commit-list previews
  before any mutating operation is confirmed
- agent tool: sub-agents run an isolated, headless tool loop sharing the parent's permissions;
  bounded by an explicit depth guard (no nested sub-agents) and a 120s hard timeout, independent
  of the toolset already excluding `agent` for sub-agent sessions
- MCP client support (stdio + streamable HTTP transports) via the official SDK: locode connects
  to servers configured in `.mcp.json` or via `locode mcp add`, and exposes their tools alongside
  the built-in ones, namespaced as `mcp__<server>__<tool>`
- Session persistence: conversations auto-save after every turn; `--continue`/`--resume`,
  `locode sessions list/rm`, and an interactive resume picker
- Context compaction: tracks real token usage via `stream_options.include_usage` (falling back to
  a char-based estimate), auto-detects the model's context window (Ollama/LM Studio native APIs,
  cached, else a configurable default), and auto-summarizes the conversation at 85% usage or on
  demand via /compact
- Status bar and welcome banner: added a context-usage indicator and fixed several dim-by-default
  colors that were hard to read

Session/loop architecture changed to support all of this: Session now carries its own toolset
(tools + registry + OpenAI schemas) built from local + MCP tools, so runTurn no longer depends on
a fixed global tool list — the same mechanism that lets sub-agents inherit MCP tools while
excluding `agent` itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-07-06 17:53:27 +09:00
co-authored by Claude Sonnet 5
parent 68f2242984
commit 3f1527375c
34 changed files with 2751 additions and 77 deletions
+162 -5
View File
@@ -2,6 +2,9 @@ import { Command } from "commander";
import { makeClient } from "./backend/client.js";
import { ConfigError, resolveBackendConfig, resolveModel } from "./config/config.js";
import { configFilePath, loadStoredConfig, saveStoredConfig, type StoredConfig } from "./config/store.js";
import { loadMergedServers, removeUserServer, saveUserServer, userMcpFilePath } from "./mcp/config.js";
import { isHttpServerConfig } from "./mcp/types.js";
import { deleteSession, listSessions, mostRecentSessionId, sessionsDir } from "./persistence/sessionStore.js";
import { runInkApp } from "./ui/ink/index.js";
const program = new Command();
@@ -24,6 +27,8 @@ addBackendOptions(program)
"native | fallback | auto — auto defaults to native, probing only on /model and /backend",
"auto",
)
.option("-c, --continue", "resume the most recently saved conversation")
.option("-r, --resume [id]", "resume a saved conversation by id, or pick interactively if no id is given")
.action(async (opts) => {
try {
const { baseURL } = resolveBackendConfig(opts);
@@ -31,7 +36,32 @@ addBackendOptions(program)
const model: string | undefined = opts.model;
const suggestedModel = model ? undefined : resolveModel(undefined);
const toolModeOverride = opts.toolMode === "native" || opts.toolMode === "fallback" ? opts.toolMode : undefined;
await runInkApp({ baseURL, model, suggestedModel, cwd: process.cwd(), toolModeOverride });
let resumeSessionId: string | undefined;
let interactiveResume = false;
if (opts.continue) {
resumeSessionId = mostRecentSessionId();
if (!resumeSessionId) {
console.error("No saved sessions found.");
process.exit(1);
}
} else if (opts.resume !== undefined) {
if (typeof opts.resume === "string" && opts.resume.length > 0) {
resumeSessionId = opts.resume;
} else {
interactiveResume = true;
}
}
await runInkApp({
baseURL,
model,
suggestedModel,
cwd: process.cwd(),
toolModeOverride,
resumeSessionId,
interactiveResume,
});
} catch (err) {
if (err instanceof ConfigError) {
console.error(err.message);
@@ -78,14 +108,23 @@ configCmd
configCmd
.command("set <key> <value>")
.description("Persist a config value (backend, model, baseUrl)")
.description("Persist a config value (backend, model, baseUrl, contextWindow)")
.action((key: string, value: string) => {
if (key !== "backend" && key !== "model" && key !== "baseUrl") {
console.error(`Unknown config key "${key}". Valid keys: backend, model, baseUrl`);
if (key !== "backend" && key !== "model" && key !== "baseUrl" && key !== "contextWindow") {
console.error(`Unknown config key "${key}". Valid keys: backend, model, baseUrl, contextWindow`);
process.exit(1);
}
const stored = loadStoredConfig();
stored[key] = value;
if (key === "contextWindow") {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) {
console.error(`contextWindow must be a positive number, got "${value}".`);
process.exit(1);
}
stored.contextWindow = n;
} else {
stored[key] = value;
}
saveStoredConfig(stored);
console.log(`Set ${key} = ${value}`);
});
@@ -97,4 +136,122 @@ configCmd
console.log(configFilePath());
});
const sessionsCmd = program.command("sessions").description("Manage saved conversations");
sessionsCmd
.command("list")
.description("List saved conversations, most recent first")
.action(() => {
const sessions = listSessions();
if (sessions.length === 0) {
console.log("No saved sessions found.");
return;
}
for (const s of sessions) {
console.log(`${s.id} [${s.updatedAt}] (${s.model}, ${s.messageCount} msgs) ${s.title}`);
}
});
sessionsCmd
.command("rm <id>")
.description("Delete a saved conversation")
.action((id: string) => {
if (deleteSession(id)) {
console.log(`Deleted session ${id}.`);
} else {
console.error(`No saved session found with id "${id}".`);
process.exit(1);
}
});
sessionsCmd
.command("path")
.description("Print the directory where sessions are stored")
.action(() => {
console.log(sessionsDir());
});
function collect(value: string, previous: string[]): string[] {
previous.push(value);
return previous;
}
function parseKeyValueList(pairs: string[], separator: string): Record<string, string> {
const result: Record<string, string> = {};
for (const pair of pairs) {
const idx = pair.indexOf(separator);
if (idx === -1) {
console.error(`Expected "KEY${separator}VALUE", got "${pair}".`);
process.exit(1);
}
result[pair.slice(0, idx)] = pair.slice(idx + 1);
}
return result;
}
const mcpCmd = program
.command("mcp")
.description("Manage MCP servers (add/remove apply to your user-level config; project .mcp.json is read too)");
mcpCmd
.command("add <name>")
.description("Add an MCP server: stdio (--command) or remote (--url)")
.option("--command <cmd>", "command to spawn for a stdio server")
.option("--arg <value>", "argument to pass to the command (repeatable)", collect, [] as string[])
.option("--env <KEY=VALUE>", "environment variable for the spawned command (repeatable)", collect, [] as string[])
.option("--url <url>", "URL for a remote (streamable HTTP) server")
.option("--header <KEY:VALUE>", "HTTP header for a remote server (repeatable)", collect, [] as string[])
.action((name: string, opts) => {
if (opts.command && opts.url) {
console.error("Specify either --command (stdio) or --url (remote), not both.");
process.exit(1);
}
if (opts.url) {
saveUserServer(name, { url: opts.url, headers: parseKeyValueList(opts.header, ":") });
} else if (opts.command) {
saveUserServer(name, { command: opts.command, args: opts.arg, env: parseKeyValueList(opts.env, "=") });
} else {
console.error("Specify --command <cmd> for a stdio server or --url <url> for a remote server.");
process.exit(1);
}
console.log(`Added MCP server "${name}".`);
});
mcpCmd
.command("remove <name>")
.description("Remove a user-level MCP server")
.action((name: string) => {
if (removeUserServer(name)) {
console.log(`Removed MCP server "${name}".`);
} else {
console.error(`No user-level MCP server found with name "${name}".`);
process.exit(1);
}
});
mcpCmd
.command("list")
.description("List configured MCP servers (user-level + project .mcp.json)")
.action(() => {
const servers = loadMergedServers(process.cwd());
const names = Object.keys(servers);
if (names.length === 0) {
console.log("No MCP servers configured.");
return;
}
for (const name of names) {
const cfg = servers[name]!;
console.log(
isHttpServerConfig(cfg) ? `${name} [http] ${cfg.url}` : `${name} [stdio] ${cfg.command} ${(cfg.args ?? []).join(" ")}`,
);
}
});
mcpCmd
.command("path")
.description("Print the path to the user-level MCP config file")
.action(() => {
console.log(userMcpFilePath());
});
program.parseAsync();