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:
@@ -34,13 +34,48 @@ List models available from the configured backend:
|
|||||||
locode models
|
locode models
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Every conversation is auto-saved as you go. Resume later:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
locode --continue # resume the most recent conversation
|
||||||
|
locode --resume # pick from a list of saved conversations
|
||||||
|
locode --resume <id> # resume a specific one
|
||||||
|
locode sessions list # see saved conversations (id, model, title) without starting the UI
|
||||||
|
locode sessions rm <id> # delete a saved conversation
|
||||||
|
```
|
||||||
|
|
||||||
|
locode can also use tools from external [MCP](https://modelcontextprotocol.io) servers:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
locode mcp add my-server --command npx --arg -y --arg @some/mcp-server # stdio server
|
||||||
|
locode mcp add my-remote --url https://example.com/mcp # remote (streamable HTTP) server
|
||||||
|
locode mcp list # see configured servers (user-level + project .mcp.json)
|
||||||
|
locode mcp remove <name>
|
||||||
|
```
|
||||||
|
|
||||||
|
Project-level servers can also be checked into a repo via a `.mcp.json` file in its root:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"my-server": { "command": "npx", "args": ["-y", "@some/mcp-server"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
locode connects to every configured server on startup; their tools show up alongside the built-in ones, namespaced as `mcp__<server>__<tool>`.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
locode is a full-screen terminal app built with [Ink](https://github.com/vadimdemedes/ink) (the same React-for-CLI framework Claude Code itself is built with) — it needs a real interactive terminal (piped/redirected input isn't supported). It takes over the terminal's alternate screen buffer (like `vim`/`htop`) — your prior scrollback is restored when you exit. The input box is always pinned to the last row of the window; the conversation fills the space above it and old messages scroll off the top as new ones arrive. Press Ctrl+C or type `/exit` to quit.
|
locode is a full-screen terminal app built with [Ink](https://github.com/vadimdemedes/ink) (the same React-for-CLI framework Claude Code itself is built with) — it needs a real interactive terminal (piped/redirected input isn't supported). It takes over the terminal's alternate screen buffer (like `vim`/`htop`) — your prior scrollback is restored when you exit. The input box is always pinned to the last row of the window; the conversation fills the space above it and old messages scroll off the top as new ones arrive. Press Ctrl+C or type `/exit` to quit.
|
||||||
|
|
||||||
- **Backends**: `--backend ollama` (default) or `--backend lmstudio`, or `--base-url <url>` for anything else that speaks the same API.
|
- **Backends**: `--backend ollama` (default) or `--backend lmstudio`, or `--base-url <url>` for anything else that speaks the same API.
|
||||||
- **Tools**: `read_file`, `list_files`, `grep` run automatically. `write_file`, `edit_file`, and `bash` show a diff/preview in a bordered box and ask you to pick Yes / Yes-always-this-session / No with the arrow keys before running.
|
- **Tools**: `read_file`, `list_files`, `grep`, `web_search`, `web_fetch`, `git_status` run automatically. `write_file`, `edit_file`, `bash`, and `git_commit` show a diff/preview in a bordered box and ask you to pick Yes / Yes-always-this-session / No with the arrow keys before running.
|
||||||
|
- **Git**: `git_status` covers read-only inspection (`status`, `diff`, `log`, `show`, `branches`) and runs automatically. `git_commit` covers `add`, `commit`, `create_branch`, `checkout`, and `push` — each shows the actual diff/status/commits it's about to affect before you confirm (e.g. a commit's preview is the staged diff plus the message, a push's preview is the list of commits it would send).
|
||||||
|
- **Sub-agents**: the `agent` tool lets the model delegate a self-contained task to a fresh, isolated tool loop (same tools, minus `agent` itself — no nested sub-agents) and get back only the final answer, keeping the main conversation's context focused. It shows up as a single `⏺ Agent(description)` / `⎿ Sub-agent finished (...)` line — the sub-agent's own intermediate steps aren't displayed. Any mutating tool calls it makes still go through the same permission prompts as the main conversation.
|
||||||
|
- **MCP servers**: locode connects to any [MCP](https://modelcontextprotocol.io) servers configured via `locode mcp add` or a project's `.mcp.json` (stdio and remote/streamable-HTTP transports), and adds their tools to every session, namespaced as `mcp__<server>__<tool>`. A server's tool is treated as mutating (confirmation required) unless it declares itself read-only via the MCP `readOnlyHint` annotation. One misconfigured server doesn't block the others — check `/mcp` for per-server connection status.
|
||||||
- **Tool-calling mode**: on connect, locode probes whether the model reliably uses native OpenAI-style function calling. If not, it switches to a prompt-based fallback mode where the model is instructed to emit tool calls as fenced ` ```tool_call ``` ` JSON blocks, which locode parses itself. The result is cached per backend+model so future sessions skip the probe. Override with `--tool-mode native|fallback|auto` or the in-session `/mode` command.
|
- **Tool-calling mode**: on connect, locode probes whether the model reliably uses native OpenAI-style function calling. If not, it switches to a prompt-based fallback mode where the model is instructed to emit tool calls as fenced ` ```tool_call ``` ` JSON blocks, which locode parses itself. The result is cached per backend+model so future sessions skip the probe. Override with `--tool-mode native|fallback|auto` or the in-session `/mode` command.
|
||||||
|
- **Context tracking & compaction**: the status bar shows `ctx NN%` — context window usage, from real `usage.prompt_tokens` when the backend reports it (requested via `stream_options.include_usage`), or a `~`-prefixed char-based estimate otherwise. The window size itself is auto-detected (Ollama's `/api/show`, then LM Studio's `/api/v0/models`) and cached per backend+model; falls back to a configurable default (`locode config set contextWindow <n>`, or `$LOCODE_CONTEXT_WINDOW`) if neither responds. At 85% usage, locode automatically asks the model to summarize the conversation and replaces the history with that summary (a notice tells you when this happens) — or trigger it yourself anytime with `/compact`.
|
||||||
|
|
||||||
Note: even models with genuine native tool-calling support occasionally emit a tool call as plain text instead of a real structured call — this is model sampling variance, not a bug. If a turn seems to "describe" a tool call instead of running it, just ask again or try `/mode fallback`.
|
Note: even models with genuine native tool-calling support occasionally emit a tool call as plain text instead of a real structured call — this is model sampling variance, not a bug. If a turn seems to "describe" a tool call instead of running it, just ask again or try `/mode fallback`.
|
||||||
|
|
||||||
@@ -53,6 +88,9 @@ Note: even models with genuine native tool-calling support occasionally emit a t
|
|||||||
/status show current model, backend, tool-call mode, and cwd
|
/status show current model, backend, tool-call mode, and cwd
|
||||||
/tools list available tools
|
/tools list available tools
|
||||||
/permissions list mutating tools allowed for the rest of this session
|
/permissions list mutating tools allowed for the rest of this session
|
||||||
|
/sessions list saved conversations you can resume with --resume
|
||||||
|
/mcp show connected MCP servers and their tool counts
|
||||||
|
/compact summarize the conversation now to free up context
|
||||||
/clear clear conversation history
|
/clear clear conversation history
|
||||||
/help show this help
|
/help show this help
|
||||||
/exit, /quit exit
|
/exit, /quit exit
|
||||||
@@ -65,6 +103,7 @@ Config precedence: CLI flags > env vars (`LOCODE_BACKEND`, `LOCODE_MODEL`, `LOCO
|
|||||||
```sh
|
```sh
|
||||||
locode config set backend ollama
|
locode config set backend ollama
|
||||||
locode config set model qwen3-coder:30b
|
locode config set model qwen3-coder:30b
|
||||||
|
locode config set contextWindow 32768 # fallback size when auto-detection fails
|
||||||
locode config get
|
locode config get
|
||||||
locode config path
|
locode config path
|
||||||
```
|
```
|
||||||
@@ -74,6 +113,9 @@ locode config path
|
|||||||
- Requires a real interactive terminal (TTY) — you can't pipe input into it or run it from a non-interactive script.
|
- Requires a real interactive terminal (TTY) — you can't pipe input into it or run it from a non-interactive script.
|
||||||
- Native tool-calling reliability varies by model and is non-deterministic even for capable models (see above).
|
- Native tool-calling reliability varies by model and is non-deterministic even for capable models (see above).
|
||||||
- No sandboxing beyond the confirmation prompts — mutating tools operate on the real filesystem/shell with the permissions of the user running `locode`. Only approve commands you understand.
|
- No sandboxing beyond the confirmation prompts — mutating tools operate on the real filesystem/shell with the permissions of the user running `locode`. Only approve commands you understand.
|
||||||
- No session save/resume yet — each `locode` run starts a fresh conversation.
|
- Session resume replays prior user/assistant text so you can see it, but it doesn't re-display prior tool-call/tool-result lines from before the resume (the model still has that history — it's just not re-rendered).
|
||||||
- No in-app scrollback — once a message scrolls off the top of the window it's gone until you resize the terminal taller (the conversation itself is still intact and sent to the model; this only affects what you can visually re-read).
|
- No in-app scrollback — once a message scrolls off the top of the window it's gone until you resize the terminal taller (the conversation itself is still intact and sent to the model; this only affects what you can visually re-read).
|
||||||
- Windows shell quoting for the `bash` tool has only had light testing; behavior may differ from Unix shells for complex quoting.
|
- Windows shell quoting for the `bash` tool has only had light testing; behavior may differ from Unix shells for complex quoting.
|
||||||
|
- `git_commit` covers add/commit/create_branch/checkout/push only — no reset, stash, merge, rebase, or branch deletion. Use `bash` for anything beyond that.
|
||||||
|
- MCP tool results only support text content — image/audio/resource content blocks are shown as a placeholder note rather than rendered. Remote (HTTP) MCP servers support static headers (e.g. a bearer token) but not OAuth flows.
|
||||||
|
- Compaction (`/compact` or automatic at 85%) replaces history with a model-generated prose summary — it costs one extra model call and loses tool-call/tool-result detail (the model's own account of what happened survives; the raw record doesn't). The 85% threshold isn't currently configurable.
|
||||||
|
|||||||
Generated
+1053
-2
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@
|
|||||||
"prepublishOnly": "npm run build"
|
"prepublishOnly": "npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"@vscode/ripgrep": "^1.18.0",
|
"@vscode/ripgrep": "^1.18.0",
|
||||||
"commander": "^13.0.0",
|
"commander": "^13.0.0",
|
||||||
"diff": "^9.0.0",
|
"diff": "^9.0.0",
|
||||||
|
|||||||
+156
-12
@@ -1,19 +1,89 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import type { ChatCompletionChunk } from "openai/resources/chat/completions";
|
import type { ChatCompletionChunk } from "openai/resources/chat/completions";
|
||||||
|
import type { CompletionUsage } from "openai/resources/completions";
|
||||||
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
|
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
|
||||||
import type { AgentEventHandler } from "./events.js";
|
import type { AgentEventHandler } from "./events.js";
|
||||||
import { TOOL_REGISTRY, TOOLS } from "../tools/index.js";
|
import { buildToolSet, type ToolSet } from "../tools/toolset.js";
|
||||||
|
import type { SubAgentTask } from "../tools/types.js";
|
||||||
import { FALLBACK_RETRY_NUDGE } from "../toolcalling/fallbackPrompt.js";
|
import { FALLBACK_RETRY_NUDGE } from "../toolcalling/fallbackPrompt.js";
|
||||||
import { parseFallbackToolCalls } from "../toolcalling/fallbackParser.js";
|
import { parseFallbackToolCalls } from "../toolcalling/fallbackParser.js";
|
||||||
import { resolveToolCall, toOpenAITools } from "../toolcalling/nativeAdapter.js";
|
import { resolveToolCall } from "../toolcalling/nativeAdapter.js";
|
||||||
import { resolveToolInvocation, runTool, type ResolvedToolCall } from "../toolcalling/resolve.js";
|
import { resolveToolInvocation, runTool, type ResolvedToolCall } from "../toolcalling/resolve.js";
|
||||||
import { formatCallLabel, summarizeToolResult } from "../ui/toolSummary.js";
|
import { formatCallLabel, summarizeToolResult } from "../ui/toolSummary.js";
|
||||||
|
import { estimateTokens } from "../utils/tokens.js";
|
||||||
|
import { buildSystemPrompt } from "./systemPrompt.js";
|
||||||
import type { Session } from "./session.js";
|
import type { Session } from "./session.js";
|
||||||
|
|
||||||
export class AgentError extends Error {}
|
export class AgentError extends Error {}
|
||||||
|
|
||||||
const openaiTools = toOpenAITools(TOOLS);
|
|
||||||
const MAX_MALFORMED_RETRIES = 2;
|
const MAX_MALFORMED_RETRIES = 2;
|
||||||
|
|
||||||
|
// Sub-agent safety limits. The toolset already excludes `agent` for sub-agents (so a model can't
|
||||||
|
// spawn nested sub-agents through normal tool use), but these are independent, explicit backstops
|
||||||
|
// that hold even if that filtering were ever bypassed or a future change loosened it.
|
||||||
|
const MAX_SUBAGENT_DEPTH = 1;
|
||||||
|
const SUBAGENT_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
/** Fraction of the context window at which the caller should auto-compact (see shouldAutoCompact). */
|
||||||
|
export const AUTO_COMPACT_THRESHOLD = 0.85;
|
||||||
|
|
||||||
|
function updateContextTracking(session: Session, promptTokens: number | null | undefined): void {
|
||||||
|
if (typeof promptTokens === "number") {
|
||||||
|
session.lastContextTokens = promptTokens;
|
||||||
|
session.lastContextTokensIsEstimate = false;
|
||||||
|
} else {
|
||||||
|
session.lastContextTokens = estimateTokens(session.messages);
|
||||||
|
session.lastContextTokensIsEstimate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contextUsageRatio(session: Session): number {
|
||||||
|
return session.lastContextTokens / session.contextWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldAutoCompact(session: Session): boolean {
|
||||||
|
return contextUsageRatio(session) >= AUTO_COMPACT_THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the conversation history with a model-generated summary of everything so far, to free
|
||||||
|
* up context. Runs as a plain (non-tool-calling) request so the model just produces prose, not
|
||||||
|
* more tool calls. The new history is just [system, a synthetic assistant "recap"] — framing the
|
||||||
|
* summary as an assistant turn keeps proper role alternation with whatever real user message
|
||||||
|
* follows next.
|
||||||
|
*/
|
||||||
|
export async function compactSession(session: Session): Promise<void> {
|
||||||
|
const requestMessages: ChatCompletionMessageParam[] = [
|
||||||
|
...session.messages,
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content:
|
||||||
|
"Summarize this entire conversation so far, concisely but completely: the user's goals, key decisions " +
|
||||||
|
"made, files created/changed and why, current task state, and anything still outstanding. Write it as " +
|
||||||
|
"background context for continuing the conversation — plain prose, no meta-commentary about summarizing.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const res = await session.client.chat.completions.create({
|
||||||
|
model: session.model,
|
||||||
|
messages: requestMessages,
|
||||||
|
stream: false,
|
||||||
|
max_tokens: 1024,
|
||||||
|
});
|
||||||
|
const summary = res.choices[0]?.message?.content;
|
||||||
|
if (!summary) {
|
||||||
|
throw new AgentError("Compaction failed: the model returned no summary.");
|
||||||
|
}
|
||||||
|
|
||||||
|
session.messages = [
|
||||||
|
{ role: "system", content: buildSystemPrompt(session.toolset.tools, session.mode) },
|
||||||
|
{ role: "assistant", content: `[Earlier conversation compacted to save context]\n\n${summary}` },
|
||||||
|
];
|
||||||
|
// res.usage describes the old (now-discarded) prompt, not the new shorter history — estimate fresh.
|
||||||
|
session.lastContextTokens = estimateTokens(session.messages);
|
||||||
|
session.lastContextTokensIsEstimate = true;
|
||||||
|
}
|
||||||
|
|
||||||
/** Accumulates streaming tool-call deltas into complete tool calls. */
|
/** Accumulates streaming tool-call deltas into complete tool calls. */
|
||||||
interface AccumulatedToolCall {
|
interface AccumulatedToolCall {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -35,7 +105,7 @@ async function gateAndRun(
|
|||||||
const { tool, args } = resolved;
|
const { tool, args } = resolved;
|
||||||
emit({ type: "tool_call", label: formatCallLabel(tool.name, args) });
|
emit({ type: "tool_call", label: formatCallLabel(tool.name, args) });
|
||||||
|
|
||||||
const ctx = { cwd: session.cwd };
|
const ctx = { cwd: session.cwd, runSubAgent: (task: SubAgentTask) => runSubAgentTurn(session, task) };
|
||||||
|
|
||||||
if (tool.mutating && !session.permissions.isAutoApproved(tool.name)) {
|
if (tool.mutating && !session.permissions.isAutoApproved(tool.name)) {
|
||||||
const preview = tool.preview ? await tool.preview(args, ctx) : undefined;
|
const preview = tool.preview ? await tool.preview(args, ctx) : undefined;
|
||||||
@@ -55,6 +125,64 @@ async function gateAndRun(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a sub-agent as a fresh, isolated turn loop that shares the parent's client/model/cwd/
|
||||||
|
* permissions, but starts with no conversation history beyond the delegated task. Runs headless —
|
||||||
|
* its own streaming text and tool calls aren't surfaced to the UI, only its final text is (via the
|
||||||
|
* `agent` tool's result) — so mutating tool calls it makes still go through the shared
|
||||||
|
* PermissionManager/confirm and will still prompt the user, they just aren't shown mid-flight.
|
||||||
|
*
|
||||||
|
* Bounded by two independent safety limits (see MAX_SUBAGENT_DEPTH/SUBAGENT_TIMEOUT_MS above):
|
||||||
|
* an explicit depth check (on top of the toolset already excluding `agent` for sub-agents) and a
|
||||||
|
* hard wall-clock timeout, so a misbehaving model can't hang the parent turn indefinitely.
|
||||||
|
*/
|
||||||
|
async function runSubAgentTurn(parent: Session, task: SubAgentTask): Promise<string> {
|
||||||
|
if (parent.subAgentDepth >= MAX_SUBAGENT_DEPTH) {
|
||||||
|
throw new Error(`Sub-agents cannot spawn further sub-agents (max depth ${MAX_SUBAGENT_DEPTH}).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derived from the parent's own toolset (so MCP tools carry over) minus `agent` itself —
|
||||||
|
// sub-agents can't spawn further sub-agents, keeping recursion bounded to one level.
|
||||||
|
const subToolset = buildToolSet(parent.toolset.tools.filter((t) => t.name !== "agent"));
|
||||||
|
const subMessages: ChatCompletionMessageParam[] = [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: `${buildSystemPrompt(subToolset.tools, parent.mode)}\n\nYou are a sub-agent handling one focused task delegated by another assistant. Only the final text you return will be seen — not your intermediate tool calls — so make your answer complete and self-contained.`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const subSession: Session = {
|
||||||
|
id: randomUUID(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
client: parent.client,
|
||||||
|
model: parent.model,
|
||||||
|
cwd: parent.cwd,
|
||||||
|
mode: parent.mode,
|
||||||
|
messages: subMessages,
|
||||||
|
maxIterations: parent.maxIterations,
|
||||||
|
permissions: parent.permissions,
|
||||||
|
confirm: parent.confirm,
|
||||||
|
toolset: subToolset,
|
||||||
|
subAgentDepth: parent.subAgentDepth + 1,
|
||||||
|
contextWindow: parent.contextWindow,
|
||||||
|
contextWindowIsEstimate: parent.contextWindowIsEstimate,
|
||||||
|
lastContextTokens: estimateTokens(subMessages),
|
||||||
|
lastContextTokensIsEstimate: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout>;
|
||||||
|
const timeout = new Promise<never>((_, reject) => {
|
||||||
|
timeoutId = setTimeout(
|
||||||
|
() => reject(new Error(`Sub-agent timed out after ${SUBAGENT_TIMEOUT_MS / 1000}s.`)),
|
||||||
|
SUBAGENT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return await Promise.race([runTurn(subSession, task.prompt, () => {}, subToolset), timeout]);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process a completed (non-streaming) response — handles both native tool_calls
|
* Process a completed (non-streaming) response — handles both native tool_calls
|
||||||
* and plain text, shared by the streaming path (after accumulation) and the
|
* and plain text, shared by the streaming path (after accumulation) and the
|
||||||
@@ -64,6 +192,7 @@ async function handleCompletedMessage(
|
|||||||
message: any,
|
message: any,
|
||||||
session: Session,
|
session: Session,
|
||||||
emit: AgentEventHandler,
|
emit: AgentEventHandler,
|
||||||
|
toolset: ToolSet,
|
||||||
): Promise<{ text: string; hadToolCalls: boolean; malformed?: boolean }> {
|
): Promise<{ text: string; hadToolCalls: boolean; malformed?: boolean }> {
|
||||||
// Native tool calls
|
// Native tool calls
|
||||||
if (session.mode === "native" && message.tool_calls?.length) {
|
if (session.mode === "native" && message.tool_calls?.length) {
|
||||||
@@ -74,7 +203,7 @@ async function handleCompletedMessage(
|
|||||||
} as ChatCompletionMessageParam);
|
} as ChatCompletionMessageParam);
|
||||||
|
|
||||||
for (const call of message.tool_calls) {
|
for (const call of message.tool_calls) {
|
||||||
const resolved = resolveToolCall(call as any, TOOL_REGISTRY);
|
const resolved = resolveToolCall(call as any, toolset.registry);
|
||||||
const label = call.type === "function" ? `${call.function.name}(${call.function.arguments})` : call.type;
|
const label = call.type === "function" ? `${call.function.name}(${call.function.arguments})` : call.type;
|
||||||
const result = await gateAndRun(resolved, label, session, emit);
|
const result = await gateAndRun(resolved, label, session, emit);
|
||||||
session.messages.push({
|
session.messages.push({
|
||||||
@@ -96,7 +225,7 @@ async function handleCompletedMessage(
|
|||||||
if (parsed.calls.length) {
|
if (parsed.calls.length) {
|
||||||
session.messages.push({ role: "assistant", content: text });
|
session.messages.push({ role: "assistant", content: text });
|
||||||
for (const call of parsed.calls) {
|
for (const call of parsed.calls) {
|
||||||
const resolved = resolveToolInvocation(call.name, call.arguments, TOOL_REGISTRY);
|
const resolved = resolveToolInvocation(call.name, call.arguments, toolset.registry);
|
||||||
const label = `${call.name}(${JSON.stringify(call.arguments)})`;
|
const label = `${call.name}(${JSON.stringify(call.arguments)})`;
|
||||||
const result = await gateAndRun(resolved, label, session, emit);
|
const result = await gateAndRun(resolved, label, session, emit);
|
||||||
session.messages.push({
|
session.messages.push({
|
||||||
@@ -116,7 +245,12 @@ async function handleCompletedMessage(
|
|||||||
return { text, hadToolCalls: false };
|
return { text, hadToolCalls: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runTurn(session: Session, userInput: string, emit: AgentEventHandler): Promise<string> {
|
export async function runTurn(
|
||||||
|
session: Session,
|
||||||
|
userInput: string,
|
||||||
|
emit: AgentEventHandler,
|
||||||
|
toolset: ToolSet = session.toolset,
|
||||||
|
): Promise<string> {
|
||||||
session.messages.push({ role: "user", content: userInput });
|
session.messages.push({ role: "user", content: userInput });
|
||||||
let malformedRetries = 0;
|
let malformedRetries = 0;
|
||||||
|
|
||||||
@@ -126,16 +260,23 @@ export async function runTurn(session: Session, userInput: string, emit: AgentEv
|
|||||||
let finishReason: string | null = null;
|
let finishReason: string | null = null;
|
||||||
const accumulatedToolCalls: AccumulatedToolCall[] = [];
|
const accumulatedToolCalls: AccumulatedToolCall[] = [];
|
||||||
|
|
||||||
|
let usage: CompletionUsage | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stream = await session.client.chat.completions.create({
|
const stream = await session.client.chat.completions.create({
|
||||||
model: session.model,
|
model: session.model,
|
||||||
messages: session.messages,
|
messages: session.messages,
|
||||||
tools: session.mode === "native" ? openaiTools : undefined,
|
tools: session.mode === "native" ? toolset.openaiTools : undefined,
|
||||||
stream: true,
|
stream: true,
|
||||||
|
stream_options: { include_usage: true },
|
||||||
max_tokens: 4096,
|
max_tokens: 4096,
|
||||||
});
|
});
|
||||||
|
|
||||||
for await (const chunk of stream) {
|
for await (const chunk of stream) {
|
||||||
|
// The usage-carrying final chunk has an empty `choices` array per spec — read it before
|
||||||
|
// the early-continue below would otherwise skip straight past it.
|
||||||
|
if (chunk.usage) usage = chunk.usage;
|
||||||
|
|
||||||
const choice = chunk.choices[0];
|
const choice = chunk.choices[0];
|
||||||
if (!choice) continue;
|
if (!choice) continue;
|
||||||
|
|
||||||
@@ -177,6 +318,8 @@ export async function runTurn(session: Session, userInput: string, emit: AgentEv
|
|||||||
throw streamErr;
|
throw streamErr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateContextTracking(session, usage?.prompt_tokens);
|
||||||
|
|
||||||
// --- Handle native tool calls from streaming ---
|
// --- Handle native tool calls from streaming ---
|
||||||
if (session.mode === "native" && accumulatedToolCalls.length > 0) {
|
if (session.mode === "native" && accumulatedToolCalls.length > 0) {
|
||||||
// Validate accumulated arguments — if any fail to parse (Ollama streaming bug),
|
// Validate accumulated arguments — if any fail to parse (Ollama streaming bug),
|
||||||
@@ -196,12 +339,13 @@ export async function runTurn(session: Session, userInput: string, emit: AgentEv
|
|||||||
const res = await session.client.chat.completions.create({
|
const res = await session.client.chat.completions.create({
|
||||||
model: session.model,
|
model: session.model,
|
||||||
messages: session.messages,
|
messages: session.messages,
|
||||||
tools: openaiTools,
|
tools: toolset.openaiTools,
|
||||||
stream: false,
|
stream: false,
|
||||||
max_tokens: 4096,
|
max_tokens: 4096,
|
||||||
});
|
});
|
||||||
const message = res.choices[0]?.message;
|
const message = res.choices[0]?.message;
|
||||||
if (!message) throw new AgentError("Empty response from model.");
|
if (!message) throw new AgentError("Empty response from model.");
|
||||||
|
updateContextTracking(session, res.usage?.prompt_tokens);
|
||||||
|
|
||||||
// If we streamed partial text, we need to tell the UI the text is done
|
// If we streamed partial text, we need to tell the UI the text is done
|
||||||
// (it may have been partially displayed). The non-streaming response
|
// (it may have been partially displayed). The non-streaming response
|
||||||
@@ -210,7 +354,7 @@ export async function runTurn(session: Session, userInput: string, emit: AgentEv
|
|||||||
// The partial stream text is superseded by the non-streaming response
|
// The partial stream text is superseded by the non-streaming response
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await handleCompletedMessage(message, session, emit);
|
const result = await handleCompletedMessage(message, session, emit, toolset);
|
||||||
if (result.hadToolCalls) continue;
|
if (result.hadToolCalls) continue;
|
||||||
|
|
||||||
// Non-tool-call text from the retry
|
// Non-tool-call text from the retry
|
||||||
@@ -235,7 +379,7 @@ export async function runTurn(session: Session, userInput: string, emit: AgentEv
|
|||||||
for (const tc of accumulatedToolCalls) {
|
for (const tc of accumulatedToolCalls) {
|
||||||
const resolved = resolveToolCall(
|
const resolved = resolveToolCall(
|
||||||
{ id: tc.id, type: "function", function: { name: tc.name, arguments: tc.arguments } } as any,
|
{ id: tc.id, type: "function", function: { name: tc.name, arguments: tc.arguments } } as any,
|
||||||
TOOL_REGISTRY,
|
toolset.registry,
|
||||||
);
|
);
|
||||||
const label = `${tc.name}(${tc.arguments})`;
|
const label = `${tc.name}(${tc.arguments})`;
|
||||||
const result = await gateAndRun(resolved, label, session, emit);
|
const result = await gateAndRun(resolved, label, session, emit);
|
||||||
@@ -258,7 +402,7 @@ export async function runTurn(session: Session, userInput: string, emit: AgentEv
|
|||||||
emit({ type: "text_done", fullText });
|
emit({ type: "text_done", fullText });
|
||||||
session.messages.push({ role: "assistant", content: fullText });
|
session.messages.push({ role: "assistant", content: fullText });
|
||||||
for (const call of parsed.calls) {
|
for (const call of parsed.calls) {
|
||||||
const resolved = resolveToolInvocation(call.name, call.arguments, TOOL_REGISTRY);
|
const resolved = resolveToolInvocation(call.name, call.arguments, toolset.registry);
|
||||||
const label = `${call.name}(${JSON.stringify(call.arguments)})`;
|
const label = `${call.name}(${JSON.stringify(call.arguments)})`;
|
||||||
const result = await gateAndRun(resolved, label, session, emit);
|
const result = await gateAndRun(resolved, label, session, emit);
|
||||||
session.messages.push({
|
session.messages.push({
|
||||||
|
|||||||
+89
-2
@@ -1,12 +1,20 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import type OpenAI from "openai";
|
import type OpenAI from "openai";
|
||||||
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
|
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
|
||||||
import type { ToolCallMode } from "../backend/capabilityProbe.js";
|
import type { ToolCallMode } from "../backend/capabilityProbe.js";
|
||||||
|
import { DEFAULT_CONTEXT_WINDOW } from "../config/defaults.js";
|
||||||
|
import type { SessionRecord } from "../persistence/sessionStore.js";
|
||||||
import { PermissionManager } from "../permissions/permissionManager.js";
|
import { PermissionManager } from "../permissions/permissionManager.js";
|
||||||
import type { ConfirmFn } from "../permissions/types.js";
|
import type { ConfirmFn } from "../permissions/types.js";
|
||||||
import { TOOLS } from "../tools/index.js";
|
import { TOOLS } from "../tools/index.js";
|
||||||
|
import { buildToolSet, type ToolSet } from "../tools/toolset.js";
|
||||||
|
import type { ToolDef } from "../tools/types.js";
|
||||||
|
import { estimateTokens } from "../utils/tokens.js";
|
||||||
import { buildSystemPrompt } from "./systemPrompt.js";
|
import { buildSystemPrompt } from "./systemPrompt.js";
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
client: OpenAI;
|
client: OpenAI;
|
||||||
model: string;
|
model: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
@@ -15,6 +23,21 @@ export interface Session {
|
|||||||
maxIterations: number;
|
maxIterations: number;
|
||||||
permissions: PermissionManager;
|
permissions: PermissionManager;
|
||||||
confirm: ConfirmFn;
|
confirm: ConfirmFn;
|
||||||
|
/** Local tools plus any dynamically-discovered ones (currently: MCP) available for this session. */
|
||||||
|
toolset: ToolSet;
|
||||||
|
/** 0 for a normal session; incremented for each level of sub-agent nesting (capped at
|
||||||
|
* MAX_SUBAGENT_DEPTH in agent/loop.ts, independent of the toolset already excluding `agent`). */
|
||||||
|
subAgentDepth: number;
|
||||||
|
/** The model's context window in tokens — auto-detected where possible (backend/contextWindow.ts),
|
||||||
|
* otherwise a configured/hardcoded fallback (see contextWindowIsEstimate). */
|
||||||
|
contextWindow: number;
|
||||||
|
/** True when `contextWindow` is a fallback rather than an actual value read from the backend. */
|
||||||
|
contextWindowIsEstimate: boolean;
|
||||||
|
/** Tokens consumed by the last request's prompt (system + history), i.e. current context usage.
|
||||||
|
* From real `usage.prompt_tokens` when the backend reports it, else a char-based estimate. */
|
||||||
|
lastContextTokens: number;
|
||||||
|
/** True when `lastContextTokens` is a char-based estimate rather than real usage stats. */
|
||||||
|
lastContextTokensIsEstimate: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSession(
|
export function createSession(
|
||||||
@@ -23,24 +46,88 @@ export function createSession(
|
|||||||
cwd: string,
|
cwd: string,
|
||||||
confirm: ConfirmFn,
|
confirm: ConfirmFn,
|
||||||
mode: ToolCallMode,
|
mode: ToolCallMode,
|
||||||
|
tools: ToolDef[] = TOOLS,
|
||||||
|
contextWindow: number = DEFAULT_CONTEXT_WINDOW,
|
||||||
|
contextWindowIsEstimate: boolean = true,
|
||||||
): Session {
|
): Session {
|
||||||
|
const toolset = buildToolSet(tools);
|
||||||
|
const messages: ChatCompletionMessageParam[] = [{ role: "system", content: buildSystemPrompt(toolset.tools, mode) }];
|
||||||
return {
|
return {
|
||||||
|
id: randomUUID(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
client,
|
client,
|
||||||
model,
|
model,
|
||||||
cwd,
|
cwd,
|
||||||
mode,
|
mode,
|
||||||
messages: [{ role: "system", content: buildSystemPrompt(TOOLS, mode) }],
|
messages,
|
||||||
maxIterations: 8,
|
maxIterations: 8,
|
||||||
permissions: new PermissionManager(),
|
permissions: new PermissionManager(),
|
||||||
confirm,
|
confirm,
|
||||||
|
toolset,
|
||||||
|
subAgentDepth: 0,
|
||||||
|
contextWindow,
|
||||||
|
contextWindowIsEstimate,
|
||||||
|
lastContextTokens: estimateTokens(messages),
|
||||||
|
lastContextTokensIsEstimate: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rebuilds the system prompt from the current tool list rather than trusting the saved one,
|
||||||
|
* so a resumed session always reflects whatever tools locode has today (including MCP tools). */
|
||||||
|
export function createSessionFromRecord(
|
||||||
|
client: OpenAI,
|
||||||
|
record: SessionRecord,
|
||||||
|
cwd: string,
|
||||||
|
confirm: ConfirmFn,
|
||||||
|
tools: ToolDef[] = TOOLS,
|
||||||
|
contextWindow: number = DEFAULT_CONTEXT_WINDOW,
|
||||||
|
contextWindowIsEstimate: boolean = true,
|
||||||
|
): Session {
|
||||||
|
const toolset = buildToolSet(tools);
|
||||||
|
const messages: ChatCompletionMessageParam[] = [
|
||||||
|
{ role: "system", content: buildSystemPrompt(toolset.tools, record.mode) },
|
||||||
|
...record.messages,
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
client,
|
||||||
|
model: record.model,
|
||||||
|
cwd,
|
||||||
|
mode: record.mode,
|
||||||
|
messages,
|
||||||
|
maxIterations: 8,
|
||||||
|
permissions: new PermissionManager(),
|
||||||
|
confirm,
|
||||||
|
toolset,
|
||||||
|
subAgentDepth: 0,
|
||||||
|
contextWindow,
|
||||||
|
contextWindowIsEstimate,
|
||||||
|
lastContextTokens: estimateTokens(messages),
|
||||||
|
lastContextTokensIsEstimate: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toSessionRecord(session: Session, baseURL: string): SessionRecord {
|
||||||
|
return {
|
||||||
|
id: session.id,
|
||||||
|
createdAt: session.createdAt,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
cwd: session.cwd,
|
||||||
|
baseURL,
|
||||||
|
model: session.model,
|
||||||
|
mode: session.mode,
|
||||||
|
messages: session.messages.slice(1),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetSession(session: Session): void {
|
export function resetSession(session: Session): void {
|
||||||
session.messages = [session.messages[0] as ChatCompletionMessageParam];
|
session.messages = [session.messages[0] as ChatCompletionMessageParam];
|
||||||
|
session.lastContextTokens = estimateTokens(session.messages);
|
||||||
|
session.lastContextTokensIsEstimate = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setMode(session: Session, mode: ToolCallMode): void {
|
export function setMode(session: Session, mode: ToolCallMode): void {
|
||||||
session.mode = mode;
|
session.mode = mode;
|
||||||
session.messages[0] = { role: "system", content: buildSystemPrompt(TOOLS, mode) };
|
session.messages[0] = { role: "system", content: buildSystemPrompt(session.toolset.tools, mode) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { resolveContextWindowDefault } from "../config/config.js";
|
||||||
|
import { getCachedContextWindow, setCachedContextWindow } from "./contextWindowCache.js";
|
||||||
|
|
||||||
|
function stripV1(baseURL: string): string {
|
||||||
|
return baseURL.replace(/\/v1\/?$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ollama's native (non-OpenAI-compatible) endpoint exposes the model's real context length,
|
||||||
|
* nested under an architecture-prefixed key (e.g. "llama.context_length") that we have to look
|
||||||
|
* up dynamically via the architecture name it also reports. */
|
||||||
|
async function detectOllamaContextWindow(baseURL: string, model: string): Promise<number | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${stripV1(baseURL)}/api/show`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ model }),
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = (await res.json()) as { model_info?: Record<string, unknown> };
|
||||||
|
const info = data.model_info;
|
||||||
|
if (!info) return null;
|
||||||
|
const architecture = info["general.architecture"];
|
||||||
|
if (typeof architecture !== "string") return null;
|
||||||
|
const contextLength = info[`${architecture}.context_length`];
|
||||||
|
return typeof contextLength === "number" ? contextLength : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** LM Studio's native REST API (distinct from its OpenAI-compatible one) reports the context
|
||||||
|
* length a loaded model was actually started with, or its maximum otherwise. */
|
||||||
|
async function detectLmStudioContextWindow(baseURL: string, model: string): Promise<number | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${stripV1(baseURL)}/api/v0/models`);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = (await res.json()) as { data?: Array<Record<string, unknown>> };
|
||||||
|
const match = data.data?.find((m) => m.id === model);
|
||||||
|
const length = match?.loaded_context_length ?? match?.max_context_length;
|
||||||
|
return typeof length === "number" ? length : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort: tries each backend's own way of reporting context length in turn, since we can't
|
||||||
|
* assume which one is actually running behind an OpenAI-compatible baseURL. Returns null (rather
|
||||||
|
* than guessing) if neither responds usefully — callers should fall back to a configured default. */
|
||||||
|
export async function detectContextWindow(baseURL: string, model: string): Promise<number | null> {
|
||||||
|
const ollama = await detectOllamaContextWindow(baseURL, model);
|
||||||
|
if (ollama !== null) return ollama;
|
||||||
|
return detectLmStudioContextWindow(baseURL, model);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedContextWindow {
|
||||||
|
value: number;
|
||||||
|
/** false only when this came from an actual backend lookup, not the configured/hardcoded default. */
|
||||||
|
isEstimate: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cache-then-detect-then-default resolution, so repeat sessions with the same backend+model skip
|
||||||
|
* the network round-trip. */
|
||||||
|
export async function resolveContextWindow(baseURL: string, model: string): Promise<ResolvedContextWindow> {
|
||||||
|
const cached = getCachedContextWindow(baseURL, model);
|
||||||
|
if (cached !== undefined) return { value: cached, isEstimate: false };
|
||||||
|
|
||||||
|
const detected = await detectContextWindow(baseURL, model);
|
||||||
|
if (detected !== null) {
|
||||||
|
setCachedContextWindow(baseURL, model, detected);
|
||||||
|
return { value: detected, isEstimate: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { value: resolveContextWindowDefault(), isEstimate: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import envPaths from "env-paths";
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const paths = envPaths("locode", { suffix: "" });
|
||||||
|
const cacheFile = path.join(paths.config, "context-windows.json");
|
||||||
|
|
||||||
|
type Cache = Record<string, number>;
|
||||||
|
|
||||||
|
function keyFor(baseURL: string, model: string): string {
|
||||||
|
return `${baseURL}::${model}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let memoryCache: Cache | null = null;
|
||||||
|
|
||||||
|
function load(): Cache {
|
||||||
|
if (memoryCache) return memoryCache;
|
||||||
|
if (!existsSync(cacheFile)) {
|
||||||
|
memoryCache = {};
|
||||||
|
return memoryCache;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
memoryCache = JSON.parse(readFileSync(cacheFile, "utf-8")) as Cache;
|
||||||
|
return memoryCache;
|
||||||
|
} catch {
|
||||||
|
memoryCache = {};
|
||||||
|
return memoryCache;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(cache: Cache): void {
|
||||||
|
memoryCache = cache;
|
||||||
|
mkdirSync(paths.config, { recursive: true });
|
||||||
|
writeFileSync(cacheFile, JSON.stringify(cache, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCachedContextWindow(baseURL: string, model: string): number | undefined {
|
||||||
|
return load()[keyFor(baseURL, model)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCachedContextWindow(baseURL: string, model: string, contextWindow: number): void {
|
||||||
|
const cache = load();
|
||||||
|
cache[keyFor(baseURL, model)] = contextWindow;
|
||||||
|
save(cache);
|
||||||
|
}
|
||||||
+162
-5
@@ -2,6 +2,9 @@ import { Command } from "commander";
|
|||||||
import { makeClient } from "./backend/client.js";
|
import { makeClient } from "./backend/client.js";
|
||||||
import { ConfigError, resolveBackendConfig, resolveModel } from "./config/config.js";
|
import { ConfigError, resolveBackendConfig, resolveModel } from "./config/config.js";
|
||||||
import { configFilePath, loadStoredConfig, saveStoredConfig, type StoredConfig } from "./config/store.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";
|
import { runInkApp } from "./ui/ink/index.js";
|
||||||
|
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
@@ -24,6 +27,8 @@ addBackendOptions(program)
|
|||||||
"native | fallback | auto — auto defaults to native, probing only on /model and /backend",
|
"native | fallback | auto — auto defaults to native, probing only on /model and /backend",
|
||||||
"auto",
|
"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) => {
|
.action(async (opts) => {
|
||||||
try {
|
try {
|
||||||
const { baseURL } = resolveBackendConfig(opts);
|
const { baseURL } = resolveBackendConfig(opts);
|
||||||
@@ -31,7 +36,32 @@ addBackendOptions(program)
|
|||||||
const model: string | undefined = opts.model;
|
const model: string | undefined = opts.model;
|
||||||
const suggestedModel = model ? undefined : resolveModel(undefined);
|
const suggestedModel = model ? undefined : resolveModel(undefined);
|
||||||
const toolModeOverride = opts.toolMode === "native" || opts.toolMode === "fallback" ? opts.toolMode : 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) {
|
} catch (err) {
|
||||||
if (err instanceof ConfigError) {
|
if (err instanceof ConfigError) {
|
||||||
console.error(err.message);
|
console.error(err.message);
|
||||||
@@ -78,14 +108,23 @@ configCmd
|
|||||||
|
|
||||||
configCmd
|
configCmd
|
||||||
.command("set <key> <value>")
|
.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) => {
|
.action((key: string, value: string) => {
|
||||||
if (key !== "backend" && key !== "model" && key !== "baseUrl") {
|
if (key !== "backend" && key !== "model" && key !== "baseUrl" && key !== "contextWindow") {
|
||||||
console.error(`Unknown config key "${key}". Valid keys: backend, model, baseUrl`);
|
console.error(`Unknown config key "${key}". Valid keys: backend, model, baseUrl, contextWindow`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const stored = loadStoredConfig();
|
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);
|
saveStoredConfig(stored);
|
||||||
console.log(`Set ${key} = ${value}`);
|
console.log(`Set ${key} = ${value}`);
|
||||||
});
|
});
|
||||||
@@ -97,4 +136,122 @@ configCmd
|
|||||||
console.log(configFilePath());
|
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();
|
program.parseAsync();
|
||||||
|
|||||||
+10
-1
@@ -1,4 +1,4 @@
|
|||||||
import { KNOWN_BACKENDS, type BackendName } from "./defaults.js";
|
import { DEFAULT_CONTEXT_WINDOW, KNOWN_BACKENDS, type BackendName } from "./defaults.js";
|
||||||
import { loadStoredConfig } from "./store.js";
|
import { loadStoredConfig } from "./store.js";
|
||||||
|
|
||||||
export class ConfigError extends Error {}
|
export class ConfigError extends Error {}
|
||||||
@@ -32,3 +32,12 @@ export function resolveModel(cliModel?: string): string | undefined {
|
|||||||
const stored = loadStoredConfig();
|
const stored = loadStoredConfig();
|
||||||
return cliModel ?? process.env.LOCODE_MODEL ?? stored.model;
|
return cliModel ?? process.env.LOCODE_MODEL ?? stored.model;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The fallback context window size to use when it can't be auto-detected from the backend. */
|
||||||
|
export function resolveContextWindowDefault(): number {
|
||||||
|
const stored = loadStoredConfig();
|
||||||
|
const envValue = Number(process.env.LOCODE_CONTEXT_WINDOW);
|
||||||
|
if (Number.isFinite(envValue) && envValue > 0) return envValue;
|
||||||
|
if (typeof stored.contextWindow === "number" && stored.contextWindow > 0) return stored.contextWindow;
|
||||||
|
return DEFAULT_CONTEXT_WINDOW;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,3 +7,7 @@ export const KNOWN_BACKENDS = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type BackendName = keyof typeof KNOWN_BACKENDS;
|
export type BackendName = keyof typeof KNOWN_BACKENDS;
|
||||||
|
|
||||||
|
/** Used when the context window can't be auto-detected from the backend (see backend/contextWindow.ts)
|
||||||
|
* and the user hasn't configured one — a conservative size common among smaller local models. */
|
||||||
|
export const DEFAULT_CONTEXT_WINDOW = 8192;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export interface StoredConfig {
|
|||||||
backend?: string;
|
backend?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
|
/** Fallback context window size (in tokens) used when it can't be auto-detected from the backend. */
|
||||||
|
contextWindow?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const paths = envPaths("locode", { suffix: "" });
|
const paths = envPaths("locode", { suffix: "" });
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||||
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||||
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||||
|
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||||
|
import { isHttpServerConfig, type McpServerConfig } from "./types.js";
|
||||||
|
|
||||||
|
export interface McpToolInfo {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
inputSchema: Record<string, unknown>;
|
||||||
|
annotations?: { readOnlyHint?: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectedMcpServer {
|
||||||
|
name: string;
|
||||||
|
client: Client;
|
||||||
|
transport: Transport;
|
||||||
|
tools: McpToolInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connectMcpServer(name: string, config: McpServerConfig): Promise<ConnectedMcpServer> {
|
||||||
|
const transport: Transport = isHttpServerConfig(config)
|
||||||
|
? new StreamableHTTPClientTransport(new URL(config.url), {
|
||||||
|
requestInit: config.headers ? { headers: config.headers } : undefined,
|
||||||
|
})
|
||||||
|
: new StdioClientTransport({
|
||||||
|
command: config.command,
|
||||||
|
args: config.args,
|
||||||
|
env: config.env,
|
||||||
|
// Default is "inherit", which would leak the child's stderr straight into the terminal
|
||||||
|
// and corrupt Ink's alternate-screen UI. Pipe it instead so it's just discarded.
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new Client({ name: "locode", version: "0.1.0" });
|
||||||
|
await client.connect(transport);
|
||||||
|
const { tools } = await client.listTools();
|
||||||
|
return { name, client, transport, tools: tools as McpToolInfo[] };
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import envPaths from "env-paths";
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { McpServerConfig, McpServersFile } from "./types.js";
|
||||||
|
|
||||||
|
const paths = envPaths("locode", { suffix: "" });
|
||||||
|
const userFile = path.join(paths.config, "mcp.json");
|
||||||
|
|
||||||
|
export function userMcpFilePath(): string {
|
||||||
|
return userFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readServersFile(file: string): Record<string, McpServerConfig> {
|
||||||
|
if (!existsSync(file)) return {};
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(readFileSync(file, "utf-8")) as McpServersFile;
|
||||||
|
return parsed.mcpServers ?? {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User-level servers (`locode mcp add`), available in every project. */
|
||||||
|
export function loadUserServers(): Record<string, McpServerConfig> {
|
||||||
|
return readServersFile(userFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Project-level `.mcp.json` in the given directory — meant to be hand-edited/checked into a repo. */
|
||||||
|
export function loadProjectServers(cwd: string): Record<string, McpServerConfig> {
|
||||||
|
return readServersFile(path.join(cwd, ".mcp.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User-level servers plus project-level ones, with project entries winning on name collisions. */
|
||||||
|
export function loadMergedServers(cwd: string): Record<string, McpServerConfig> {
|
||||||
|
return { ...loadUserServers(), ...loadProjectServers(cwd) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveUserServer(name: string, config: McpServerConfig): void {
|
||||||
|
const servers = loadUserServers();
|
||||||
|
servers[name] = config;
|
||||||
|
mkdirSync(paths.config, { recursive: true });
|
||||||
|
writeFileSync(userFile, JSON.stringify({ mcpServers: servers }, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeUserServer(name: string): boolean {
|
||||||
|
const servers = loadUserServers();
|
||||||
|
if (!(name in servers)) return false;
|
||||||
|
delete servers[name];
|
||||||
|
writeFileSync(userFile, JSON.stringify({ mcpServers: servers }, null, 2));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { ToolDef } from "../tools/types.js";
|
||||||
|
import { connectMcpServer, type ConnectedMcpServer } from "./client.js";
|
||||||
|
import { loadMergedServers } from "./config.js";
|
||||||
|
import { mcpToolToToolDef } from "./toolAdapter.js";
|
||||||
|
|
||||||
|
export interface McpServerStatus {
|
||||||
|
name: string;
|
||||||
|
status: "connected" | "error";
|
||||||
|
toolCount: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let connections: ConnectedMcpServer[] = [];
|
||||||
|
let statuses: McpServerStatus[] = [];
|
||||||
|
|
||||||
|
/** Connects to every configured MCP server in parallel (one bad server doesn't block the rest)
|
||||||
|
* and returns the flattened, ready-to-use tools from whichever servers connected successfully. */
|
||||||
|
export async function connectConfiguredMcpServers(cwd: string): Promise<ToolDef[]> {
|
||||||
|
const servers = loadMergedServers(cwd);
|
||||||
|
const entries = Object.entries(servers);
|
||||||
|
if (entries.length === 0) return [];
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(entries.map(([name, config]) => connectMcpServer(name, config)));
|
||||||
|
|
||||||
|
const allTools: ToolDef[] = [];
|
||||||
|
const nextStatuses: McpServerStatus[] = [];
|
||||||
|
const nextConnections: ConnectedMcpServer[] = [];
|
||||||
|
|
||||||
|
results.forEach((result, i) => {
|
||||||
|
const name = entries[i]![0];
|
||||||
|
if (result.status === "fulfilled") {
|
||||||
|
const server = result.value;
|
||||||
|
nextConnections.push(server);
|
||||||
|
const toolDefs = server.tools.map((t) => mcpToolToToolDef(name, server.client, t));
|
||||||
|
allTools.push(...toolDefs);
|
||||||
|
nextStatuses.push({ name, status: "connected", toolCount: toolDefs.length });
|
||||||
|
} else {
|
||||||
|
nextStatuses.push({ name, status: "error", toolCount: 0, error: (result.reason as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
statuses = nextStatuses;
|
||||||
|
connections = nextConnections;
|
||||||
|
return allTools;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMcpStatuses(): McpServerStatus[] {
|
||||||
|
return statuses;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disconnectAllMcpServers(): Promise<void> {
|
||||||
|
await Promise.allSettled(connections.map((c) => c.transport.close()));
|
||||||
|
connections = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { ToolDef } from "../tools/types.js";
|
||||||
|
import type { McpToolInfo } from "./client.js";
|
||||||
|
|
||||||
|
function sanitize(id: string): string {
|
||||||
|
return id.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MCP tool names are namespaced as `mcp__<server>__<tool>` (matching the convention Claude Code
|
||||||
|
* itself uses) so tools from different servers can't collide with each other or with local tools. */
|
||||||
|
export function mcpToolName(serverName: string, toolName: string): string {
|
||||||
|
return `mcp__${sanitize(serverName)}__${sanitize(toolName)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const argsSchema = z.record(z.string(), z.unknown());
|
||||||
|
|
||||||
|
export function mcpToolToToolDef(serverName: string, client: Client, mcpTool: McpToolInfo): ToolDef<Record<string, unknown>> {
|
||||||
|
return {
|
||||||
|
name: mcpToolName(serverName, mcpTool.name),
|
||||||
|
description: `[MCP: ${serverName}] ${mcpTool.description ?? mcpTool.name}`,
|
||||||
|
schema: argsSchema,
|
||||||
|
rawInputSchema: mcpTool.inputSchema,
|
||||||
|
// MCP tools can do anything server-side; only trust the spec's readOnlyHint to skip confirmation.
|
||||||
|
mutating: mcpTool.annotations?.readOnlyHint !== true,
|
||||||
|
preview: async (args) => "```json\n" + JSON.stringify(args, null, 2) + "\n```",
|
||||||
|
handler: async (args) => {
|
||||||
|
const result = await client.callTool({ name: mcpTool.name, arguments: args });
|
||||||
|
const blocks = Array.isArray(result.content) ? result.content : [];
|
||||||
|
const text = blocks
|
||||||
|
.map((block) =>
|
||||||
|
block && typeof block === "object" && "type" in block && block.type === "text"
|
||||||
|
? (block as { text: string }).text
|
||||||
|
: `[unsupported MCP content type: ${(block as { type?: string })?.type ?? "unknown"}]`,
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
if (result.isError) {
|
||||||
|
return { error: text || "MCP tool call failed." };
|
||||||
|
}
|
||||||
|
return { content: text };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export interface McpStdioServerConfig {
|
||||||
|
command: string;
|
||||||
|
args?: string[];
|
||||||
|
env?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpHttpServerConfig {
|
||||||
|
url: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type McpServerConfig = McpStdioServerConfig | McpHttpServerConfig;
|
||||||
|
|
||||||
|
export interface McpServersFile {
|
||||||
|
mcpServers: Record<string, McpServerConfig>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHttpServerConfig(cfg: McpServerConfig): cfg is McpHttpServerConfig {
|
||||||
|
return "url" in cfg;
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import envPaths from "env-paths";
|
||||||
|
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
|
||||||
|
import type { ToolCallMode } from "../backend/capabilityProbe.js";
|
||||||
|
|
||||||
|
/** A saved conversation. `messages` excludes the system prompt — it's rebuilt fresh from the
|
||||||
|
* current tool list on load, so a saved session always reflects whatever tools locode has today. */
|
||||||
|
export interface SessionRecord {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
cwd: string;
|
||||||
|
baseURL: string;
|
||||||
|
model: string;
|
||||||
|
mode: ToolCallMode;
|
||||||
|
messages: ChatCompletionMessageParam[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionSummary {
|
||||||
|
id: string;
|
||||||
|
updatedAt: string;
|
||||||
|
title: string;
|
||||||
|
model: string;
|
||||||
|
baseURL: string;
|
||||||
|
messageCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paths = envPaths("locode", { suffix: "" });
|
||||||
|
const dir = path.join(paths.data, "sessions");
|
||||||
|
|
||||||
|
export function sessionsDir(): string {
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filePath(id: string): string {
|
||||||
|
return path.join(dir, `${id}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveTitle(messages: ChatCompletionMessageParam[]): string {
|
||||||
|
const first = messages.find((m) => m.role === "user");
|
||||||
|
const text = first && typeof first.content === "string" ? first.content.trim() : "";
|
||||||
|
if (!text) return "(empty conversation)";
|
||||||
|
return text.length > 60 ? `${text.slice(0, 60)}...` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSession(record: SessionRecord): void {
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(filePath(record.id), JSON.stringify(record, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadSession(id: string): SessionRecord | undefined {
|
||||||
|
const file = filePath(id);
|
||||||
|
if (!existsSync(file)) return undefined;
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(file, "utf-8")) as SessionRecord;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listSessions(): SessionSummary[] {
|
||||||
|
if (!existsSync(dir)) return [];
|
||||||
|
const summaries: SessionSummary[] = [];
|
||||||
|
for (const entry of readdirSync(dir)) {
|
||||||
|
if (!entry.endsWith(".json")) continue;
|
||||||
|
try {
|
||||||
|
const record = JSON.parse(readFileSync(path.join(dir, entry), "utf-8")) as SessionRecord;
|
||||||
|
summaries.push({
|
||||||
|
id: record.id,
|
||||||
|
updatedAt: record.updatedAt,
|
||||||
|
title: deriveTitle(record.messages),
|
||||||
|
model: record.model,
|
||||||
|
baseURL: record.baseURL,
|
||||||
|
messageCount: record.messages.length,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Skip corrupt/partial session files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return summaries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mostRecentSessionId(): string | undefined {
|
||||||
|
return listSessions()[0]?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSession(id: string): boolean {
|
||||||
|
const file = filePath(id);
|
||||||
|
if (!existsSync(file)) return false;
|
||||||
|
unlinkSync(file);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ export function toOpenAITools(tools: ToolDef[]): ChatCompletionTool[] {
|
|||||||
function: {
|
function: {
|
||||||
name: t.name,
|
name: t.name,
|
||||||
description: t.description,
|
description: t.description,
|
||||||
parameters: z.toJSONSchema(t.schema) as Record<string, unknown>,
|
parameters: t.rawInputSchema ?? (z.toJSONSchema(t.schema) as Record<string, unknown>),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import type { ToolDef } from "./types.js";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
description: z.string().describe("A short (3-6 word) label for what this sub-agent is doing, shown in the UI."),
|
||||||
|
prompt: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"The full task for the sub-agent to carry out. Be specific and self-contained — the sub-agent starts with no " +
|
||||||
|
"memory of this conversation beyond what you put here, and it cannot ask you follow-up questions.",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const agentTool: ToolDef<z.infer<typeof schema>> = {
|
||||||
|
name: "agent",
|
||||||
|
description:
|
||||||
|
"Delegate a self-contained task to a fresh sub-agent with its own tool loop (read_file, list_files, grep, " +
|
||||||
|
"web_search, web_fetch, write_file, edit_file, bash — but not another agent). Use for multi-step research or " +
|
||||||
|
"exploration you want to run in isolation, getting back only the final answer so your own context stays " +
|
||||||
|
"focused. Only the sub-agent's final text is returned to you, not its intermediate steps.",
|
||||||
|
schema,
|
||||||
|
mutating: false,
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
if (!ctx.runSubAgent) {
|
||||||
|
throw new Error("Sub-agents are not available in this context.");
|
||||||
|
}
|
||||||
|
const result = await ctx.runSubAgent(args);
|
||||||
|
return { description: args.description, result };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { execa } from "execa";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { truncate } from "../utils/truncate.js";
|
||||||
|
import type { ToolDef } from "./types.js";
|
||||||
|
|
||||||
|
async function tryGit(args: string[], cwd: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const result = await execa("git", args, { cwd, reject: false });
|
||||||
|
return result.exitCode === 0 ? result.stdout : "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- git_status: read-only inspection, auto-runs like grep/read_file ---
|
||||||
|
|
||||||
|
const statusSchema = z.object({
|
||||||
|
operation: z.enum(["status", "diff", "log", "show", "branches"]),
|
||||||
|
paths: z.array(z.string()).optional().describe("Limit `status`/`diff` to these paths, relative to the working directory."),
|
||||||
|
staged: z.boolean().optional().describe("For `diff`: show staged changes (--cached) instead of the working tree diff."),
|
||||||
|
ref: z.string().optional().describe("For `show`: the commit/ref to inspect (default HEAD). For `log`: start from this ref."),
|
||||||
|
maxCount: z.number().int().min(1).max(200).optional().describe("For `log`: maximum number of commits to return (default 20)."),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const gitStatusTool: ToolDef<z.infer<typeof statusSchema>> = {
|
||||||
|
name: "git_status",
|
||||||
|
description:
|
||||||
|
"Inspect the git repository at the working directory: `status` (working tree state), `diff` (unstaged or, with staged:true, " +
|
||||||
|
"staged changes), `log` (recent commits), `show` (a specific commit/ref, default HEAD), or `branches` (local branches with " +
|
||||||
|
"upstream tracking info). Read-only — runs automatically without confirmation.",
|
||||||
|
schema: statusSchema,
|
||||||
|
mutating: false,
|
||||||
|
handler: async ({ operation, paths, staged, ref, maxCount }, ctx) => {
|
||||||
|
const pathArgs = paths?.length ? ["--", ...paths] : [];
|
||||||
|
const args: string[] = (() => {
|
||||||
|
switch (operation) {
|
||||||
|
case "status":
|
||||||
|
return ["status", "--short", "--branch", ...pathArgs];
|
||||||
|
case "diff":
|
||||||
|
return ["diff", ...(staged ? ["--cached"] : []), ...pathArgs];
|
||||||
|
case "log":
|
||||||
|
return ["log", `--max-count=${maxCount ?? 20}`, "--oneline", "--decorate", ...(ref ? [ref] : [])];
|
||||||
|
case "show":
|
||||||
|
return ["show", ref ?? "HEAD"];
|
||||||
|
case "branches":
|
||||||
|
return ["branch", "--list", "-vv"];
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const result = await execa("git", args, { cwd: ctx.cwd, reject: false });
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return { error: truncate(result.stderr || result.stdout || `git ${operation} failed (exit ${result.exitCode})`) };
|
||||||
|
}
|
||||||
|
return { output: truncate(result.stdout) };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- git_commit: mutating operations, gated behind a confirmation prompt ---
|
||||||
|
|
||||||
|
const commitSchema = z.object({
|
||||||
|
operation: z.enum(["add", "commit", "create_branch", "checkout", "push"]),
|
||||||
|
paths: z.array(z.string()).optional().describe("For `add`: paths to stage, relative to the working directory. Omit to stage all changes."),
|
||||||
|
message: z.string().optional().describe("Commit message. Required for `commit`."),
|
||||||
|
branchName: z.string().optional().describe("Branch name. Required for `create_branch`/`checkout`."),
|
||||||
|
createIfMissing: z.boolean().optional().describe("For `checkout`: create the branch if it doesn't exist yet (like `checkout -b`)."),
|
||||||
|
remote: z.string().optional().describe("For `push`: remote name (default \"origin\")."),
|
||||||
|
setUpstream: z.boolean().optional().describe("For `push`: set upstream tracking — needed the first time a new branch is pushed."),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const gitCommitTool: ToolDef<z.infer<typeof commitSchema>> = {
|
||||||
|
name: "git_commit",
|
||||||
|
description:
|
||||||
|
"Make changes to the git repository at the working directory: `add` (stage paths, or all changes if omitted), `commit` " +
|
||||||
|
"(requires `message`; commit the currently staged changes), `create_branch` (requires `branchName`; branch at current HEAD " +
|
||||||
|
"without switching), `checkout` (requires `branchName`; switch branches, or create-and-switch with createIfMissing), or " +
|
||||||
|
"`push` (push the current branch, optionally with setUpstream for a new branch's first push). Each use requires the user's " +
|
||||||
|
"confirmation, which shows the actual diff/status/commits affected.",
|
||||||
|
schema: commitSchema,
|
||||||
|
mutating: true,
|
||||||
|
preview: async ({ operation, paths, message, branchName, createIfMissing, remote, setUpstream }, ctx) => {
|
||||||
|
switch (operation) {
|
||||||
|
case "add": {
|
||||||
|
const target = paths?.length ? paths.join(", ") : "all changes";
|
||||||
|
const status = await tryGit(["status", "--short", ...(paths?.length ? ["--", ...paths] : [])], ctx.cwd);
|
||||||
|
return status ? `Stage ${target}:\n\n${status}` : `Stage ${target} (no changes detected).`;
|
||||||
|
}
|
||||||
|
case "commit": {
|
||||||
|
const staged = await tryGit(["diff", "--cached"], ctx.cwd);
|
||||||
|
if (!staged) return "Nothing is staged — this commit will fail. Use the `add` operation first.";
|
||||||
|
return `Commit message: "${message ?? "(none provided — this will fail)"}"\n\n${staged}`;
|
||||||
|
}
|
||||||
|
case "create_branch":
|
||||||
|
return `Create new branch "${branchName}" pointing at current HEAD (does not switch to it).`;
|
||||||
|
case "checkout": {
|
||||||
|
const status = await tryGit(["status", "--short"], ctx.cwd);
|
||||||
|
const action = createIfMissing ? `Create and switch to new branch "${branchName}"` : `Switch to branch "${branchName}"`;
|
||||||
|
return status ? `${action}.\n\nUncommitted changes that will carry over:\n${status}` : `${action}. Working tree is clean.`;
|
||||||
|
}
|
||||||
|
case "push": {
|
||||||
|
const remoteName = remote ?? "origin";
|
||||||
|
const branch = (await tryGit(["rev-parse", "--abbrev-ref", "HEAD"], ctx.cwd)).trim() || "HEAD";
|
||||||
|
// `@{u}..` only resolves once upstream tracking exists — on a branch's first push
|
||||||
|
// (exactly when setUpstream is used) it doesn't, so fall back to recent commits on HEAD.
|
||||||
|
const ahead =
|
||||||
|
(await tryGit(["log", "--oneline", "@{u}.."], ctx.cwd)) || (await tryGit(["log", "--oneline", "-n", "10"], ctx.cwd));
|
||||||
|
const upstreamNote = setUpstream ? " (setting upstream tracking)" : "";
|
||||||
|
return ahead
|
||||||
|
? `Push "${branch}" to "${remoteName}"${upstreamNote} — commits to push:\n\n${ahead}`
|
||||||
|
: `Push "${branch}" to "${remoteName}"${upstreamNote}.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handler: async ({ operation, paths, message, branchName, createIfMissing, remote, setUpstream }, ctx) => {
|
||||||
|
const args: string[] = (() => {
|
||||||
|
switch (operation) {
|
||||||
|
case "add":
|
||||||
|
return ["add", ...(paths?.length ? paths : ["-A"])];
|
||||||
|
case "commit":
|
||||||
|
if (!message) throw new Error("`message` is required for the commit operation.");
|
||||||
|
return ["commit", "-m", message];
|
||||||
|
case "create_branch":
|
||||||
|
if (!branchName) throw new Error("`branchName` is required for the create_branch operation.");
|
||||||
|
return ["branch", branchName];
|
||||||
|
case "checkout":
|
||||||
|
if (!branchName) throw new Error("`branchName` is required for the checkout operation.");
|
||||||
|
return createIfMissing ? ["checkout", "-b", branchName] : ["checkout", branchName];
|
||||||
|
case "push":
|
||||||
|
return ["push", ...(setUpstream ? ["-u"] : []), remote ?? "origin", "HEAD"];
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const result = await execa("git", args, { cwd: ctx.cwd, reject: false });
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return { error: truncate(result.stderr || result.stdout || `git ${operation} failed (exit ${result.exitCode})`) };
|
||||||
|
}
|
||||||
|
// `git push` writes its useful progress/summary output to stderr even on success.
|
||||||
|
const output = truncate(result.stdout || result.stderr);
|
||||||
|
|
||||||
|
switch (operation) {
|
||||||
|
case "add":
|
||||||
|
return { staged: paths?.length ? paths : ["(all changes)"] };
|
||||||
|
case "create_branch":
|
||||||
|
return { created: branchName };
|
||||||
|
case "checkout":
|
||||||
|
return { switchedTo: branchName };
|
||||||
|
default:
|
||||||
|
return { output };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
+17
-1
@@ -1,11 +1,27 @@
|
|||||||
|
import { agentTool } from "./agentTool.js";
|
||||||
import { bashTool } from "./bash.js";
|
import { bashTool } from "./bash.js";
|
||||||
import { editFileTool } from "./editFile.js";
|
import { editFileTool } from "./editFile.js";
|
||||||
|
import { gitCommitTool, gitStatusTool } from "./git.js";
|
||||||
import { grepTool } from "./grep.js";
|
import { grepTool } from "./grep.js";
|
||||||
import { listFilesTool } from "./listFiles.js";
|
import { listFilesTool } from "./listFiles.js";
|
||||||
import { readFileTool } from "./readFile.js";
|
import { readFileTool } from "./readFile.js";
|
||||||
|
import { webFetchTool } from "./webFetch.js";
|
||||||
|
import { webSearchTool } from "./webSearch.js";
|
||||||
import { writeFileTool } from "./writeFile.js";
|
import { writeFileTool } from "./writeFile.js";
|
||||||
import type { ToolDef } from "./types.js";
|
import type { ToolDef } from "./types.js";
|
||||||
|
|
||||||
export const TOOLS: ToolDef[] = [readFileTool, listFilesTool, grepTool, writeFileTool, editFileTool, bashTool];
|
export const TOOLS: ToolDef[] = [
|
||||||
|
readFileTool,
|
||||||
|
listFilesTool,
|
||||||
|
grepTool,
|
||||||
|
webSearchTool,
|
||||||
|
webFetchTool,
|
||||||
|
gitStatusTool,
|
||||||
|
writeFileTool,
|
||||||
|
editFileTool,
|
||||||
|
bashTool,
|
||||||
|
gitCommitTool,
|
||||||
|
agentTool,
|
||||||
|
];
|
||||||
|
|
||||||
export const TOOL_REGISTRY: Map<string, ToolDef> = new Map(TOOLS.map((t) => [t.name, t]));
|
export const TOOL_REGISTRY: Map<string, ToolDef> = new Map(TOOLS.map((t) => [t.name, t]));
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { ChatCompletionTool } from "openai/resources/chat/completions";
|
||||||
|
import { toOpenAITools } from "../toolcalling/nativeAdapter.js";
|
||||||
|
import type { ToolDef } from "./types.js";
|
||||||
|
|
||||||
|
/** A tool list bundled with its lookup registry and pre-built OpenAI schemas — built once per
|
||||||
|
* session (whose tools may include dynamically-discovered MCP tools) rather than per-call. */
|
||||||
|
export interface ToolSet {
|
||||||
|
tools: ToolDef[];
|
||||||
|
registry: Map<string, ToolDef>;
|
||||||
|
openaiTools: ChatCompletionTool[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildToolSet(tools: ToolDef[]): ToolSet {
|
||||||
|
return { tools, registry: new Map(tools.map((t) => [t.name, t])), openaiTools: toOpenAITools(tools) };
|
||||||
|
}
|
||||||
@@ -1,13 +1,25 @@
|
|||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
|
|
||||||
|
export interface SubAgentTask {
|
||||||
|
/** Short (3-6 word) label shown in the UI while the sub-agent runs. */
|
||||||
|
description: string;
|
||||||
|
/** The full, self-contained task — the sub-agent has no memory of the parent conversation. */
|
||||||
|
prompt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ToolContext {
|
export interface ToolContext {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
/** Only present when running inside a session capable of spawning sub-agents (used by the `agent` tool). */
|
||||||
|
runSubAgent?: (task: SubAgentTask) => Promise<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolDef<T = any> {
|
export interface ToolDef<T = any> {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
schema: z.ZodType<T>;
|
schema: z.ZodType<T>;
|
||||||
|
/** If set, used verbatim as the JSON Schema shown to the model instead of one derived from `schema` —
|
||||||
|
* needed for tools (e.g. MCP-provided ones) whose real parameter schema isn't expressible as a Zod type. */
|
||||||
|
rawInputSchema?: Record<string, unknown>;
|
||||||
mutating: boolean;
|
mutating: boolean;
|
||||||
/** For mutating tools: human-readable preview (e.g. a diff) shown before the user confirms. */
|
/** For mutating tools: human-readable preview (e.g. a diff) shown before the user confirms. */
|
||||||
preview?: (args: T, ctx: ToolContext) => Promise<string>;
|
preview?: (args: T, ctx: ToolContext) => Promise<string>;
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { truncate } from "../utils/truncate.js";
|
||||||
|
import { htmlToText, USER_AGENT, withFetchTimeout } from "../utils/html.js";
|
||||||
|
import type { ToolDef } from "./types.js";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
url: z.string().describe("URL to fetch. Must include the scheme, e.g. 'https://example.com'."),
|
||||||
|
});
|
||||||
|
|
||||||
|
const MAX_CHARS = 20_000;
|
||||||
|
|
||||||
|
export const webFetchTool: ToolDef<z.infer<typeof schema>> = {
|
||||||
|
name: "web_fetch",
|
||||||
|
description:
|
||||||
|
"Fetch a URL and return its content as readable text (HTML is stripped of tags/scripts/styles). Use to read a specific page, e.g. one found via web_search or linked from documentation.",
|
||||||
|
schema,
|
||||||
|
mutating: false,
|
||||||
|
handler: async ({ url }) => {
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(url);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Invalid URL: ${url}`);
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||||
|
throw new Error(`Unsupported URL scheme: ${parsed.protocol}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { signal, clear } = withFetchTimeout(15_000);
|
||||||
|
try {
|
||||||
|
const response = await fetch(parsed, {
|
||||||
|
headers: { "User-Agent": USER_AGENT },
|
||||||
|
signal,
|
||||||
|
redirect: "follow",
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Fetch failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
const contentType = response.headers.get("content-type") ?? "";
|
||||||
|
const body = await response.text();
|
||||||
|
const content = contentType.includes("html") ? htmlToText(body) : body;
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: parsed.toString(),
|
||||||
|
finalUrl: response.url,
|
||||||
|
status: response.status,
|
||||||
|
contentType,
|
||||||
|
content: truncate(content, MAX_CHARS),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
clear();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { stripTags, USER_AGENT, withFetchTimeout } from "../utils/html.js";
|
||||||
|
import type { ToolDef } from "./types.js";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
query: z.string().describe("Search query."),
|
||||||
|
max_results: z.number().int().min(1).max(20).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
snippet: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveResultUrl(href: string): string {
|
||||||
|
const absolute = href.startsWith("//") ? `https:${href}` : href;
|
||||||
|
try {
|
||||||
|
const url = new URL(absolute);
|
||||||
|
const uddg = url.searchParams.get("uddg");
|
||||||
|
return uddg ? uddg : absolute;
|
||||||
|
} catch {
|
||||||
|
return absolute;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseResults(html: string, limit: number): SearchResult[] {
|
||||||
|
const results: SearchResult[] = [];
|
||||||
|
const resultRegex =
|
||||||
|
/<a rel="nofollow" class="result__a" href="([^"]+)">([\s\S]*?)<\/a>[\s\S]*?<a class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g;
|
||||||
|
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = resultRegex.exec(html)) && results.length < limit) {
|
||||||
|
const [, href, titleHtml, snippetHtml] = match;
|
||||||
|
if (!href || !titleHtml || !snippetHtml) continue;
|
||||||
|
results.push({
|
||||||
|
title: stripTags(titleHtml),
|
||||||
|
url: resolveResultUrl(href),
|
||||||
|
snippet: stripTags(snippetHtml),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const webSearchTool: ToolDef<z.infer<typeof schema>> = {
|
||||||
|
name: "web_search",
|
||||||
|
description:
|
||||||
|
"Search the web via DuckDuckGo and return matching results (title, url, snippet). Use for information not available in the local codebase, e.g. current docs, library APIs, or general knowledge.",
|
||||||
|
schema,
|
||||||
|
mutating: false,
|
||||||
|
handler: async ({ query, max_results }) => {
|
||||||
|
const limit = max_results ?? 5;
|
||||||
|
const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
|
||||||
|
|
||||||
|
const { signal, clear } = withFetchTimeout(10_000);
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { "User-Agent": USER_AGENT },
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Search request failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
const html = await response.text();
|
||||||
|
const results = parseResults(html, limit);
|
||||||
|
return { query, results };
|
||||||
|
} finally {
|
||||||
|
clear();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
+229
-33
@@ -1,17 +1,37 @@
|
|||||||
import { Box, Static, useApp } from "ink";
|
import { Box, Static, useApp } from "ink";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { AgentError, runTurn } from "../../agent/loop.js";
|
import { AgentError, compactSession, contextUsageRatio, runTurn, shouldAutoCompact } from "../../agent/loop.js";
|
||||||
import { createSession, resetSession, setMode, type Session } from "../../agent/session.js";
|
import {
|
||||||
|
createSession,
|
||||||
|
createSessionFromRecord,
|
||||||
|
resetSession,
|
||||||
|
setMode,
|
||||||
|
toSessionRecord,
|
||||||
|
type Session,
|
||||||
|
} from "../../agent/session.js";
|
||||||
import { makeClient } from "../../backend/client.js";
|
import { makeClient } from "../../backend/client.js";
|
||||||
import type { ToolCallMode } from "../../backend/capabilityProbe.js";
|
import type { ToolCallMode } from "../../backend/capabilityProbe.js";
|
||||||
import { setCachedMode } from "../../backend/capabilityCache.js";
|
import { setCachedMode } from "../../backend/capabilityCache.js";
|
||||||
|
import { resolveContextWindow } from "../../backend/contextWindow.js";
|
||||||
import { resolveToolCallMode } from "../../backend/resolveMode.js";
|
import { resolveToolCallMode } from "../../backend/resolveMode.js";
|
||||||
import { KNOWN_BACKENDS, type BackendName } from "../../config/defaults.js";
|
import { KNOWN_BACKENDS, type BackendName } from "../../config/defaults.js";
|
||||||
|
import { getMcpStatuses } from "../../mcp/manager.js";
|
||||||
import type { PermissionDecision, PermissionMode } from "../../permissions/types.js";
|
import type { PermissionDecision, PermissionMode } from "../../permissions/types.js";
|
||||||
|
import {
|
||||||
|
deriveTitle,
|
||||||
|
listSessions,
|
||||||
|
loadSession,
|
||||||
|
saveSession,
|
||||||
|
type SessionRecord,
|
||||||
|
type SessionSummary,
|
||||||
|
} from "../../persistence/sessionStore.js";
|
||||||
|
import { TOOLS } from "../../tools/index.js";
|
||||||
|
import type { ToolDef } from "../../tools/types.js";
|
||||||
import { ChatInput } from "./ChatInput.js";
|
import { ChatInput } from "./ChatInput.js";
|
||||||
import { HistoryItemView } from "./HistoryItemView.js";
|
import { HistoryItemView } from "./HistoryItemView.js";
|
||||||
import { ModelSelect } from "./ModelSelect.js";
|
import { ModelSelect } from "./ModelSelect.js";
|
||||||
import { PermissionPrompt } from "./PermissionPrompt.js";
|
import { PermissionPrompt } from "./PermissionPrompt.js";
|
||||||
|
import { SessionSelect } from "./SessionSelect.js";
|
||||||
import { StatusBar } from "./StatusBar.js";
|
import { StatusBar } from "./StatusBar.js";
|
||||||
import { ThinkingIndicator } from "./ThinkingIndicator.js";
|
import { ThinkingIndicator } from "./ThinkingIndicator.js";
|
||||||
import { nextId, type HistoryItem, type NewHistoryItem } from "./types.js";
|
import { nextId, type HistoryItem, type NewHistoryItem } from "./types.js";
|
||||||
@@ -22,6 +42,9 @@ export interface AppProps {
|
|||||||
toolModeOverride?: ToolCallMode;
|
toolModeOverride?: ToolCallMode;
|
||||||
initialModel?: string;
|
initialModel?: string;
|
||||||
suggestedModel?: string;
|
suggestedModel?: string;
|
||||||
|
resumeSessionId?: string;
|
||||||
|
interactiveResume?: boolean;
|
||||||
|
mcpToolsPromise: Promise<ToolDef[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PendingPermission {
|
interface PendingPermission {
|
||||||
@@ -31,7 +54,7 @@ interface PendingPermission {
|
|||||||
resolve: (decision: PermissionDecision) => void;
|
resolve: (decision: PermissionDecision) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Phase = "connecting" | "loading-models" | "model-select" | "input";
|
type Phase = "starting" | "connecting" | "loading-models" | "model-select" | "session-select" | "input";
|
||||||
|
|
||||||
export function App({
|
export function App({
|
||||||
baseURL: initialBaseURL,
|
baseURL: initialBaseURL,
|
||||||
@@ -39,16 +62,22 @@ export function App({
|
|||||||
toolModeOverride,
|
toolModeOverride,
|
||||||
initialModel,
|
initialModel,
|
||||||
suggestedModel,
|
suggestedModel,
|
||||||
|
resumeSessionId,
|
||||||
|
interactiveResume,
|
||||||
|
mcpToolsPromise,
|
||||||
}: AppProps) {
|
}: AppProps) {
|
||||||
const { exit } = useApp();
|
const { exit } = useApp();
|
||||||
const [staticItems, setStaticItems] = useState<HistoryItem[]>([]);
|
const [staticItems, setStaticItems] = useState<HistoryItem[]>([]);
|
||||||
const [phase, setPhase] = useState<Phase>(initialModel ? "connecting" : "loading-models");
|
const [phase, setPhase] = useState<Phase>(
|
||||||
|
resumeSessionId ? "connecting" : interactiveResume ? "starting" : initialModel ? "connecting" : "loading-models",
|
||||||
|
);
|
||||||
const [inputValue, setInputValue] = useState("");
|
const [inputValue, setInputValue] = useState("");
|
||||||
const [permission, setPermission] = useState<PendingPermission | null>(null);
|
const [permission, setPermission] = useState<PendingPermission | null>(null);
|
||||||
const [streamingText, setStreamingText] = useState<string | null>(null);
|
const [streamingText, setStreamingText] = useState<string | null>(null);
|
||||||
const [isThinking, setIsThinking] = useState(false);
|
const [isThinking, setIsThinking] = useState(false);
|
||||||
const [permMode, setPermMode] = useState<PermissionMode>("default");
|
const [permMode, setPermMode] = useState<PermissionMode>("default");
|
||||||
const [modelList, setModelList] = useState<string[]>([]);
|
const [modelList, setModelList] = useState<string[]>([]);
|
||||||
|
const [sessionList, setSessionList] = useState<SessionSummary[]>([]);
|
||||||
|
|
||||||
const baseURLRef = useRef(initialBaseURL);
|
const baseURLRef = useRef(initialBaseURL);
|
||||||
const sessionRef = useRef<Session | null>(null);
|
const sessionRef = useRef<Session | null>(null);
|
||||||
@@ -69,10 +98,17 @@ export function App({
|
|||||||
setStaticItems((h) => [...h, { id: nextId(), ...partial } as HistoryItem]);
|
setStaticItems((h) => [...h, { id: nextId(), ...partial } as HistoryItem]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Fetch models on mount when no --model is specified
|
const persistCurrentSession = useCallback(() => {
|
||||||
useEffect(() => {
|
const session = sessionRef.current;
|
||||||
if (initialModel) return; // skip model listing if --model was provided
|
if (!session) return;
|
||||||
|
try {
|
||||||
|
saveSession(toSessionRecord(session, baseURLRef.current));
|
||||||
|
} catch {
|
||||||
|
// Best-effort — a failed save shouldn't crash the UI.
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchModelsForPicker = useCallback(() => {
|
||||||
const client = makeClient({ baseURL: baseURLRef.current, model: "" });
|
const client = makeClient({ baseURL: baseURLRef.current, model: "" });
|
||||||
client.models
|
client.models
|
||||||
.list()
|
.list()
|
||||||
@@ -90,8 +126,7 @@ export function App({
|
|||||||
push({ kind: "notice", text: `Failed to list models: ${(err as Error).message}`, isError: true });
|
push({ kind: "notice", text: `Failed to list models: ${(err as Error).message}`, isError: true });
|
||||||
setPhase("input");
|
setPhase("input");
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [push]);
|
||||||
}, []);
|
|
||||||
|
|
||||||
const initSession = useCallback(
|
const initSession = useCallback(
|
||||||
async (model: string) => {
|
async (model: string) => {
|
||||||
@@ -105,7 +140,17 @@ export function App({
|
|||||||
new Promise<PermissionDecision>((resolve) => {
|
new Promise<PermissionDecision>((resolve) => {
|
||||||
setPermission({ ...opts, resolve });
|
setPermission({ ...opts, resolve });
|
||||||
});
|
});
|
||||||
sessionRef.current = createSession(client, model, cwd, confirmFn, mode);
|
const [mcpTools, contextWindow] = await Promise.all([mcpToolsPromise, resolveContextWindow(baseURLRef.current, model)]);
|
||||||
|
sessionRef.current = createSession(
|
||||||
|
client,
|
||||||
|
model,
|
||||||
|
cwd,
|
||||||
|
confirmFn,
|
||||||
|
mode,
|
||||||
|
[...TOOLS, ...mcpTools],
|
||||||
|
contextWindow.value,
|
||||||
|
contextWindow.isEstimate,
|
||||||
|
);
|
||||||
push({ kind: "banner", cwd, model, backend: baseURLRef.current });
|
push({ kind: "banner", cwd, model, backend: baseURLRef.current });
|
||||||
setPhase("input");
|
setPhase("input");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -121,13 +166,92 @@ export function App({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[cwd, toolModeOverride, modelList, exit, push],
|
[cwd, toolModeOverride, modelList, exit, push, mcpToolsPromise],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const initSessionFromRecord = useCallback(
|
||||||
|
async (record: SessionRecord) => {
|
||||||
|
setPhase("connecting");
|
||||||
|
try {
|
||||||
|
baseURLRef.current = record.baseURL;
|
||||||
|
const client = makeClient({ baseURL: record.baseURL, model: record.model });
|
||||||
|
const confirmFn = (opts: { toolName: string; args: unknown; preview?: string }) =>
|
||||||
|
new Promise<PermissionDecision>((resolve) => {
|
||||||
|
setPermission({ ...opts, resolve });
|
||||||
|
});
|
||||||
|
const [mcpTools, contextWindow] = await Promise.all([
|
||||||
|
mcpToolsPromise,
|
||||||
|
resolveContextWindow(record.baseURL, record.model),
|
||||||
|
]);
|
||||||
|
sessionRef.current = createSessionFromRecord(
|
||||||
|
client,
|
||||||
|
record,
|
||||||
|
cwd,
|
||||||
|
confirmFn,
|
||||||
|
[...TOOLS, ...mcpTools],
|
||||||
|
contextWindow.value,
|
||||||
|
contextWindow.isEstimate,
|
||||||
|
);
|
||||||
|
push({
|
||||||
|
kind: "banner",
|
||||||
|
cwd,
|
||||||
|
model: record.model,
|
||||||
|
backend: record.baseURL,
|
||||||
|
resumedTitle: deriveTitle(record.messages),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Replay the saved user/assistant turns so the transcript isn't blank — tool
|
||||||
|
// call/result lines aren't replayed since we don't persist their display labels.
|
||||||
|
const replayed: HistoryItem[] = [];
|
||||||
|
for (const m of record.messages) {
|
||||||
|
if (m.role === "user" && typeof m.content === "string" && !m.content.startsWith("```tool_result")) {
|
||||||
|
replayed.push({ id: nextId(), kind: "user", text: m.content });
|
||||||
|
} else if (m.role === "assistant" && typeof m.content === "string" && m.content) {
|
||||||
|
replayed.push({ id: nextId(), kind: "assistant", text: m.content });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setStaticItems((prev) => [...prev, ...replayed]);
|
||||||
|
|
||||||
|
setPhase("input");
|
||||||
|
} catch (err) {
|
||||||
|
push({ kind: "notice", text: `Failed to resume session: ${(err as Error).message}`, isError: true });
|
||||||
|
fetchModelsForPicker();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[cwd, push, fetchModelsForPicker, mcpToolsPromise],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Decide the startup path once on mount: resume a specific session, show a resume
|
||||||
|
// picker, jump straight to a given model, or fall back to the model picker.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (resumeSessionId) {
|
||||||
|
const record = loadSession(resumeSessionId);
|
||||||
|
if (record) {
|
||||||
|
void initSessionFromRecord(record);
|
||||||
|
} else {
|
||||||
|
push({ kind: "notice", text: `No saved session found with id "${resumeSessionId}".`, isError: true });
|
||||||
|
if (initialModel) void initSession(initialModel);
|
||||||
|
else fetchModelsForPicker();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (interactiveResume) {
|
||||||
|
const list = listSessions();
|
||||||
|
if (list.length === 0) {
|
||||||
|
push({ kind: "notice", text: "No saved sessions found — starting a new conversation." });
|
||||||
|
if (initialModel) void initSession(initialModel);
|
||||||
|
else fetchModelsForPicker();
|
||||||
|
} else {
|
||||||
|
setSessionList(list);
|
||||||
|
setPhase("session-select");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (initialModel) {
|
if (initialModel) {
|
||||||
void initSession(initialModel);
|
void initSession(initialModel);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
fetchModelsForPicker();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -135,6 +259,21 @@ export function App({
|
|||||||
await initSession(model);
|
await initSession(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSessionSelect(id: string | null) {
|
||||||
|
if (id === null) {
|
||||||
|
if (initialModel) void initSession(initialModel);
|
||||||
|
else fetchModelsForPicker();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const record = loadSession(id);
|
||||||
|
if (record) {
|
||||||
|
void initSessionFromRecord(record);
|
||||||
|
} else {
|
||||||
|
push({ kind: "notice", text: `No saved session found with id "${id}".`, isError: true });
|
||||||
|
fetchModelsForPicker();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function switchModel(name: string) {
|
async function switchModel(name: string) {
|
||||||
const session = sessionRef.current;
|
const session = sessionRef.current;
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
@@ -142,9 +281,15 @@ export function App({
|
|||||||
session.model = name;
|
session.model = name;
|
||||||
setIsThinking(true);
|
setIsThinking(true);
|
||||||
try {
|
try {
|
||||||
const newMode = await resolveToolCallMode(session.client, baseURLRef.current, name);
|
const [newMode, newContextWindow] = await Promise.all([
|
||||||
|
resolveToolCallMode(session.client, baseURLRef.current, name),
|
||||||
|
resolveContextWindow(baseURLRef.current, name),
|
||||||
|
]);
|
||||||
setMode(session, newMode);
|
setMode(session, newMode);
|
||||||
|
session.contextWindow = newContextWindow.value;
|
||||||
|
session.contextWindowIsEstimate = newContextWindow.isEstimate;
|
||||||
push({ kind: "notice", text: `Switched model to "${name}" (tool-call mode: ${newMode}).` });
|
push({ kind: "notice", text: `Switched model to "${name}" (tool-call mode: ${newMode}).` });
|
||||||
|
persistCurrentSession();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
session.model = previousModel;
|
session.model = previousModel;
|
||||||
push({ kind: "notice", text: `Failed to switch model: ${(err as Error).message}`, isError: true });
|
push({ kind: "notice", text: `Failed to switch model: ${(err as Error).message}`, isError: true });
|
||||||
@@ -162,12 +307,18 @@ export function App({
|
|||||||
session.client = makeClient({ baseURL: baseURLRef.current, model: session.model });
|
session.client = makeClient({ baseURL: baseURLRef.current, model: session.model });
|
||||||
setIsThinking(true);
|
setIsThinking(true);
|
||||||
try {
|
try {
|
||||||
const newMode = await resolveToolCallMode(session.client, baseURLRef.current, session.model);
|
const [newMode, newContextWindow] = await Promise.all([
|
||||||
|
resolveToolCallMode(session.client, baseURLRef.current, session.model),
|
||||||
|
resolveContextWindow(baseURLRef.current, session.model),
|
||||||
|
]);
|
||||||
setMode(session, newMode);
|
setMode(session, newMode);
|
||||||
|
session.contextWindow = newContextWindow.value;
|
||||||
|
session.contextWindowIsEstimate = newContextWindow.isEstimate;
|
||||||
push({
|
push({
|
||||||
kind: "notice",
|
kind: "notice",
|
||||||
text: `Switched backend to "${name}" (${baseURLRef.current}, tool-call mode: ${newMode}).`,
|
text: `Switched backend to "${name}" (${baseURLRef.current}, tool-call mode: ${newMode}).`,
|
||||||
});
|
});
|
||||||
|
persistCurrentSession();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
baseURLRef.current = previousBaseURL;
|
baseURLRef.current = previousBaseURL;
|
||||||
session.client = previousClient;
|
session.client = previousClient;
|
||||||
@@ -198,20 +349,53 @@ export function App({
|
|||||||
if (trimmed === "/clear") {
|
if (trimmed === "/clear") {
|
||||||
resetSession(session);
|
resetSession(session);
|
||||||
push({ kind: "notice", text: "Conversation history cleared." });
|
push({ kind: "notice", text: "Conversation history cleared." });
|
||||||
|
persistCurrentSession();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (trimmed === "/status") {
|
if (trimmed === "/status") {
|
||||||
push({ kind: "status", model: session.model, baseURL: baseURLRef.current, mode: session.mode, cwd });
|
push({
|
||||||
|
kind: "status",
|
||||||
|
model: session.model,
|
||||||
|
baseURL: baseURLRef.current,
|
||||||
|
mode: session.mode,
|
||||||
|
cwd,
|
||||||
|
sessionId: session.id,
|
||||||
|
contextTokens: session.lastContextTokens,
|
||||||
|
contextWindow: session.contextWindow,
|
||||||
|
contextTokensIsEstimate: session.lastContextTokensIsEstimate,
|
||||||
|
contextWindowIsEstimate: session.contextWindowIsEstimate,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (trimmed === "/compact") {
|
||||||
|
setIsThinking(true);
|
||||||
|
try {
|
||||||
|
await compactSession(session);
|
||||||
|
push({ kind: "notice", text: "Conversation compacted to save context." });
|
||||||
|
persistCurrentSession();
|
||||||
|
} catch (err) {
|
||||||
|
push({ kind: "notice", text: `Compaction failed: ${(err as Error).message}`, isError: true });
|
||||||
|
} finally {
|
||||||
|
setIsThinking(false);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (trimmed === "/tools") {
|
if (trimmed === "/tools") {
|
||||||
push({ kind: "tools" });
|
push({ kind: "tools", tools: session.toolset.tools });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (trimmed === "/permissions") {
|
if (trimmed === "/permissions") {
|
||||||
push({ kind: "permissions", allowed: session.permissions.listAllowed() });
|
push({ kind: "permissions", allowed: session.permissions.listAllowed() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (trimmed === "/sessions") {
|
||||||
|
push({ kind: "sessions", sessions: listSessions() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (trimmed === "/mcp") {
|
||||||
|
push({ kind: "mcp", statuses: getMcpStatuses() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (trimmed.startsWith("/model")) {
|
if (trimmed.startsWith("/model")) {
|
||||||
const name = trimmed.slice("/model".length).trim();
|
const name = trimmed.slice("/model".length).trim();
|
||||||
if (!name) {
|
if (!name) {
|
||||||
@@ -232,21 +416,6 @@ export function App({
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (trimmed === "/perm") {
|
|
||||||
// Cycle: default → auto-edit → auto-accept → default
|
|
||||||
const modes: PermissionMode[] = ["default", "auto-edit", "auto-accept"];
|
|
||||||
const currentIdx = modes.indexOf(permMode);
|
|
||||||
const nextMode = modes[(currentIdx + 1) % modes.length];
|
|
||||||
setPermMode(nextMode);
|
|
||||||
session.permissions.setMode(nextMode);
|
|
||||||
const labels: Record<PermissionMode, string> = {
|
|
||||||
default: "default (ask before mutating tools)",
|
|
||||||
"auto-edit": "auto-edit (file edits auto-approved, bash still asks)",
|
|
||||||
"auto-accept": "auto-accept (all tools auto-approved ⚠)",
|
|
||||||
};
|
|
||||||
push({ kind: "notice", text: `Permission mode: ${labels[nextMode]}` });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (trimmed.startsWith("/perm")) {
|
if (trimmed.startsWith("/perm")) {
|
||||||
const name = trimmed.slice("/perm".length).trim();
|
const name = trimmed.slice("/perm".length).trim();
|
||||||
const validModes: Record<string, PermissionMode> = { default: "default", "auto-edit": "auto-edit", "auto-accept": "auto-accept" };
|
const validModes: Record<string, PermissionMode> = { default: "default", "auto-edit": "auto-edit", "auto-accept": "auto-accept" };
|
||||||
@@ -254,7 +423,7 @@ export function App({
|
|||||||
// Cycle
|
// Cycle
|
||||||
const modes: PermissionMode[] = ["default", "auto-edit", "auto-accept"];
|
const modes: PermissionMode[] = ["default", "auto-edit", "auto-accept"];
|
||||||
const currentIdx = modes.indexOf(permMode);
|
const currentIdx = modes.indexOf(permMode);
|
||||||
const nextMode = modes[(currentIdx + 1) % modes.length];
|
const nextMode = modes[(currentIdx + 1) % modes.length]!;
|
||||||
setPermMode(nextMode);
|
setPermMode(nextMode);
|
||||||
session.permissions.setMode(nextMode);
|
session.permissions.setMode(nextMode);
|
||||||
const labels: Record<PermissionMode, string> = {
|
const labels: Record<PermissionMode, string> = {
|
||||||
@@ -283,6 +452,7 @@ export function App({
|
|||||||
setMode(session, name);
|
setMode(session, name);
|
||||||
setCachedMode(baseURLRef.current, session.model, name);
|
setCachedMode(baseURLRef.current, session.model, name);
|
||||||
push({ kind: "notice", text: `Forced tool-call mode to "${name}" (cached for this model).` });
|
push({ kind: "notice", text: `Forced tool-call mode to "${name}" (cached for this model).` });
|
||||||
|
persistCurrentSession();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -325,6 +495,25 @@ export function App({
|
|||||||
});
|
});
|
||||||
// text_done already added the assistant message to staticItems
|
// text_done already added the assistant message to staticItems
|
||||||
// No fallback needed — the streaming loop always emits text_done
|
// No fallback needed — the streaming loop always emits text_done
|
||||||
|
void text;
|
||||||
|
persistCurrentSession();
|
||||||
|
|
||||||
|
if (shouldAutoCompact(session)) {
|
||||||
|
const percentBefore = Math.round(contextUsageRatio(session) * 100);
|
||||||
|
setIsThinking(true);
|
||||||
|
try {
|
||||||
|
await compactSession(session);
|
||||||
|
push({
|
||||||
|
kind: "notice",
|
||||||
|
text: `Context was getting full (${percentBefore}%) — auto-compacted the conversation.`,
|
||||||
|
});
|
||||||
|
persistCurrentSession();
|
||||||
|
} catch (err) {
|
||||||
|
push({ kind: "notice", text: `Auto-compact failed: ${(err as Error).message}`, isError: true });
|
||||||
|
} finally {
|
||||||
|
setIsThinking(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (streamRafRef.current !== null) {
|
if (streamRafRef.current !== null) {
|
||||||
clearTimeout(streamRafRef.current);
|
clearTimeout(streamRafRef.current);
|
||||||
@@ -354,7 +543,7 @@ export function App({
|
|||||||
if (!session) return;
|
if (!session) return;
|
||||||
const modes: PermissionMode[] = ["default", "auto-edit", "auto-accept"];
|
const modes: PermissionMode[] = ["default", "auto-edit", "auto-accept"];
|
||||||
const currentIdx = modes.indexOf(permMode);
|
const currentIdx = modes.indexOf(permMode);
|
||||||
const nextMode = modes[(currentIdx + 1) % modes.length];
|
const nextMode = modes[(currentIdx + 1) % modes.length]!;
|
||||||
setPermMode(nextMode);
|
setPermMode(nextMode);
|
||||||
session.permissions.setMode(nextMode);
|
session.permissions.setMode(nextMode);
|
||||||
const labels: Record<PermissionMode, string> = {
|
const labels: Record<PermissionMode, string> = {
|
||||||
@@ -385,10 +574,14 @@ export function App({
|
|||||||
preview={permission.preview}
|
preview={permission.preview}
|
||||||
onSelect={handlePermissionSelect}
|
onSelect={handlePermissionSelect}
|
||||||
/>
|
/>
|
||||||
|
) : phase === "starting" ? (
|
||||||
|
<ThinkingIndicator label="starting..." />
|
||||||
) : phase === "connecting" ? (
|
) : phase === "connecting" ? (
|
||||||
<ThinkingIndicator label="connecting..." />
|
<ThinkingIndicator label="connecting..." />
|
||||||
) : phase === "loading-models" ? (
|
) : phase === "loading-models" ? (
|
||||||
<ThinkingIndicator label="loading models..." />
|
<ThinkingIndicator label="loading models..." />
|
||||||
|
) : phase === "session-select" ? (
|
||||||
|
<SessionSelect sessions={sessionList} onSelect={handleSessionSelect} />
|
||||||
) : phase === "model-select" ? (
|
) : phase === "model-select" ? (
|
||||||
<ModelSelect models={modelList} currentModel={suggestedModel} onSelect={handleModelSelect} />
|
<ModelSelect models={modelList} currentModel={suggestedModel} onSelect={handleModelSelect} />
|
||||||
) : (
|
) : (
|
||||||
@@ -400,8 +593,11 @@ export function App({
|
|||||||
mode={sessionRef.current.mode}
|
mode={sessionRef.current.mode}
|
||||||
permMode={permMode}
|
permMode={permMode}
|
||||||
cwd={cwd}
|
cwd={cwd}
|
||||||
|
contextTokens={sessionRef.current.lastContextTokens}
|
||||||
|
contextWindow={sessionRef.current.contextWindow}
|
||||||
|
contextIsEstimate={sessionRef.current.contextWindowIsEstimate || sessionRef.current.lastContextTokensIsEstimate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Box, Text } from "ink";
|
import { Box, Text } from "ink";
|
||||||
import { TOOLS } from "../../tools/index.js";
|
|
||||||
import { renderMarkdown } from "../render.js";
|
import { renderMarkdown } from "../render.js";
|
||||||
import { ACCENT_HEX } from "../theme.js";
|
import { ACCENT_HEX } from "../theme.js";
|
||||||
import type { HistoryItem } from "./types.js";
|
import type { HistoryItem } from "./types.js";
|
||||||
@@ -13,6 +12,9 @@ const HELP_LINES = [
|
|||||||
" /status show current model, backend, tool-call mode, and cwd",
|
" /status show current model, backend, tool-call mode, and cwd",
|
||||||
" /tools list available tools",
|
" /tools list available tools",
|
||||||
" /permissions list mutating tools allowed for the rest of this session",
|
" /permissions list mutating tools allowed for the rest of this session",
|
||||||
|
" /sessions list saved conversations you can resume with --resume",
|
||||||
|
" /mcp show connected MCP servers and their tool counts",
|
||||||
|
" /compact summarize the conversation now to free up context",
|
||||||
" /clear clear conversation history",
|
" /clear clear conversation history",
|
||||||
" /help show this help",
|
" /help show this help",
|
||||||
" /exit, /quit exit",
|
" /exit, /quit exit",
|
||||||
@@ -32,22 +34,28 @@ export function HistoryItemView({ item }: { item: HistoryItem }) {
|
|||||||
<Text> </Text>
|
<Text> </Text>
|
||||||
{item.model ? (
|
{item.model ? (
|
||||||
<Text>
|
<Text>
|
||||||
<Text dimColor> model: </Text>
|
<Text color="yellow"> model: </Text>
|
||||||
<Text bold>{item.model}</Text>
|
<Text bold>{item.model}</Text>
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
{item.backend ? (
|
{item.backend ? (
|
||||||
<Text>
|
<Text>
|
||||||
<Text dimColor> backend: </Text>
|
<Text color="yellow"> backend: </Text>
|
||||||
{item.backend}
|
{item.backend}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
<Text>
|
<Text>
|
||||||
<Text dimColor> cwd: </Text>
|
<Text color="yellow"> cwd: </Text>
|
||||||
{item.cwd}
|
{item.cwd}
|
||||||
</Text>
|
</Text>
|
||||||
|
{item.resumedTitle ? (
|
||||||
|
<Text>
|
||||||
|
<Text color="yellow"> resumed: </Text>
|
||||||
|
{item.resumedTitle}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
<Text> </Text>
|
<Text> </Text>
|
||||||
<Text dimColor> /help for commands · /perm to change permission mode · Shift+Tab to cycle modes</Text>
|
<Text color="yellow"> /help for commands · /perm to change permission mode · Shift+Tab to cycle modes</Text>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -72,6 +80,17 @@ export function HistoryItemView({ item }: { item: HistoryItem }) {
|
|||||||
<Text dimColor>cwd: </Text>
|
<Text dimColor>cwd: </Text>
|
||||||
{item.cwd}
|
{item.cwd}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text>
|
||||||
|
<Text dimColor>session: </Text>
|
||||||
|
{item.sessionId}
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
|
<Text dimColor>context: </Text>
|
||||||
|
{item.contextTokens.toLocaleString()}
|
||||||
|
{item.contextTokensIsEstimate ? " (est.)" : ""} / {item.contextWindow.toLocaleString()}
|
||||||
|
{item.contextWindowIsEstimate ? " (default)" : ""} tokens (
|
||||||
|
{Math.round((item.contextTokens / item.contextWindow) * 100)}%)
|
||||||
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -123,7 +142,7 @@ export function HistoryItemView({ item }: { item: HistoryItem }) {
|
|||||||
return (
|
return (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
<Text>Available tools:</Text>
|
<Text>Available tools:</Text>
|
||||||
{TOOLS.map((t) => (
|
{item.tools.map((t) => (
|
||||||
<Text key={t.name}>
|
<Text key={t.name}>
|
||||||
{" "}
|
{" "}
|
||||||
{t.name}
|
{t.name}
|
||||||
@@ -141,5 +160,39 @@ export function HistoryItemView({ item }: { item: HistoryItem }) {
|
|||||||
: "No mutating tools have been allowed for the rest of this session yet."}
|
: "No mutating tools have been allowed for the rest of this session yet."}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case "sessions":
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text>Saved conversations (resume with `locode --resume <id>`):</Text>
|
||||||
|
{item.sessions.length === 0 ? (
|
||||||
|
<Text dimColor> (none yet)</Text>
|
||||||
|
) : (
|
||||||
|
item.sessions.map((s) => (
|
||||||
|
<Text key={s.id} dimColor>
|
||||||
|
{" "}
|
||||||
|
{s.id} {s.title} ({s.model}, {s.messageCount} msgs)
|
||||||
|
</Text>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "mcp":
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text>MCP servers:</Text>
|
||||||
|
{item.statuses.length === 0 ? (
|
||||||
|
<Text dimColor> (none configured — see .mcp.json or `locode mcp add`)</Text>
|
||||||
|
) : (
|
||||||
|
item.statuses.map((s) => (
|
||||||
|
<Text key={s.name} color={s.status === "error" ? "red" : undefined} dimColor={s.status !== "error"}>
|
||||||
|
{" "}
|
||||||
|
{s.name}: {s.status === "connected" ? `connected, ${s.toolCount} tool(s)` : `error — ${s.error}`}
|
||||||
|
</Text>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Box, Text } from "ink";
|
||||||
|
import SelectInput from "ink-select-input";
|
||||||
|
import type { SessionSummary } from "../../persistence/sessionStore.js";
|
||||||
|
import { ACCENT_HEX } from "../theme.js";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
sessions: SessionSummary[];
|
||||||
|
onSelect: (id: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeTime(iso: string): string {
|
||||||
|
const ms = Date.now() - new Date(iso).getTime();
|
||||||
|
const minutes = Math.round(ms / 60_000);
|
||||||
|
if (minutes < 1) return "just now";
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.round(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
return `${Math.round(hours / 24)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionSelect({ sessions, onSelect }: Props) {
|
||||||
|
const items = [
|
||||||
|
{ label: "Start a new conversation", value: "__new__" },
|
||||||
|
...sessions.map((s) => ({
|
||||||
|
label: `${s.title} (${s.model}, ${s.messageCount} msgs, ${relativeTime(s.updatedAt)})`,
|
||||||
|
value: s.id,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" justifyContent="center" alignItems="center" height="100%" width="100%">
|
||||||
|
<Box borderStyle="round" borderColor={ACCENT_HEX} flexDirection="column" paddingX={2} width={80}>
|
||||||
|
<Text bold color={ACCENT_HEX}>
|
||||||
|
✻ locode
|
||||||
|
</Text>
|
||||||
|
<Text> </Text>
|
||||||
|
<Text dimColor>Resume a conversation, or start fresh:</Text>
|
||||||
|
<Text> </Text>
|
||||||
|
<SelectInput items={items} onSelect={(item) => onSelect(item.value === "__new__" ? null : item.value)} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ const MODE_LABELS: Record<PermissionMode, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const MODE_COLORS: Record<PermissionMode, string> = {
|
const MODE_COLORS: Record<PermissionMode, string> = {
|
||||||
default: "gray",
|
default: "yellow",
|
||||||
"auto-edit": "yellow",
|
"auto-edit": "yellow",
|
||||||
"auto-accept": "red",
|
"auto-accept": "red",
|
||||||
};
|
};
|
||||||
@@ -19,24 +19,47 @@ interface Props {
|
|||||||
mode: string;
|
mode: string;
|
||||||
permMode: PermissionMode;
|
permMode: PermissionMode;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
contextTokens: number;
|
||||||
|
contextWindow: number;
|
||||||
|
contextIsEstimate: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatusBar({ model, mode, permMode, cwd }: Props) {
|
// Always a legible, visible color — never dimmed, since this is one of the most-referenced fields.
|
||||||
|
function contextColor(percent: number): string {
|
||||||
|
if (percent >= 85) return "red";
|
||||||
|
if (percent >= 60) return "yellow";
|
||||||
|
return "green";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTokenCount(n: number): string {
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
|
||||||
|
if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}K`;
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBar({ model, mode, permMode, cwd, contextTokens, contextWindow, contextIsEstimate }: Props) {
|
||||||
// Show just the last segment of cwd for brevity
|
// Show just the last segment of cwd for brevity
|
||||||
const shortCwd = cwd.split(/[/\\]/).pop() ?? cwd;
|
const shortCwd = cwd.split(/[/\\]/).pop() ?? cwd;
|
||||||
|
const percent = Math.round((contextTokens / contextWindow) * 100);
|
||||||
|
const approx = contextIsEstimate ? "~" : "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="row" width="100%" justifyContent="space-between" paddingX={1}>
|
<Box flexDirection="row" width="100%" justifyContent="space-between" paddingX={1}>
|
||||||
<Box gap={1}>
|
<Box gap={1}>
|
||||||
<Text dimColor>{model}</Text>
|
<Text color="yellow">{model}</Text>
|
||||||
<Text dimColor>·</Text>
|
<Text dimColor>·</Text>
|
||||||
<Text dimColor>{mode}</Text>
|
<Text color="yellow">{mode}</Text>
|
||||||
<Text dimColor>·</Text>
|
<Text dimColor>·</Text>
|
||||||
<Text dimColor>{shortCwd}</Text>
|
<Text color="yellow">{shortCwd}</Text>
|
||||||
<Text dimColor>·</Text>
|
<Text dimColor>·</Text>
|
||||||
<Text color={MODE_COLORS[permMode]}>{MODE_LABELS[permMode]}</Text>
|
<Text color={MODE_COLORS[permMode]}>{MODE_LABELS[permMode]}</Text>
|
||||||
|
<Text dimColor>·</Text>
|
||||||
|
<Text color={contextColor(percent)}>
|
||||||
|
ctx {approx}
|
||||||
|
{formatTokenCount(contextTokens)}/{formatTokenCount(contextWindow)} ({percent}%)
|
||||||
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Text dimColor>/perm · /help · /exit</Text>
|
<Text color="yellow">/compact · /perm · /help · /exit</Text>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-1
@@ -1,6 +1,8 @@
|
|||||||
import { render } from "ink";
|
import { render } from "ink";
|
||||||
import { makeClient } from "../../backend/client.js";
|
import { makeClient } from "../../backend/client.js";
|
||||||
import type { ToolCallMode } from "../../backend/capabilityProbe.js";
|
import type { ToolCallMode } from "../../backend/capabilityProbe.js";
|
||||||
|
import { connectConfiguredMcpServers, disconnectAllMcpServers } from "../../mcp/manager.js";
|
||||||
|
import type { ToolDef } from "../../tools/types.js";
|
||||||
import { App } from "./App.js";
|
import { App } from "./App.js";
|
||||||
|
|
||||||
export interface RunInkAppOptions {
|
export interface RunInkAppOptions {
|
||||||
@@ -9,6 +11,8 @@ export interface RunInkAppOptions {
|
|||||||
suggestedModel?: string;
|
suggestedModel?: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
toolModeOverride?: ToolCallMode;
|
toolModeOverride?: ToolCallMode;
|
||||||
|
resumeSessionId?: string;
|
||||||
|
interactiveResume?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runInkApp(opts: RunInkAppOptions): Promise<void> {
|
export async function runInkApp(opts: RunInkAppOptions): Promise<void> {
|
||||||
@@ -19,6 +23,10 @@ export async function runInkApp(opts: RunInkAppOptions): Promise<void> {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kick off MCP server connections immediately so they overlap with model listing/picking —
|
||||||
|
// by the time a session is actually created, this is usually already settled.
|
||||||
|
const mcpToolsPromise: Promise<ToolDef[]> = connectConfiguredMcpServers(opts.cwd).catch(() => []);
|
||||||
|
|
||||||
// Start the UI immediately — no blocking network calls before rendering.
|
// Start the UI immediately — no blocking network calls before rendering.
|
||||||
// Model listing happens inside the App component so the user sees the UI right away.
|
// Model listing happens inside the App component so the user sees the UI right away.
|
||||||
const instance = render(
|
const instance = render(
|
||||||
@@ -28,7 +36,11 @@ export async function runInkApp(opts: RunInkAppOptions): Promise<void> {
|
|||||||
toolModeOverride={opts.toolModeOverride}
|
toolModeOverride={opts.toolModeOverride}
|
||||||
initialModel={opts.model}
|
initialModel={opts.model}
|
||||||
suggestedModel={opts.suggestedModel}
|
suggestedModel={opts.suggestedModel}
|
||||||
|
resumeSessionId={opts.resumeSessionId}
|
||||||
|
interactiveResume={opts.interactiveResume}
|
||||||
|
mcpToolsPromise={mcpToolsPromise}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await instance.waitUntilExit();
|
await instance.waitUntilExit();
|
||||||
}
|
await disconnectAllMcpServers();
|
||||||
|
}
|
||||||
|
|||||||
+18
-4
@@ -1,6 +1,18 @@
|
|||||||
export type HistoryItem =
|
export type HistoryItem =
|
||||||
| { id: string; kind: "banner"; cwd: string; model?: string; backend?: string }
|
| { id: string; kind: "banner"; cwd: string; model?: string; backend?: string; resumedTitle?: string }
|
||||||
| { id: string; kind: "status"; model: string; baseURL: string; mode: string; cwd: string }
|
| {
|
||||||
|
id: string;
|
||||||
|
kind: "status";
|
||||||
|
model: string;
|
||||||
|
baseURL: string;
|
||||||
|
mode: string;
|
||||||
|
cwd: string;
|
||||||
|
sessionId: string;
|
||||||
|
contextTokens: number;
|
||||||
|
contextWindow: number;
|
||||||
|
contextTokensIsEstimate: boolean;
|
||||||
|
contextWindowIsEstimate: boolean;
|
||||||
|
}
|
||||||
| { id: string; kind: "user"; text: string }
|
| { id: string; kind: "user"; text: string }
|
||||||
| { id: string; kind: "assistant"; text: string }
|
| { id: string; kind: "assistant"; text: string }
|
||||||
| { id: string; kind: "streaming_text"; text: string }
|
| { id: string; kind: "streaming_text"; text: string }
|
||||||
@@ -8,8 +20,10 @@ export type HistoryItem =
|
|||||||
| { id: string; kind: "tool_result"; summary: string; isError: boolean }
|
| { id: string; kind: "tool_result"; summary: string; isError: boolean }
|
||||||
| { id: string; kind: "notice"; text: string; isError?: boolean }
|
| { id: string; kind: "notice"; text: string; isError?: boolean }
|
||||||
| { id: string; kind: "help" }
|
| { id: string; kind: "help" }
|
||||||
| { id: string; kind: "tools" }
|
| { id: string; kind: "tools"; tools: import("../../tools/types.js").ToolDef[] }
|
||||||
| { id: string; kind: "permissions"; allowed: string[] };
|
| { id: string; kind: "permissions"; allowed: string[] }
|
||||||
|
| { id: string; kind: "sessions"; sessions: import("../../persistence/sessionStore.js").SessionSummary[] }
|
||||||
|
| { id: string; kind: "mcp"; statuses: import("../../mcp/manager.js").McpServerStatus[] };
|
||||||
|
|
||||||
/** Plain Omit<Union, K> collapses to keys common to every member; this distributes over each branch instead. */
|
/** Plain Omit<Union, K> collapses to keys common to every member; this distributes over each branch instead. */
|
||||||
export type NewHistoryItem = HistoryItem extends infer T
|
export type NewHistoryItem = HistoryItem extends infer T
|
||||||
|
|||||||
@@ -7,6 +7,15 @@ export function formatCallLabel(name: string, args: unknown): string {
|
|||||||
return `List(${a.pattern ?? ""})`;
|
return `List(${a.pattern ?? ""})`;
|
||||||
case "grep":
|
case "grep":
|
||||||
return `Grep(${a.pattern ?? ""})`;
|
return `Grep(${a.pattern ?? ""})`;
|
||||||
|
case "web_search":
|
||||||
|
return `WebSearch(${a.query ?? ""})`;
|
||||||
|
case "web_fetch":
|
||||||
|
return `WebFetch(${a.url ?? ""})`;
|
||||||
|
case "agent":
|
||||||
|
return `Agent(${a.description ?? ""})`;
|
||||||
|
case "git_status":
|
||||||
|
case "git_commit":
|
||||||
|
return `Git(${a.operation ?? ""})`;
|
||||||
case "write_file":
|
case "write_file":
|
||||||
return `Write(${a.path ?? ""})`;
|
return `Write(${a.path ?? ""})`;
|
||||||
case "edit_file":
|
case "edit_file":
|
||||||
@@ -33,6 +42,19 @@ export function summarizeToolResult(toolName: string, result: unknown): string {
|
|||||||
return Array.isArray(r.matches) ? `Found ${r.matches.length} file(s)` : "Listed files";
|
return Array.isArray(r.matches) ? `Found ${r.matches.length} file(s)` : "Listed files";
|
||||||
case "grep":
|
case "grep":
|
||||||
return Array.isArray(r.matches) ? `Found ${r.matches.length} match(es)` : "Searched";
|
return Array.isArray(r.matches) ? `Found ${r.matches.length} match(es)` : "Searched";
|
||||||
|
case "web_search":
|
||||||
|
return Array.isArray(r.results) ? `Found ${r.results.length} result(s)` : "Searched web";
|
||||||
|
case "web_fetch":
|
||||||
|
return typeof r.status === "number" ? `Fetched (HTTP ${r.status})` : "Fetched page";
|
||||||
|
case "agent":
|
||||||
|
return typeof r.result === "string" ? `Sub-agent finished (${r.result.length} chars)` : "Sub-agent finished";
|
||||||
|
case "git_status":
|
||||||
|
return typeof r.output === "string" ? `${r.output.split("\n").filter(Boolean).length} line(s)` : "Ran git query";
|
||||||
|
case "git_commit":
|
||||||
|
if (Array.isArray(r.staged)) return `Staged ${r.staged.length} path(s)`;
|
||||||
|
if (typeof r.created === "string") return `Created branch "${r.created}"`;
|
||||||
|
if (typeof r.switchedTo === "string") return `Switched to "${r.switchedTo}"`;
|
||||||
|
return "Done";
|
||||||
case "write_file":
|
case "write_file":
|
||||||
return typeof r.bytesWritten === "number" ? `Wrote ${r.bytesWritten} bytes` : "Wrote file";
|
return typeof r.bytesWritten === "number" ? `Wrote ${r.bytesWritten} bytes` : "Wrote file";
|
||||||
case "edit_file":
|
case "edit_file":
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
export const USER_AGENT =
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36";
|
||||||
|
|
||||||
|
export function decodeEntities(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/ /g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripTags(html: string): string {
|
||||||
|
return decodeEntities(html.replace(/<[^>]+>/g, "")).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strips script/style/head content and tags from an HTML document, collapsing it to readable text. */
|
||||||
|
export function htmlToText(html: string): string {
|
||||||
|
const withoutNonContent = html
|
||||||
|
.replace(/<head[\s\S]*?<\/head>/gi, "")
|
||||||
|
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
||||||
|
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
||||||
|
.replace(/<!--[\s\S]*?-->/g, "");
|
||||||
|
const withBreaks = withoutNonContent
|
||||||
|
.replace(/<\/(p|div|li|h[1-6]|tr|section|article|br)>/gi, "\n")
|
||||||
|
.replace(/<br\s*\/?>/gi, "\n");
|
||||||
|
return stripTags(withBreaks)
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withFetchTimeout(ms: number): { signal: AbortSignal; clear: () => void } {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), ms);
|
||||||
|
return { signal: controller.signal, clear: () => clearTimeout(timeout) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
|
||||||
|
|
||||||
|
/** Rough token estimate (~4 chars/token) used when the backend doesn't report real usage stats
|
||||||
|
* (via `stream_options: { include_usage: true }`) or before any turn has run yet. */
|
||||||
|
export function estimateTokens(messages: ChatCompletionMessageParam[]): number {
|
||||||
|
const chars = JSON.stringify(messages).length;
|
||||||
|
return Math.ceil(chars / 4);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user