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:
+162
-5
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user